From dc3fc443b484b9f56749a5d47647de0702518cd4 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 27 Apr 2026 15:28:55 +0200 Subject: [PATCH] refactor(data): retire legacy TaskStatus values and backfill existing rows Slice 6 of the worker state and queue consolidation refactor. * Drop Manual, Planning, Planned, Draft, Waiting from the TaskStatus enum and from the EF value converter; only the lifecycle values remain (Idle, Queued, Running, Done, Failed, Cancelled). * Add migration RetireLegacyTaskStatus that rewrites existing rows: manual/draft -> idle, planning -> idle+planning_phase=active, planned -> idle+planning_phase=finalized, waiting -> queued+blocked_by derived from sort_order via a CTE with LAG(). * Reroute every call site that compared/set legacy values to the new three-field model (Status + PlanningPhase + BlockedByTaskId), including the planning repo helpers, MCP services, the planning chain coordinator, and the UI view-models. TaskRowViewModel now exposes PlanningPhase to drive the planning badge. * Refresh Worker/CLAUDE.md and Data/CLAUDE.md, the docs/plan.md status section, and the planning verification notes in docs/open.md. --- docs/open.md | 5 +- docs/plan.md | 4 +- src/ClaudeDo.Data/CLAUDE.md | 6 +- .../Configuration/TaskEntityConfiguration.cs | 12 ---- .../20260427130058_RetireLegacyTaskStatus.cs | 51 ++++++++++++++++ src/ClaudeDo.Data/Models/TaskEntity.cs | 12 +--- .../Repositories/TaskRepository.cs | 20 ++++--- .../Islands/DetailsIslandViewModel.cs | 3 +- .../ViewModels/Islands/TaskRowViewModel.cs | 44 ++++++++------ .../Islands/TasksIslandViewModel.cs | 29 +++++---- src/ClaudeDo.Worker/CLAUDE.md | 59 ++++++++++++++++--- .../External/ExternalMcpService.cs | 6 +- .../Planning/PlanningChainCoordinator.cs | 10 +--- .../Planning/PlanningMcpService.cs | 8 +-- .../Planning/PlanningSessionManager.cs | 12 ++-- .../Planning/PlanningTokenAuth.cs | 2 +- src/ClaudeDo.Worker/State/TaskStateService.cs | 13 +--- .../ViewModels/DetailsIslandPlanningTests.cs | 6 +- .../ViewModels/TasksIslandRegroupTests.cs | 12 ++-- .../External/ExternalMcpServiceTests.cs | 2 +- .../Hub/PlanningHubTests.cs | 11 ++-- .../Planning/PlanningAggregatorTests.cs | 4 +- .../Planning/PlanningChainCoordinatorTests.cs | 15 +---- .../Planning/PlanningEndToEndTests.cs | 4 +- .../Planning/PlanningMcpServiceTests.cs | 6 +- .../PlanningMergeOrchestratorTests.cs | 10 ++-- .../Planning/PlanningSessionManagerTests.cs | 8 ++- .../Queue/QueuePickerTests.cs | 5 -- .../TaskRepositoryParentCompletionTests.cs | 19 +++--- .../TaskRepositoryPlanningTests.cs | 51 ++++++++-------- .../Repositories/TaskRepositoryTests.cs | 2 +- .../Runner/TaskRunnerParentCompletionTests.cs | 3 +- .../WorktreeMaintenanceServiceTests.cs | 2 +- .../State/TaskStateServiceTests.cs | 28 +++------ .../UiVm/TaskRowViewModelPlanningTests.cs | 24 ++++---- .../UiVm/TaskRowViewModelTests.cs | 2 +- .../UiVm/TasksIslandViewModelPlanningTests.cs | 25 ++++---- 37 files changed, 306 insertions(+), 229 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260427130058_RetireLegacyTaskStatus.cs diff --git a/docs/open.md b/docs/open.md index 15baac9..8f08d58 100644 --- a/docs/open.md +++ b/docs/open.md @@ -219,10 +219,11 @@ Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure 3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`. 4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge. 5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand. -6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge. +6. Ask Claude to `finalize` — children move to `Queued` (first) and `Queued + BlockedBy` (rest); parent's `PlanningPhase` flips from `Active` to `Finalized` (`PLANNED` badge). The first child must reach `Running` automatically — no manual `WakeQueue` needed (regression-tested in `PlanningEndToEndTests`). 7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard. 8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted. **Known followups (non-blocking):** -- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush. +- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both `PlanningPhase=Active` and `Finalized`, so a finalized parent gets the amber badge. Either make the view swap `Classes.planned` when `PlanningPhase` is `Finalized`, or remove the unused style + brush. - Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed. +- `Ui.Tests` `IWorkerClient` fakes (`DetailsIslandPlanningTests`, `PlanningDiffViewModelTests`, `ConflictResolutionViewModelTests`) miss `OpenInteractiveTerminalAsync` and `QueuePlanningSubtasksAsync` — pre-existing constructor-drift; rebase fakes onto a shared abstract base. diff --git a/docs/plan.md b/docs/plan.md index e8a2338..774ddf0 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -49,7 +49,9 @@ Schema in 3NF. Keine Mehrwert-Felder (z.B. JSON-Arrays), keine transitiven Abhä - `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE - `title` TEXT NOT NULL - `description` TEXT NULL -- `status` TEXT NOT NULL — `manual` | `queued` | `running` | `done` | `failed` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt) +- `status` TEXT NOT NULL — Lifecycle-only: `idle` | `queued` | `running` | `done` | `failed` | `cancelled` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt). Planungs-Hierarchie und Chain-Blocking laufen über zwei separate Felder. +- `planning_phase` TEXT NOT NULL DEFAULT `'none'` — Parent-only Marker: `none` | `active` (Planung läuft) | `finalized` (Plan committed, Children existieren). Ein Parent kann `status='idle'` sein und gleichzeitig `planning_phase='finalized'` (für Re-Runs). +- `blocked_by_task_id` TEXT NULL REFERENCES `tasks(id)` ON DELETE SET NULL — Vorgänger in einem sequenziellen Subtask-Chain. Ein `queued`-Row mit `blocked_by_task_id IS NOT NULL` wird vom Picker übersprungen. - `scheduled_for` TIMESTAMP NULL — "nicht vor" - `result` TEXT NULL (Markdown) - `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 8d474a5..6cf100d 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio ## Models -- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes +- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration. - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable) - **TagEntity** — Id (autoincrement), Name (unique) @@ -16,7 +16,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim. -- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`, `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides) +- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), tag management (`GetEffectiveTagsAsync` — union of task + list tags), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them. - **ListRepository** — CRUD, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` - **TagRepository** — `GetOrCreateAsync` (idempotent) - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` @@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T ## Schema -Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". +Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index e503b46..f2a2913 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -17,12 +17,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration TaskStatus.Done => "done", TaskStatus.Failed => "failed", TaskStatus.Cancelled => "cancelled", - // Legacy values — kept for compat until slice 6 retires them. - TaskStatus.Manual => "manual", - TaskStatus.Planning => "planning", - TaskStatus.Planned => "planned", - TaskStatus.Draft => "draft", - TaskStatus.Waiting => "waiting", _ => throw new ArgumentOutOfRangeException(nameof(v)), }; @@ -35,12 +29,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration "done" => TaskStatus.Done, "failed" => TaskStatus.Failed, "cancelled" => TaskStatus.Cancelled, - // Legacy values — kept for compat until slice 6 retires them. - "manual" => TaskStatus.Manual, - "planning" => TaskStatus.Planning, - "planned" => TaskStatus.Planned, - "draft" => TaskStatus.Draft, - "waiting" => TaskStatus.Waiting, _ => throw new ArgumentOutOfRangeException(nameof(v)), }; diff --git a/src/ClaudeDo.Data/Migrations/20260427130058_RetireLegacyTaskStatus.cs b/src/ClaudeDo.Data/Migrations/20260427130058_RetireLegacyTaskStatus.cs new file mode 100644 index 0000000..00aff25 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260427130058_RetireLegacyTaskStatus.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class RetireLegacyTaskStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // manual / draft -> idle + migrationBuilder.Sql("UPDATE tasks SET status = 'idle' WHERE status IN ('manual', 'draft');"); + + // planning -> idle + planning_phase=active + migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'active' WHERE status = 'planning';"); + + // planned -> idle + planning_phase=finalized + migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'finalized' WHERE status = 'planned';"); + + // waiting -> queued + blocked_by_task_id derived from sort_order chain. + // SQLite 3.25+ supports window functions (LAG). + migrationBuilder.Sql(@" +WITH ordered AS ( + SELECT id, + LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id + FROM tasks + WHERE status = 'waiting' +) +UPDATE tasks +SET status = 'queued', + blocked_by_task_id = (SELECT prev_id FROM ordered WHERE ordered.id = tasks.id) +WHERE id IN (SELECT id FROM ordered);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Best-effort and lossy: cancelled is folded back into failed, + // (idle, finalized) -> planned, (idle, active) -> planning, + // queued + blocked_by_task_id != null -> waiting. + // Manual/Draft distinction is unrecoverable — anything previously + // 'manual' or 'draft' stays 'idle' on the way back. + migrationBuilder.Sql("UPDATE tasks SET status = 'failed' WHERE status = 'cancelled';"); + migrationBuilder.Sql("UPDATE tasks SET status = 'planned' WHERE status = 'idle' AND planning_phase = 'finalized';"); + migrationBuilder.Sql("UPDATE tasks SET status = 'planning' WHERE status = 'idle' AND planning_phase = 'active';"); + migrationBuilder.Sql("UPDATE tasks SET status = 'waiting', blocked_by_task_id = NULL WHERE status = 'queued' AND blocked_by_task_id IS NOT NULL;"); + } + } +} diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 011182d..96b39fe 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -2,22 +2,12 @@ namespace ClaudeDo.Data.Models; public enum TaskStatus { - // Lifecycle (canonical values). Idle, Queued, Running, Done, Failed, Cancelled, - - // Legacy values — kept for backwards compatibility while the worker - // layer is migrated to (Status, PlanningPhase, BlockedByTaskId). - // Removed in slice 6 of the worker-state-and-queue-consolidation refactor. - Manual, - Planning, - Planned, - Draft, - Waiting, } public enum PlanningPhase @@ -33,7 +23,7 @@ public sealed class TaskEntity public required string ListId { get; init; } public required string Title { get; set; } public string? Description { get; set; } - public TaskStatus Status { get; set; } = TaskStatus.Manual; + public TaskStatus Status { get; set; } = TaskStatus.Idle; public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None; public string? BlockedByTaskId { get; set; } public DateTime? ScheduledFor { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 31038db..bbc4748 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -141,7 +141,7 @@ public sealed class TaskRepository await _context.Tasks .Where(t => t.Id == taskId) .ExecuteUpdateAsync(s => s - .SetProperty(t => t.Status, TaskStatus.Manual) + .SetProperty(t => t.Status, TaskStatus.Idle) .SetProperty(t => t.StartedAt, (DateTime?)null) .SetProperty(t => t.FinishedAt, (DateTime?)null) .SetProperty(t => t.Result, (string?)null), ct); @@ -270,7 +270,7 @@ public sealed class TaskRepository ListId = parent.ListId, Title = title, Description = description, - Status = TaskStatus.Draft, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, ParentTaskId = parentId, @@ -355,9 +355,10 @@ public sealed class TaskRepository CancellationToken ct = default) { var affected = await _context.Tasks - .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) + .Where(t => t.Id == taskId + && t.Status == TaskStatus.Idle + && t.PlanningPhase == PlanningPhase.None) .ExecuteUpdateAsync(s => s - .SetProperty(t => t.Status, TaskStatus.Planning) .SetProperty(t => t.PlanningPhase, PlanningPhase.Active) .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); @@ -406,20 +407,23 @@ public sealed class TaskRepository var parent = await _context.Tasks .AsNoTracking() .FirstOrDefaultAsync(t => t.Id == parentId, ct); - if (parent is null || parent.Status != TaskStatus.Planning) + if (parent is null || parent.PlanningPhase != PlanningPhase.Active) { await tx.RollbackAsync(ct); return false; } + // Children created during the planning session are Status=Idle, PlanningPhase=None. await _context.Tasks - .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) + .Where(t => t.ParentTaskId == parentId + && t.Status == TaskStatus.Idle + && t.PlanningPhase == PlanningPhase.None) .ExecuteDeleteAsync(ct); await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s - .SetProperty(t => t.Status, TaskStatus.Manual) + .SetProperty(t => t.Status, TaskStatus.Idle) .SetProperty(t => t.PlanningPhase, PlanningPhase.None) .SetProperty(t => t.PlanningSessionId, (string?)null) .SetProperty(t => t.PlanningSessionToken, (string?)null) @@ -434,7 +438,7 @@ public sealed class TaskRepository CancellationToken ct = default) { var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct); - if (parent is null || parent.Status != TaskStatus.Planned) return; + if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return; var children = await _context.Tasks .Where(t => t.ParentTaskId == parentId) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 5fc5e78..9773f2d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -474,8 +474,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase foreach (var s in subs) Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); - if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning || - entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned) + if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None) { await LoadPlanningChildrenAsync(row.Id, ct); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 7049988..3a35f70 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -15,6 +15,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private bool _isMyDay; [ObservableProperty] private bool _isSelected; [ObservableProperty] private TaskStatus _status; + [ObservableProperty] private PlanningPhase _planningPhase; [ObservableProperty] private string? _branch; [ObservableProperty] private string? _diffStat; [ObservableProperty] private string? _liveTail; @@ -37,19 +38,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase public int StepsCompleted { get; init; } public bool IsChild => !string.IsNullOrEmpty(ParentTaskId); - public bool IsPlanningParent => Status == TaskStatus.Planning - || Status == TaskStatus.Planned + public bool IsPlanningParent => PlanningPhase != PlanningPhase.None || HasPlanningChildren; - public bool IsDraft => Status == TaskStatus.Draft; + public bool IsDraft => IsChild && Status == TaskStatus.Idle; - public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild; - public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning; + public bool CanOpenPlanningSession => Status == TaskStatus.Idle + && PlanningPhase == PlanningPhase.None + && !IsChild; + public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active; - public string? PlanningBadge => Status switch + public string? PlanningBadge => PlanningPhase switch { - TaskStatus.Planning => "PLANNING", - TaskStatus.Planned => "PLANNED", - _ => null, + PlanningPhase.Active => "PLANNING", + PlanningPhase.Finalized => "PLANNED", + _ => null, }; public bool HasBranch => !string.IsNullOrWhiteSpace(Branch); @@ -59,8 +61,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done; public bool IsRunning => Status == TaskStatus.Running; public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); - public bool IsWaiting => (Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId)) - || Status == TaskStatus.Waiting; + public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool HasSchedule => ScheduledFor.HasValue; public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); @@ -71,13 +72,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch { - (TaskStatus.Running, _) => "running", - (TaskStatus.Failed, _) => "error", - (TaskStatus.Done, _) => "review", + (TaskStatus.Running, _) => "running", + (TaskStatus.Failed, _) => "error", + (TaskStatus.Done, _) => "review", (TaskStatus.Queued, true) => "waiting", (TaskStatus.Queued, false) => "queued", - (TaskStatus.Waiting, _) => "waiting", - _ => "idle", + _ => "idle", }; partial void OnStatusChanged(TaskStatus value) @@ -87,14 +87,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsWaiting)); OnPropertyChanged(nameof(HasLiveTail)); - OnPropertyChanged(nameof(IsPlanningParent)); - OnPropertyChanged(nameof(PlanningBadge)); OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(CanOpenPlanningSession)); - OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); OnPropertyChanged(nameof(CanRemoveFromQueue)); } + partial void OnPlanningPhaseChanged(PlanningPhase value) + { + OnPropertyChanged(nameof(IsPlanningParent)); + OnPropertyChanged(nameof(PlanningBadge)); + OnPropertyChanged(nameof(CanOpenPlanningSession)); + OnPropertyChanged(nameof(CanResumeOrDiscardPlanning)); + } + partial void OnHasQueuedSubtasksChanged(bool value) => OnPropertyChanged(nameof(CanRemoveFromQueue)); @@ -141,6 +146,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase IsStarred = t.IsStarred; IsMyDay = t.IsMyDay; Status = t.Status; + PlanningPhase = t.PlanningPhase; Branch = t.Worktree?.BranchName; DiffStat = t.Worktree?.DiffStat; ScheduledFor = t.ScheduledFor; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index eb4ad2b..96e924f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -176,7 +176,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase ct.ThrowIfCancellationRequested(); - static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned; + static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None; IEnumerable filtered = list.Kind switch { @@ -185,10 +185,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => (t.Status == TaskStatus.Queued && t.ParentTaskId == null) || - (IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && (c.Status == TaskStatus.Queued || c.Status == TaskStatus.Waiting)))), + (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))), ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => (t.Status == TaskStatus.Running && t.ParentTaskId == null) || - (IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))), + (IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))), ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null), ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id), _ => Enumerable.Empty(), @@ -437,7 +437,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { - entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Manual; + entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle; row.Status = entity.Status; await db.SaveChangesAsync(); } @@ -493,18 +493,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; - // For a planning parent the dequeue button targets queued/waiting children, - // not the parent itself (whose Status is Planning/Planned). - if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned - || entity.PlanningPhase != PlanningPhase.None) + // For a planning parent the dequeue button targets queued children + // (chain-blocked or not), not the parent itself. + if (entity.PlanningPhase != PlanningPhase.None) { var children = await db.Tasks - .Where(t => t.ParentTaskId == row.Id - && (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting)) + .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued) .ToListAsync(); foreach (var c in children) { - c.Status = TaskStatus.Manual; + c.Status = TaskStatus.Idle; c.BlockedByTaskId = null; } await db.SaveChangesAsync(); @@ -513,7 +511,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase var childRow = Items.FirstOrDefault(r => r.Id == c.Id); if (childRow is not null) { - childRow.Status = TaskStatus.Manual; + childRow.Status = TaskStatus.Idle; childRow.BlockedByTaskId = null; } } @@ -521,9 +519,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase } else { - entity.Status = TaskStatus.Manual; + entity.Status = TaskStatus.Idle; await db.SaveChangesAsync(); - row.Status = TaskStatus.Manual; + row.Status = TaskStatus.Idle; } Regroup(); UpdateSubtitle(); @@ -565,7 +563,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase [RelayCommand] private async Task OpenPlanningSessionAsync(TaskRowViewModel? row) { - if (row is null || row.Status != TaskStatus.Manual) return; + if (row is null) return; + if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return; ForegroundHelper.AllowAny(); try { await _worker!.StartPlanningSessionAsync(row.Id); } catch { } diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 3c1e873..03b63ec 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -2,15 +2,60 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments. +## Folder Layout + +``` +Worker/ + State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes) + Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService + Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService + Worktrees/ — WorktreeMaintenanceService + Agents/ — AgentFileService, DefaultAgentSeeder + Runner/ — TaskRunner + Claude CLI integration + Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService + External/ — ExternalMcpService + Hub/ — WorkerHub, HubBroadcaster +``` + ## Architecture - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` -- **QueueService** — `BackgroundService` with two execution slots: - - Queue slot: FIFO sequential processing of "agent"-tagged queued tasks - - Override slot: immediate execution via `RunNow(taskId)` - - Wake signaling via `SemaphoreSlim`, backstop timer (30s default) -- **StaleTaskRecovery** — startup-only service, flips orphaned "running" tasks to "failed" -- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header. +- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. +- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, schedule, and the `agent` tag; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. +- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). +- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. +- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header. + +## Status Model + +`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated. + +| Field | Values | Meaning | +|---|---|---| +| `Status` | `Idle`, `Queued`, `Running`, `Done`, `Failed`, `Cancelled` | Lifecycle only. | +| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | +| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. | + +Allowed transitions (enforced by `TaskStateService`): + +``` +Idle → Queued | Running (RunNow) +Queued → Running | Cancelled | Idle +Running → Done | Failed | Cancelled +Done → Idle (re-run) +Failed → Idle | Queued +Cancelled → Idle | Queued +``` + +## Planning Flow + +`PlanningSessionManager.FinalizeAsync` is the single path: + +1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`. +2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1]. +3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`. + +`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them. ## Task Execution Pipeline @@ -28,7 +73,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree). - **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser. -- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history. +- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history. - **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats - **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` - **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index e447438..720f819 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -117,7 +117,7 @@ public sealed class ExternalMcpService ListId = listId, Title = title, Description = description, - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy, @@ -167,7 +167,7 @@ public sealed class ExternalMcpService return ToDto(reload); } - [McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] + [McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] public async Task UpdateTaskStatus( string taskId, string status, @@ -181,7 +181,7 @@ public sealed class ExternalMcpService switch (target) { - case TaskStatus.Manual: + case TaskStatus.Idle: await _tasks.ResetToManualAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); break; diff --git a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs index 707fc0c..c897339 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs @@ -39,16 +39,10 @@ public sealed class PlanningChainCoordinator if (children.Count == 0) throw new InvalidOperationException("Parent has no subtasks."); - // Eligibility: new layout uses Status=Idle. Tolerate legacy Manual/Planned/Draft - // values during this slice — they will be migrated away in slice 6. - var bad = children.FirstOrDefault(c => - c.Status != TaskStatus.Idle && - c.Status != TaskStatus.Manual && - c.Status != TaskStatus.Planned && - c.Status != TaskStatus.Draft); + var bad = children.FirstOrDefault(c => c.Status != TaskStatus.Idle); if (bad is not null) throw new InvalidOperationException( - $"Child {bad.Id} is in status {bad.Status}; expected Idle (or legacy Manual/Planned/Draft)."); + $"Child {bad.Id} is in status {bad.Status}; expected Idle."); // Worker queue picker requires the "agent" tag — attach it so children are pickable. var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct); diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index c9bd9bb..ecb7307 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -49,7 +49,7 @@ public sealed class PlanningMcpService var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); - return new CreatedChildDto(child.Id, "Draft"); + return new CreatedChildDto(child.Id, child.Status.ToString()); } [McpServerTool, Description("List all child tasks under the current planning session's parent task.")] @@ -68,9 +68,9 @@ public sealed class PlanningMcpService } private static readonly TaskStatus[] EditableStatuses = - { TaskStatus.Draft, TaskStatus.Idle, TaskStatus.Manual, TaskStatus.Queued }; + { TaskStatus.Idle, TaskStatus.Queued }; - [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Idle, Manual, Queued.")] + [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Idle, Queued.")] public async Task UpdateChildTask( string taskId, string? title, @@ -97,7 +97,7 @@ public sealed class PlanningMcpService if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException($"Unknown status '{status}'."); if (!EditableStatuses.Contains(parsed)) - throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Idle, Manual, Queued."); + throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Idle, Queued."); newStatus = parsed; } diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index ae5a1d5..37dbafc 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -81,8 +81,9 @@ public sealed class PlanningSessionManager ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.ParentTaskId is not null) throw new InvalidOperationException("Cannot start a planning session on a child task."); - if (task.Status != TaskStatus.Manual) - throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); + if (task.Status != TaskStatus.Idle || task.PlanningPhase != PlanningPhase.None) + throw new InvalidOperationException( + $"Task is in status {task.Status}/{task.PlanningPhase}; only Idle+None can start planning."); var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); @@ -232,7 +233,7 @@ public sealed class PlanningSessionManager var (tasks, _, settings, ctx) = CreateRepos(); await using var __ = ctx; var children = await tasks.GetChildrenAsync(taskId, ct); - return children.Count(c => c.Status == TaskStatus.Draft); + return children.Count(c => c.Status == TaskStatus.Idle); } public async Task DiscardAsync(string taskId, CancellationToken ct) @@ -261,8 +262,9 @@ public sealed class PlanningSessionManager var task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); - if (task.Status != TaskStatus.Planning) - throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); + if (task.PlanningPhase != PlanningPhase.Active) + throw new InvalidOperationException( + $"Task planning phase is {task.PlanningPhase}; resume requires Active planning."); if (string.IsNullOrEmpty(task.PlanningSessionId)) throw new InvalidOperationException("No Claude session ID captured yet; cannot resume."); diff --git a/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs index 6cf7043..ec8a9bd 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs @@ -27,7 +27,7 @@ public sealed class PlanningTokenAuthMiddleware var token = auth.Substring("Bearer ".Length).Trim(); var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted); - if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning) + if (parent is null || parent.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.Active) { ctx.Response.StatusCode = 401; await ctx.Response.WriteAsync("Invalid or expired planning token"); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 4f9dc7d..99e6ba0 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -144,10 +144,10 @@ public sealed class TaskStateService : ITaskStateService { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var affected = await ctx.Tasks - .Where(t => t.Id == parentId && - (t.Status == TaskStatus.Manual || t.Status == TaskStatus.Idle)) + .Where(t => t.Id == parentId + && t.Status == TaskStatus.Idle + && t.PlanningPhase == PlanningPhase.None) .ExecuteUpdateAsync(s => s - .SetProperty(t => t.Status, TaskStatus.Planning) .SetProperty(t => t.PlanningPhase, PlanningPhase.Active), ct); if (affected == 0) @@ -198,13 +198,6 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task not found."); - // Bridge to legacy chain layout: a Waiting predecessor-blocked sibling becomes Queued - // when its predecessor finishes. New layout (post-Slice 4) stores siblings as - // Status=Queued + BlockedByTaskId set, so this is a no-op for them. - await ctx.Tasks - .Where(t => t.Id == taskId && t.Status == TaskStatus.Waiting) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct); - _waker.Wake(); await _broadcaster.TaskUpdated(taskId); return new TransitionResult(true, null); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs index b97f789..11a8e8a 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs @@ -175,7 +175,8 @@ public class DetailsIslandPlanningTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Parent", - Status = TaskStatus.Planning, CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active, + CreatedAt = DateTime.UtcNow, }); ctx.Tasks.Add(new TaskEntity { @@ -199,7 +200,8 @@ public class DetailsIslandPlanningTests : IDisposable // Bind triggers BindAsync → LoadPlanningChildrenAsync → GetMergeTargetsAsync var parentRow = new TaskRowViewModel { Id = parentId }; - parentRow.Status = TaskStatus.Planning; + parentRow.Status = TaskStatus.Idle; + parentRow.PlanningPhase = PlanningPhase.Active; vm.Bind(parentRow); // Wait for the background load to settle diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs index eb436a9..7ced49e 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs @@ -49,7 +49,8 @@ public class TasksIslandRegroupTests : IDisposable TaskStatus parentStatus, TaskStatus childStatus, string parentId = "p1", - string childId = "c1") + string childId = "c1", + PlanningPhase parentPhase = PlanningPhase.None) { await using var db = NewContext(); var list = new ListEntity @@ -67,6 +68,7 @@ public class TasksIslandRegroupTests : IDisposable Title = "Parent", CreatedAt = DateTime.UtcNow, Status = parentStatus, + PlanningPhase = parentPhase, SortOrder = 0, }); db.Tasks.Add(new TaskEntity @@ -110,7 +112,7 @@ public class TasksIslandRegroupTests : IDisposable public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow() { await SeedPlanningWithChildAsync( - parentStatus: TaskStatus.Planning, + parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Queued, parentId: "p1", childId: "c1"); @@ -126,7 +128,7 @@ public class TasksIslandRegroupTests : IDisposable public async Task VirtualQueued_PlannedParentWithQueuedChild_ParentIsStandaloneRow_ChildIsNot() { await SeedPlanningWithChildAsync( - parentStatus: TaskStatus.Planned, + parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Finalized, childStatus: TaskStatus.Queued, parentId: "p1", childId: "c1"); @@ -142,7 +144,7 @@ public class TasksIslandRegroupTests : IDisposable public async Task VirtualRunning_RunningChildOfPlanningParent_IsNotStandaloneRow() { await SeedPlanningWithChildAsync( - parentStatus: TaskStatus.Planning, + parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Running, parentId: "p1", childId: "c1"); @@ -158,7 +160,7 @@ public class TasksIslandRegroupTests : IDisposable public async Task Done_ChildOfOpenPlanningParent_StaysNestedUnderParent() { await SeedPlanningWithChildAsync( - parentStatus: TaskStatus.Planning, + parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Done, parentId: "p1", childId: "c1"); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index d03ffd0..13d74b5 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -74,7 +74,7 @@ public sealed class ExternalMcpServiceTests : IDisposable return id; } - private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual) + private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle) { var task = new TaskEntity { diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index a8250ee..8814315 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -75,7 +75,7 @@ public sealed class PlanningHubTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Do something", - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; @@ -96,7 +96,8 @@ public sealed class PlanningHubTests : IDisposable Assert.Equal(0, _launcher.LaunchResumeCalls); var loaded = await _tasks.GetByIdAsync(taskId); - Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.Active, loaded.PlanningPhase); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } @@ -112,7 +113,8 @@ public sealed class PlanningHubTests : IDisposable hub.StartPlanningSessionAsync(taskId)); var loaded = await _tasks.GetByIdAsync(taskId); - Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.None, loaded.PlanningPhase); var sessionDir = Path.Combine(_rootDir, taskId); Assert.False(Directory.Exists(sessionDir)); @@ -130,7 +132,8 @@ public sealed class PlanningHubTests : IDisposable await hub.DiscardPlanningSessionAsync(taskId); var loaded = await _tasks.GetByIdAsync(taskId); - Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.None, loaded.PlanningPhase); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs index b125855..993b40b 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs @@ -67,7 +67,7 @@ public class PlanningAggregatorTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planning, SortOrder = 0, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active, SortOrder = 0, }); // Two children (sorted A then B). @@ -171,7 +171,7 @@ public class PlanningAggregatorTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planning, SortOrder = 0, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active, SortOrder = 0, }); var subA = Guid.NewGuid().ToString(); var subB = Guid.NewGuid().ToString(); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs index 7476cc5..b46be59 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs @@ -32,7 +32,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable public void Dispose() => _db.Dispose(); - private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Manual) + private async Task SeedPlanningFamilyAsync(string parentId, int childCount, TaskStatus childStatus = TaskStatus.Idle) { await using var ctx = _factory.CreateDbContext(); ctx.Tasks.Add(new TaskEntity @@ -41,7 +41,8 @@ public sealed class PlanningChainCoordinatorTests : IDisposable ListId = _listId, Title = "Parent", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planned, + Status = TaskStatus.Idle, + PlanningPhase = PlanningPhase.Finalized, }); for (int i = 0; i < childCount; i++) { @@ -108,16 +109,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable Assert.Equal(2, count); } - [Fact] - public async Task SetupChain_AcceptsDraftChildren() - { - await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Draft); - - var count = await _sut.SetupChainAsync("P", default); - - Assert.Equal(2, count); - } - [Fact] public async Task OnChildDone_UnblocksTheSuccessor() { diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs index 51c5071..5acbef4 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs @@ -99,7 +99,7 @@ public sealed class PlanningEndToEndTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Big Task", - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; @@ -145,7 +145,7 @@ public sealed class PlanningEndToEndTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Parent", - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index f9e860a..74b1ae8 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -93,7 +93,7 @@ public sealed class PlanningMcpServiceTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "p", - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; @@ -110,10 +110,10 @@ public sealed class PlanningMcpServiceTests : IDisposable var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None); - Assert.Equal("Draft", result.Status); + Assert.Equal("Idle", result.Status); var child = await _tasks.GetByIdAsync(result.TaskId); Assert.Equal("My child", child!.Title); - Assert.Equal(TaskStatus.Draft, child.Status); + Assert.Equal(TaskStatus.Idle, child.Status); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index a1d7b43..e0fc975 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -101,7 +101,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planned, SortOrder = 0, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Finalized, SortOrder = 0, }); var subA = Guid.NewGuid().ToString(); @@ -169,7 +169,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planned, SortOrder = 0, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Finalized, SortOrder = 0, }); var subA = Guid.NewGuid().ToString(); var subB = Guid.NewGuid().ToString(); @@ -232,7 +232,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable using var ctx = db.CreateContext(); // Planning stays in Planned — NOT flipped to Done. - Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(PlanningPhase.Finalized, ctx.Tasks.Single(t => t.Id == parentId).PlanningPhase); // Earlier successful merge stays merged. Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State); // Conflicted subtask's worktree stays Active (abort doesn't flip it). @@ -280,7 +280,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable Assert.Contains(runningSub, ex.Message); using var ctx = db.CreateContext(); - Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(PlanningPhase.Finalized, ctx.Tasks.Single(t => t.Id == parentId).PlanningPhase); Assert.Empty(spy); } @@ -337,7 +337,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, - Status = TaskStatus.Planned, SortOrder = 0, + Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Finalized, SortOrder = 0, }); var running = Guid.NewGuid().ToString(); ctx.Tasks.Add(new TaskEntity diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index 79875e1..ab377fd 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -67,7 +67,7 @@ public sealed class PlanningSessionManagerTests : IDisposable ListId = listId, Title = "Brainstorm auth", Description = "- review tokens\n- plan rollout", - Status = TaskStatus.Manual, + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; @@ -101,7 +101,8 @@ public sealed class PlanningSessionManagerTests : IDisposable Assert.Contains("review tokens", initial); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.Active, loaded.PlanningPhase); Assert.NotNull(loaded.PlanningSessionToken); } @@ -220,7 +221,8 @@ public sealed class PlanningSessionManagerTests : IDisposable Assert.False(Directory.Exists(startCtx.Files.SessionDirectory)); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.None, loaded.PlanningPhase); Assert.Null(loaded.PlanningSessionToken); } diff --git a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index 0ca4772..4ab9a5a 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -127,11 +127,6 @@ public sealed class QueuePickerTests : IDisposable await SeedAsync(listId, status: TaskStatus.Done); await SeedAsync(listId, status: TaskStatus.Failed); await SeedAsync(listId, status: TaskStatus.Cancelled); - await SeedAsync(listId, status: TaskStatus.Manual); - await SeedAsync(listId, status: TaskStatus.Draft); - await SeedAsync(listId, status: TaskStatus.Planning); - await SeedAsync(listId, status: TaskStatus.Planned); - await SeedAsync(listId, status: TaskStatus.Waiting); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs index 1c2e563..fa7c439 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs @@ -37,7 +37,8 @@ public sealed class TaskRepositoryParentCompletionTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "p", - Status = TaskStatus.Planned, + Status = TaskStatus.Idle, + PlanningPhase = PlanningPhase.Finalized, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; @@ -102,35 +103,37 @@ public sealed class TaskRepositoryParentCompletionTests : IDisposable await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + Assert.Equal(PlanningPhase.Finalized, loaded.PlanningPhase); Assert.Null(loaded.FinishedAt); } [Fact] - public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned() + public async Task TryCompleteParentAsync_ChildStillIdle_ParentStaysFinalized() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await ChildAsync(listId, parent.Id, TaskStatus.Done); - await ChildAsync(listId, parent.Id, TaskStatus.Draft); + await ChildAsync(listId, parent.Id, TaskStatus.Idle); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase); } [Fact] - public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange() + public async Task TryCompleteParentAsync_ParentIsNotFinalized_NoChange() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); - await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id); + await _ctx.Database.ExecuteSqlRawAsync( + "UPDATE tasks SET planning_phase = 'active' WHERE id = {0}", parent.Id); await ChildAsync(listId, parent.Id, TaskStatus.Done); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal(PlanningPhase.Active, loaded!.PlanningPhase); } } diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs index 04e787d..dcabc98 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs @@ -40,12 +40,17 @@ public sealed class TaskRepositoryPlanningTests : IDisposable return listId; } - private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new() + private TaskEntity MakeTask( + string listId, + TaskStatus status = TaskStatus.Idle, + string? parentId = null, + PlanningPhase phase = PlanningPhase.None) => new() { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, + PlanningPhase = phase, CreatedAt = DateTime.UtcNow, CommitType = "feat", ParentTaskId = parentId, @@ -55,23 +60,23 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted() { var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Planning); + var parent = MakeTask(listId, phase: PlanningPhase.Active); parent.Title = "parent"; await _tasks.AddAsync(parent); - var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + var childA = MakeTask(listId, parentId: parent.Id); childA.Title = "a"; await _tasks.AddAsync(childA); childA.SortOrder = 1; await _tasks.UpdateAsync(childA); - var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); + var childB = MakeTask(listId, parentId: parent.Id); childB.Title = "b"; await _tasks.AddAsync(childB); childB.SortOrder = 0; await _tasks.UpdateAsync(childB); - var unrelated = MakeTask(listId, TaskStatus.Manual); + var unrelated = MakeTask(listId); await _tasks.AddAsync(unrelated); var children = await _tasks.GetChildrenAsync(parent.Id); @@ -82,10 +87,10 @@ public sealed class TaskRepositoryPlanningTests : IDisposable } [Fact] - public async Task CreateChildAsync_CreatesDraftUnderParent() + public async Task CreateChildAsync_CreatesIdleChildUnderParent() { var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Planning); + var parent = MakeTask(listId, phase: PlanningPhase.Active); await _tasks.AddAsync(parent); var child = await _tasks.CreateChildAsync( @@ -95,7 +100,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable tagNames: new[] { "agent" }, commitType: "feat"); - Assert.Equal(TaskStatus.Draft, child.Status); + Assert.Equal(TaskStatus.Idle, child.Status); Assert.Equal(parent.Id, child.ParentTaskId); Assert.Equal(listId, child.ListId); Assert.Equal("child title", child.Title); @@ -104,7 +109,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable var loaded = await _tasks.GetByIdAsync(child.Id); Assert.NotNull(loaded); - Assert.Equal(TaskStatus.Draft, loaded!.Status); + Assert.Equal(TaskStatus.Idle, loaded!.Status); var tags = await _tasks.GetTagsAsync(child.Id); Assert.Contains(tags, t => t.Name == "agent"); @@ -114,32 +119,33 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task CreateChildAsync_ThrowsIfParentNotFound() { var listId = await CreateListAsync(); - _ = listId; // just to create the DB + _ = listId; await Assert.ThrowsAsync(() => _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null)); } [Fact] - public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning() + public async Task SetPlanningStartedAsync_IdleTask_TransitionsToActivePhase() { var listId = await CreateListAsync(); - var task = MakeTask(listId, TaskStatus.Manual); + var task = MakeTask(listId); await _tasks.AddAsync(task); var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc"); Assert.NotNull(result); - Assert.Equal(TaskStatus.Planning, result!.Status); + Assert.Equal(TaskStatus.Idle, result!.Status); + Assert.Equal(PlanningPhase.Active, result.PlanningPhase); Assert.Equal("tok-abc", result.PlanningSessionToken); var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.Equal(PlanningPhase.Active, loaded!.PlanningPhase); Assert.Equal("tok-abc", loaded.PlanningSessionToken); } [Fact] - public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull() + public async Task SetPlanningStartedAsync_NonIdleTask_ReturnsNull() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Queued); @@ -157,7 +163,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId() { var listId = await CreateListAsync(); - var task = MakeTask(listId, TaskStatus.Manual); + var task = MakeTask(listId); await _tasks.AddAsync(task); await _tasks.SetPlanningStartedAsync(task.Id, "tok"); @@ -171,7 +177,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches() { var listId = await CreateListAsync(); - var task = MakeTask(listId, TaskStatus.Manual); + var task = MakeTask(listId); await _tasks.AddAsync(task); await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123"); @@ -192,7 +198,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent() { var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Manual); + var parent = MakeTask(listId); await _tasks.AddAsync(parent); await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42"); @@ -206,7 +212,8 @@ public sealed class TaskRepositoryPlanningTests : IDisposable Assert.Null(await _tasks.GetByIdAsync(c2.Id)); var parentLoaded = await _tasks.GetByIdAsync(parent.Id); - Assert.Equal(TaskStatus.Manual, parentLoaded!.Status); + Assert.Equal(TaskStatus.Idle, parentLoaded!.Status); + Assert.Equal(PlanningPhase.None, parentLoaded.PlanningPhase); Assert.Null(parentLoaded.PlanningSessionId); Assert.Null(parentLoaded.PlanningSessionToken); Assert.Null(parentLoaded.PlanningFinalizedAt); @@ -216,7 +223,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse() { var listId = await CreateListAsync(); - var task = MakeTask(listId, TaskStatus.Manual); + var task = MakeTask(listId); await _tasks.AddAsync(task); var ok = await _tasks.DiscardPlanningAsync(task.Id); @@ -228,12 +235,10 @@ public sealed class TaskRepositoryPlanningTests : IDisposable public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete() { var listId = await CreateListAsync(); - var parent = MakeTask(listId, TaskStatus.Planning); + var parent = MakeTask(listId, phase: PlanningPhase.Active); await _tasks.AddAsync(parent); await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); - // ExecuteDelete bypasses EF change tracking, so SQLite's FK enforcement - // (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly. await Assert.ThrowsAsync(async () => { await _tasks.DeleteAsync(parent.Id); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs index 8516d20..d550613 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs @@ -163,7 +163,7 @@ public sealed class TaskRepositoryTests : IDisposable using var readCtx = _db.CreateContext(); var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id); Assert.NotNull(after); - Assert.Equal(TaskStatus.Manual, after!.Status); + Assert.Equal(TaskStatus.Idle, after!.Status); Assert.Null(after.StartedAt); Assert.Null(after.FinishedAt); Assert.Null(after.Result); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs index 3a2d29d..ff1d4e6 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs @@ -33,7 +33,8 @@ public sealed class TaskRunnerParentCompletionTests : IDisposable Id = Guid.NewGuid().ToString(), ListId = listId, Title = "p", - Status = TaskStatus.Planned, + Status = TaskStatus.Idle, + PlanningPhase = PlanningPhase.Finalized, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; diff --git a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs index 1bc71e5..0bb203f 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs @@ -161,7 +161,7 @@ public class WorktreeMaintenanceServiceTests : IDisposable var db = NewDb(); var (list, t1) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done); - var t2 = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Manual); + var t2 = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Idle); var wt1 = await CreateWorktreeAsync(git, repo.RepoDir, t1.Id); var wt2 = await CreateWorktreeAsync(git, repo.RepoDir, t2.Id); diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index e5a1594..4a32769 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -40,7 +40,8 @@ public sealed class TaskStateServiceTests : IDisposable TaskStatus status, string? parentId = null, int sortOrder = 0, - string? blockedBy = null) + string? blockedBy = null, + PlanningPhase phase = PlanningPhase.None) { var id = Guid.NewGuid().ToString(); await using var ctx = _factory.CreateDbContext(); @@ -50,6 +51,7 @@ public sealed class TaskStateServiceTests : IDisposable ListId = _listId, Title = "task", Status = status, + PlanningPhase = phase, CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, SortOrder = sortOrder, @@ -256,15 +258,15 @@ public sealed class TaskStateServiceTests : IDisposable // ─── StartPlanningAsync ─────────────────────────────────────────────── [Fact] - public async Task StartPlanningAsync_FromManual_FlipsStatus_AndPlanningPhase() + public async Task StartPlanningAsync_FromIdle_SetsPlanningPhase() { - var id = await SeedTaskAsync(TaskStatus.Manual); + var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.StartPlanningAsync(id, default); Assert.True(result.Ok); var t = await GetTaskAsync(id); - Assert.Equal(TaskStatus.Planning, t.Status); + Assert.Equal(TaskStatus.Idle, t.Status); Assert.Equal(PlanningPhase.Active, t.PlanningPhase); } @@ -283,7 +285,7 @@ public sealed class TaskStateServiceTests : IDisposable [Fact] public async Task FinalizePlanningAsync_OnActivePhase_TransitionsToFinalized() { - var id = await SeedTaskAsync(TaskStatus.Manual); + var id = await SeedTaskAsync(TaskStatus.Idle); await _sut.StartPlanningAsync(id, default); var result = await _sut.FinalizePlanningAsync(id, default); @@ -297,7 +299,7 @@ public sealed class TaskStateServiceTests : IDisposable [Fact] public async Task FinalizePlanningAsync_OnNonePhase_Rejects() { - var id = await SeedTaskAsync(TaskStatus.Manual); + var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.FinalizePlanningAsync(id, default); @@ -335,18 +337,6 @@ public sealed class TaskStateServiceTests : IDisposable Assert.True(_built.WakeCount() > wakesBefore); } - [Fact] - public async Task UnblockAsync_OnWaitingTask_FlipsToQueued() - { - // Bridge to legacy chain layout: a Status=Waiting sibling becomes Queued on unblock. - var task = await SeedTaskAsync(TaskStatus.Waiting); - - var result = await _sut.UnblockAsync(task, default); - - Assert.True(result.Ok); - Assert.Equal(TaskStatus.Queued, await GetStatusAsync(task)); - } - // ─── RecoverStaleRunningAsync ───────────────────────────────────────── [Fact] @@ -371,7 +361,7 @@ public sealed class TaskStateServiceTests : IDisposable [Fact] public async Task CompleteAsync_OnChild_AdvancesNextBlockedSibling() { - var parent = await SeedTaskAsync(TaskStatus.Planned); + var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0); var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0); var c2 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 2, blockedBy: c1); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs index 8ac6d38..7092c7a 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs @@ -7,38 +7,42 @@ namespace ClaudeDo.Worker.Tests.UiVm; public class TaskRowViewModelPlanningTests { - private static TaskRowViewModel MakeRow(TaskStatus status, string? parentTaskId = null) - => new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId }; + private static TaskRowViewModel MakeRow( + TaskStatus status, + string? parentTaskId = null, + PlanningPhase phase = PlanningPhase.None) + => new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId, PlanningPhase = phase }; [Fact] - public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull() + public void IdleChild_IsDraft_WhenParentIdIsNotNull() { - var vm = MakeRow(TaskStatus.Draft, "parent-id"); + var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id"); Assert.True(vm.IsChild); + Assert.True(vm.IsDraft); Assert.False(vm.IsPlanningParent); } [Fact] - public void Planning_Status_SetsIsPlanningParent() + public void ActivePlanning_SetsIsPlanningParent() { - var vm = MakeRow(TaskStatus.Planning); + var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active); Assert.True(vm.IsPlanningParent); Assert.False(vm.IsChild); Assert.Equal("PLANNING", vm.PlanningBadge); } [Fact] - public void Planned_Status_ShowsPlannedBadge() + public void FinalizedPlanning_ShowsPlannedBadge() { - var vm = MakeRow(TaskStatus.Planned); + var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized); Assert.True(vm.IsPlanningParent); Assert.Equal("PLANNED", vm.PlanningBadge); } [Fact] - public void NonPlanningStatus_NoBadge() + public void PlainIdle_NoBadge() { - var vm = MakeRow(TaskStatus.Manual); + var vm = MakeRow(TaskStatus.Idle); Assert.False(vm.IsPlanningParent); Assert.Null(vm.PlanningBadge); } diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs index 67e1e61..c3e0474 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs @@ -12,7 +12,7 @@ public class TaskRowViewModelTests [InlineData(TaskStatus.Failed, "error")] [InlineData(TaskStatus.Done, "review")] [InlineData(TaskStatus.Queued, "queued")] - [InlineData(TaskStatus.Manual, "idle")] + [InlineData(TaskStatus.Idle, "idle")] public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected) { var vm = new TaskRowViewModel { Id = "t" }; diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 1e91f95..a7f1aef 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -96,15 +96,20 @@ file static class VmFactory public class TasksIslandViewModelPlanningTests { - private static TaskRowViewModel MakeRow(string id, TaskStatus status, string? parentId = null, int sortOrder = 0) - => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId }; + private static TaskRowViewModel MakeRow( + string id, + TaskStatus status, + string? parentId = null, + int sortOrder = 0, + PlanningPhase phase = PlanningPhase.None) + => new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase }; [Fact] public void ToggleExpand_CollapsesChildrenOfPlanningParent() { - var parent = MakeRow("p1", TaskStatus.Planning); - var child1 = MakeRow("c1", TaskStatus.Draft, "p1"); - var child2 = MakeRow("c2", TaskStatus.Draft, "p1"); + var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active); + var child1 = MakeRow("c1", TaskStatus.Idle, "p1"); + var child2 = MakeRow("c2", TaskStatus.Idle, "p1"); var (vm, _) = VmFactory.Create([parent, child1, child2]); @@ -123,7 +128,7 @@ public class TasksIslandViewModelPlanningTests } [Fact] - public async Task OpenPlanningSession_IgnoresNonManualRow() + public async Task OpenPlanningSession_IgnoresNonIdleRow() { var row = MakeRow("t1", TaskStatus.Queued); var (vm, worker) = VmFactory.Create([row]); @@ -134,9 +139,9 @@ public class TasksIslandViewModelPlanningTests } [Fact] - public async Task OpenPlanningSession_CallsWorkerForManualRow() + public async Task OpenPlanningSession_CallsWorkerForIdleRow() { - var row = MakeRow("t1", TaskStatus.Manual); + var row = MakeRow("t1", TaskStatus.Idle); var (vm, worker) = VmFactory.Create([row]); await ((IAsyncRelayCommand)vm.OpenPlanningSessionCommand).ExecuteAsync(row); @@ -147,8 +152,8 @@ public class TasksIslandViewModelPlanningTests [Fact] public void ToggleExpand_ExpandsCollapsedParentAgain() { - var parent = MakeRow("p1", TaskStatus.Planned); - var child = MakeRow("c1", TaskStatus.Draft, "p1"); + var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized); + var child = MakeRow("c1", TaskStatus.Idle, "p1"); var (vm, _) = VmFactory.Create([parent, child]);