feat(mission-control): give the list handler its own review task
"Let Claude handle it" now creates one ClaudeDo task per run to host the ConPTY session (Idle/IsManual, never queued) instead of an untracked ad-hoc tile, so the run has a real title, diff, and review outcome. Since the handler merges its own changes straight into the list's working dir, the task never gets a WorktreeEntity; its review range lives as new HandlerBaseCommit/HandlerHeadCommit columns on TaskEntity instead, reusing the existing commit-range diff machinery and keeping it out of the worktrees overview entirely.
This commit is contained in:
@@ -65,6 +65,19 @@ Offene Entscheidungen dazu:
|
||||
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
|
||||
landing in task descriptions, queued execution, merges).
|
||||
|
||||
## Offene Verifikation (2026-08-05)
|
||||
|
||||
- **List handler owns a task (2026-08-05)** — build + unit tests all green, but **not visually
|
||||
verified**: start "Let Claude handle it" on a list, confirm exactly one new task appears in that
|
||||
list (`Idle`, MANUAL badge, title "List handler: <list>"), the Mission Control tile is
|
||||
task-based (Submit for review button present), Submit for review flips it to
|
||||
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
|
||||
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
|
||||
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
|
||||
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
|
||||
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
||||
specifically so `list_worktrees`/the Worktrees overview never see it.
|
||||
|
||||
---
|
||||
|
||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||
|
||||
@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||
@@ -44,7 +44,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
|
||||
## Schema
|
||||
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
|
||||
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
|
||||
+810
@@ -0,0 +1,810 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260805065217_AddHandlerCommitRange")]
|
||||
partial class AddHandlerCommitRange
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHandlerCommitRange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "handler_base_commit",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "handler_head_commit",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "handler_base_commit",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "handler_head_commit",
|
||||
table: "tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,14 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -51,6 +51,15 @@ public sealed class TaskEntity
|
||||
public int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
// Review range for a worktree-less task hosting an interactive "list handler" run
|
||||
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
|
||||
// straight to the list's working dir, so there is no per-task worktree to diff. These
|
||||
// capture the repo's HEAD at session start / submit-for-review instead, so the normal
|
||||
// diff/get_task_diff paths can show `HandlerBaseCommit..HandlerHeadCommit` over the
|
||||
// list's working dir exactly like a merged task's commit-range diff.
|
||||
public string? HandlerBaseCommit { get; set; }
|
||||
public string? HandlerHeadCommit { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
public string? PlanningSessionToken { get; set; }
|
||||
|
||||
@@ -384,6 +384,19 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
||||
}
|
||||
|
||||
// Stamps the review range's head commit for a worktree-less "list handler" host task
|
||||
// (Mission Control's "Let Claude handle it") when its ConPTY session is submitted for review.
|
||||
public async Task SetHandlerHeadCommitAsync(
|
||||
string taskId,
|
||||
string headCommit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
||||
string token,
|
||||
CancellationToken ct = default)
|
||||
|
||||
@@ -284,6 +284,8 @@
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Von diesem Lauf bearbeitete Tasks:",
|
||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
||||
|
||||
@@ -284,6 +284,8 @@
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
"mergeHelperTaskDescriptionHeader": "Tasks handled by this run:",
|
||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||
"submitForReview": "Submit for review",
|
||||
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
||||
|
||||
@@ -39,7 +39,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync` opens an ad-hoc ConPTY tile running the five-phase handler prompt), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
||||
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
|
||||
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
|
||||
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -525,6 +525,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
||||
|
||||
public async Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
|
||||
|
||||
|
||||
@@ -571,8 +571,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Model = entity.Model;
|
||||
_listWorkingDir = entity.List?.WorkingDir;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||
// A worktree-less list-handler host task (Mission Control's "Let Claude handle it")
|
||||
// has no WorktreeEntity, so its review range falls back to HandlerBaseCommit/
|
||||
// HandlerHeadCommit -- see TaskEntity for why.
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
@@ -778,8 +781,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
_listWorkingDir = entity.List?.WorkingDir;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||
// A worktree-less list-handler host task (Mission Control's "Let Claude handle it")
|
||||
// has no WorktreeEntity, so its review range falls back to HandlerBaseCommit/
|
||||
// HandlerHeadCommit -- see TaskEntity for why.
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||
|
||||
@@ -42,8 +42,11 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
public bool ShowMergePreviewMuted =>
|
||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||
|
||||
// CanDiffMergedRange covers a worktree-less list-handler host task (no live worktree, but
|
||||
// a HandlerBaseCommit/HandlerHeadCommit review range over the list's working dir) so its
|
||||
// merge/diff card still renders even though _worktreePath stays null for it.
|
||||
public bool ShowMergeSection =>
|
||||
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
|
||||
_worktreePath != null || _isPlanningParent || _hasChildOutcomes || CanDiffMergedRange;
|
||||
|
||||
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
|
||||
@@ -294,22 +294,44 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
|
||||
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
||||
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
||||
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||
{
|
||||
if (taskIds is not { Count: > 0 }) return;
|
||||
|
||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||
var listName = listId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
||||
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||
string taskId;
|
||||
try
|
||||
{
|
||||
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
||||
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
||||
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||
|
||||
+40
-8
@@ -502,29 +502,30 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Get the diff for a task's worktree relative to its base commit. " +
|
||||
"Get the diff for a task's worktree relative to its base commit. For a worktree-less " +
|
||||
"list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " +
|
||||
"HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " +
|
||||
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
|
||||
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
|
||||
"files always lists the changed file paths regardless of stat mode. " +
|
||||
"totalBytes is the uncapped diff size (useful when truncated=true). " +
|
||||
"Throws if the task has no worktree or the worktree directory is missing from disk.")]
|
||||
"Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")]
|
||||
public async Task<TaskDiffDto> GetTaskDiff(
|
||||
string taskId, bool stat = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
||||
|
||||
if (!Directory.Exists(wt.Path))
|
||||
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
|
||||
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
||||
|
||||
const int maxBytes = 200 * 1024;
|
||||
|
||||
if (stat)
|
||||
{
|
||||
var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
|
||||
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", cancellationToken);
|
||||
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
|
||||
}
|
||||
|
||||
var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
|
||||
var diff = headCommit is null
|
||||
? await _git.GetBranchDiffAsync(repoPath, baseCommit, cancellationToken)
|
||||
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, cancellationToken);
|
||||
var files = ParseDiffFileNames(diff);
|
||||
|
||||
if (diff.Length <= maxBytes)
|
||||
@@ -533,6 +534,37 @@ public sealed class ExternalMcpService
|
||||
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
|
||||
}
|
||||
|
||||
// Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed
|
||||
// against HEAD) or, for a worktree-less list-handler host task, the fixed
|
||||
// HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit
|
||||
// non-null signals "fixed range" to the caller instead of "diff against live HEAD").
|
||||
private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync(
|
||||
string taskId, CancellationToken ct)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||
|
||||
if (wt is not null)
|
||||
{
|
||||
if (!Directory.Exists(wt.Path))
|
||||
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
|
||||
return (wt.Path, wt.BaseCommit, null);
|
||||
}
|
||||
|
||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||
{
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException("List not found.");
|
||||
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
||||
throw new InvalidOperationException("The list's working directory no longer exists.");
|
||||
return (list.WorkingDir, handlerBase, handlerHead);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Merge a task's worktree branch into targetBranch (default: main). " +
|
||||
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
||||
|
||||
@@ -135,6 +135,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
|
||||
private readonly WorktreeManager? _worktreeManager;
|
||||
private readonly Data.Git.GitService? _git;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -163,7 +164,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
LogRingBuffer? logBuffer = null,
|
||||
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
|
||||
WorktreeManager? worktreeManager = null)
|
||||
WorktreeManager? worktreeManager = null,
|
||||
Data.Git.GitService? git = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -192,6 +194,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_logBuffer = logBuffer;
|
||||
_interactiveLaunchSpec = interactiveLaunchSpec;
|
||||
_worktreeManager = worktreeManager;
|
||||
_git = git;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
@@ -698,6 +701,19 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
|
||||
// one task per run, never queued (Idle/IsManual). Broadcasts TaskUpdated so it shows up in
|
||||
// the list immediately.
|
||||
public Task<string> CreateMergeHelperTask(string[] taskIds, string listId, string title, string descriptionHeader) => HubGuard(async () =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
var taskId = await _interactiveLaunchSpec.CreateMergeHelperTaskAsync(
|
||||
taskIds, listId, title, descriptionHeader, Context.ConnectionAborted);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return taskId;
|
||||
});
|
||||
|
||||
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
|
||||
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
|
||||
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
||||
@@ -730,17 +746,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return _interactiveLaunchSpec.BuildPlanningResume(ctx);
|
||||
});
|
||||
|
||||
// Submits an interactively-worked task for review: commits whatever the ConPTY session left
|
||||
// in the worktree (so there is a diff to merge), then transitions Idle/Failed -> WaitingForReview.
|
||||
// The normal Approve flow then merges it. This is the only path that flips a hand-driven session
|
||||
// into the review pipeline — a ConPTY session never touches task status on its own.
|
||||
// Submits an interactively-worked task for review, then transitions Idle/Failed ->
|
||||
// WaitingForReview. The normal Approve flow then merges it (or, for a worktree-less host
|
||||
// task below, just flips to Done — there's nothing to merge). This is the only path that
|
||||
// flips a hand-driven session into the review pipeline — a ConPTY session never touches
|
||||
// task status on its own.
|
||||
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
if (_worktreeManager is null)
|
||||
throw new InvalidOperationException("Worktree manager is not configured.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
var task = await taskRepo.GetByIdAsync(taskId, Context.ConnectionAborted)
|
||||
?? throw new KeyNotFoundException();
|
||||
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
||||
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
|
||||
@@ -748,16 +763,41 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
throw new InvalidOperationException("Task is already awaiting review.");
|
||||
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
if (worktree is not null)
|
||||
{
|
||||
if (_worktreeManager is null)
|
||||
throw new InvalidOperationException("Worktree manager is not configured.");
|
||||
if (worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
throw new InvalidOperationException("This task has no active worktree to submit.");
|
||||
if (!Directory.Exists(worktree.Path))
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
||||
?? throw new InvalidOperationException("Task list not found.");
|
||||
|
||||
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
|
||||
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
|
||||
}
|
||||
else if (task.HandlerBaseCommit is { Length: > 0 })
|
||||
{
|
||||
// Worktree-less "list handler" host task (Mission Control's "Let Claude handle it"):
|
||||
// the handler commits its own changes straight to the list's working dir, so there
|
||||
// is nothing for us to commit here — just stamp the review range's head commit.
|
||||
if (_git is null)
|
||||
throw new InvalidOperationException("Git service is not configured.");
|
||||
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
||||
?? throw new InvalidOperationException("Task list not found.");
|
||||
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
||||
throw new InvalidOperationException("The list's working directory no longer exists.");
|
||||
|
||||
var headCommit = await _git.RevParseHeadAsync(list.WorkingDir, Context.ConnectionAborted);
|
||||
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, Context.ConnectionAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("This task has no active worktree to submit.");
|
||||
if (!Directory.Exists(worktree.Path))
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
||||
?? throw new InvalidOperationException("Task list not found.");
|
||||
|
||||
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
|
||||
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
||||
if (!result.Ok)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
@@ -29,6 +30,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
private readonly ISessionSkillSeeder _skillSeeder;
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly GitService _git;
|
||||
private readonly string _claudePath;
|
||||
|
||||
public InteractiveLaunchSpecService(
|
||||
@@ -36,12 +38,14 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
ISessionSkillSeeder skillSeeder,
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
WorktreeManager wtManager,
|
||||
GitService git,
|
||||
WorkerConfig cfg)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_skillSeeder = skillSeeder;
|
||||
_skillRegistry = skillRegistry;
|
||||
_wtManager = wtManager;
|
||||
_git = git;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
}
|
||||
|
||||
@@ -248,6 +252,57 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
||||
}
|
||||
|
||||
// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
|
||||
// handle it") and stamps the list repo's current HEAD as the review range's base commit.
|
||||
// The handler never gets its own worktree -- it commits straight to the list's working
|
||||
// dir -- so this HandlerBaseCommit/HandlerHeadCommit pair (see TaskEntity) is what lets the
|
||||
// normal diff/get_task_diff paths show what the run changed once it submits for review.
|
||||
// IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
|
||||
// affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
|
||||
// semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
|
||||
public async Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
|
||||
{
|
||||
if (taskIds.Count == 0)
|
||||
throw new InvalidOperationException("No tasks selected for the list handler.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var listRepo = new ListRepository(ctx);
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
|
||||
var list = await listRepo.GetByIdAsync(listId, ct)
|
||||
?? throw new KeyNotFoundException($"List not found: {listId}");
|
||||
|
||||
var repoDir = list.WorkingDir;
|
||||
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
||||
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
||||
|
||||
var descriptionLines = new List<string>();
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is not null) descriptionLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||
}
|
||||
|
||||
var baseCommit = await _git.RevParseHeadAsync(repoDir, ct);
|
||||
|
||||
var handlerTask = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = descriptionLines.Count > 0
|
||||
? $"{descriptionHeader}\n{string.Join("\n", descriptionLines)}"
|
||||
: descriptionHeader,
|
||||
IsManual = true,
|
||||
HandlerBaseCommit = baseCommit,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await taskRepo.AddAsync(handlerTask, ct);
|
||||
|
||||
return handlerTask.Id;
|
||||
}
|
||||
|
||||
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
|
||||
// preset for that model, so a missing/malformed settings row can never block a launch.
|
||||
private static string EffortFor(AppSettingsEntity settings, string? model)
|
||||
|
||||
@@ -34,4 +34,12 @@ public interface IInteractiveLaunchSpecService
|
||||
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
|
||||
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
|
||||
/// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
|
||||
/// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
|
||||
/// is empty or the list has no existing working directory.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> Task.FromResult(Guid.NewGuid().ToString());
|
||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
/// Covers the worktree-less "list handler" host task (Mission Control's "Let Claude handle
|
||||
/// it"): it has no WorktreeEntity, so the detail pane's review range must fall back to
|
||||
/// TaskEntity.HandlerBaseCommit/HandlerHeadCommit, and the merge/diff card must still render.
|
||||
public class DetailsIslandHandlerRangeTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandHandlerRangeTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handler_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||
{
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||
Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||
Task.FromResult<DailyNoteDto?>(null);
|
||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
}
|
||||
|
||||
private DetailsIslandViewModel BuildVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(
|
||||
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bind_WorktreeLessHandlerTask_FallsBackToHandlerCommitRange_AndShowsMergeSection()
|
||||
{
|
||||
const string listId = "list-1";
|
||||
const string taskId = "handler-task-1";
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "List handler: L",
|
||||
Status = TaskStatus.WaitingForReview, IsManual = true,
|
||||
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildVm();
|
||||
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.WaitingForReview });
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.WorktreeBaseCommit is null)
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.Equal("base123", vm.WorktreeBaseCommit);
|
||||
Assert.Equal("head456", vm.WorktreeHeadCommit);
|
||||
Assert.Null(vm.WorktreePath);
|
||||
Assert.True(vm.Merge.ShowMergeSection);
|
||||
Assert.True(vm.Merge.HasReviewableDiff);
|
||||
}
|
||||
}
|
||||
@@ -523,23 +523,47 @@ public class MissionControlViewModelTests : IDisposable
|
||||
=> throw new InvalidOperationException("spec failed");
|
||||
}
|
||||
|
||||
private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient
|
||||
{
|
||||
public override Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("create failed");
|
||||
}
|
||||
|
||||
private sealed class FixedTaskIdMergeHelperWorker : StubWorkerClient
|
||||
{
|
||||
public string TaskId { get; } = "fixed-handler-task";
|
||||
public int CreateCallCount { get; private set; }
|
||||
public override Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
{
|
||||
CreateCallCount++;
|
||||
return Task.FromResult(TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenMergeHelperConPtySessionAsync_AddsAdHocPane()
|
||||
public async Task OpenMergeHelperConPtySessionAsync_AddsTaskBasedPane()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Null(vm.ConPtySessions[0].TaskId);
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.NotNull(pane.TaskId);
|
||||
Assert.True(pane.IsTaskBased);
|
||||
Assert.Single(vm.Panes);
|
||||
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_NeverDeduped()
|
||||
public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_EachGetsItsOwnTaskAndPane()
|
||||
{
|
||||
// Each run creates a brand-new handler task (the stub returns a fresh Guid per call),
|
||||
// so two runs still yield two distinct, non-deduped panes -- but for a different reason
|
||||
// than the old ad-hoc pane (never deduped by construction): dedup is now by TaskId, and
|
||||
// there simply is no shared TaskId across separate runs.
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
@@ -547,6 +571,22 @@ public class MissionControlViewModelTests : IDisposable
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
|
||||
Assert.Equal(2, vm.ConPtySessions.Count);
|
||||
Assert.NotEqual(vm.ConPtySessions[0].TaskId, vm.ConPtySessions[1].TaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenMergeHelperConPtySessionAsync_SameHandlerTaskId_FocusesExistingPane_NoDuplicate()
|
||||
{
|
||||
var worker = new FixedTaskIdMergeHelperWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
var firstPane = Assert.Single(vm.ConPtySessions);
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Same(firstPane, vm.FocusedPane);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -577,6 +617,21 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenMergeHelperConPtySessionAsync_TaskCreationThrows_RaisesErrorReported_NoPaneCreated()
|
||||
{
|
||||
var worker = new ThrowingCreateMergeHelperTaskWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
||||
{
|
||||
|
||||
@@ -656,6 +656,70 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.False(diff.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
// No WorktreeEntity for this task -- it's a worktree-less list-handler host task
|
||||
// (Mission Control's "Let Claude handle it"): the handler merged its own commit
|
||||
// straight into the list's working dir after HandlerBaseCommit was stamped.
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None);
|
||||
|
||||
Assert.Contains("handled.txt", diff.Files);
|
||||
Assert.False(diff.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = new GitRepoFixture();
|
||||
_repos.Add(repo);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var t = await ctx.Tasks.FindAsync(task.Id);
|
||||
t!.HandlerBaseCommit = repo.BaseCommit;
|
||||
t.HandlerHeadCommit = headCommit;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var diff = await sut.GetTaskDiff(task.Id, true, CancellationToken.None);
|
||||
|
||||
Assert.Contains("handled.txt", diff.Content);
|
||||
}
|
||||
|
||||
// ── MergeTask ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// Covers the two hub methods a worktree-less "list handler" host task (Mission Control's
|
||||
/// "Let Claude handle it") relies on: CreateMergeHelperTask (task creation + HandlerBaseCommit
|
||||
/// stamp) and SubmitTaskForReview's worktree-less branch (HandlerHeadCommit stamp + transition).
|
||||
/// The handler itself merges the tasks it handles directly into the list's working dir -- no
|
||||
/// worktree of its own -- so these hub methods are the whole story for its review range.
|
||||
public sealed class MergeHelperTaskHubTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly List<GitRepoFixture> _repos = new();
|
||||
private readonly RecordingClientProxy _proxy = new();
|
||||
|
||||
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
||||
|
||||
public MergeHelperTaskHubTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var r in _repos) r.Dispose();
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
|
||||
{
|
||||
public Task<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Array.Empty<SessionSkillEntity>());
|
||||
}
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var factory = _db.CreateFactory();
|
||||
var git = new GitService();
|
||||
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
||||
new WorkerConfig { ClaudeBin = "claude" });
|
||||
var built = TaskStateServiceBuilder.Build(factory);
|
||||
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, factory, null!, null!, null!,
|
||||
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
|
||||
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git);
|
||||
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
private GitRepoFixture CreateRepo()
|
||||
{
|
||||
var f = new GitRepoFixture();
|
||||
_repos.Add(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
private async Task<string> SeedListAsync(string workingDir, string name = "L")
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = workingDir, CreatedAt = DateTime.UtcNow });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(
|
||||
string listId, TaskStatus status = TaskStatus.Idle, string? handlerBaseCommit = null, string title = "T")
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Status = status,
|
||||
HandlerBaseCommit = handlerBaseCommit,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
// ── CreateMergeHelperTask ──
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var listId = await SeedListAsync(repo.RepoDir, name: "Alpha");
|
||||
var t1 = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "First task");
|
||||
|
||||
var hub = CreateHub();
|
||||
var newTaskId = await hub.CreateMergeHelperTask(
|
||||
new[] { t1.Id }, listId, "List handler: Alpha", "Tasks handled by this run:");
|
||||
|
||||
var created = await _tasks.GetByIdAsync(newTaskId);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal(TaskStatus.Idle, created!.Status);
|
||||
Assert.True(created.IsManual);
|
||||
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
|
||||
Assert.Contains("First task", created.Description);
|
||||
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTask_UnknownList_Throws()
|
||||
{
|
||||
var hub = CreateHub();
|
||||
await Assert.ThrowsAsync<HubException>(
|
||||
() => hub.CreateMergeHelperTask(new[] { "t1" }, "no-such-list", "title", "header"));
|
||||
}
|
||||
|
||||
// ── SubmitTaskForReview (worktree-less branch) ──
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var listId = await SeedListAsync(repo.RepoDir);
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Idle, handlerBaseCommit: repo.BaseCommit);
|
||||
|
||||
// The handler merged a task's worktree into the list's working dir on its own.
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var hub = CreateHub();
|
||||
await hub.SubmitTaskForReview(task.Id);
|
||||
|
||||
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
||||
Assert.Equal(expectedHead, reloaded.HandlerHeadCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync(Path.GetTempPath());
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
|
||||
|
||||
var hub = CreateHub();
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_RunningTask_Throws_RegardlessOfHandlerState()
|
||||
{
|
||||
var listId = await SeedListAsync(Path.GetTempPath());
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Running, handlerBaseCommit: "abc123");
|
||||
|
||||
var hub = CreateHub();
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.SubmitTaskForReview(task.Id));
|
||||
}
|
||||
}
|
||||
|
||||
// RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the
|
||||
// whole ClaudeDo.Worker.Tests.Hub namespace in PlanningHubTests.cs; reused here as-is.
|
||||
@@ -70,6 +70,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
private InteractiveLaunchSpecService BuildService() =>
|
||||
new(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(),
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath });
|
||||
|
||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||
@@ -477,6 +478,65 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.Contains(t2, brief);
|
||||
}
|
||||
|
||||
// ── CreateMergeHelperTaskAsync ──
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_EmptyTaskIds_ThrowsInvalidOperation()
|
||||
{
|
||||
var listId = await SeedListAsync(workingDir: _tempDir);
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(Array.Empty<string>(), listId, "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_UnknownList_ThrowsKeyNotFound()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, "no-such-list", "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||
{
|
||||
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
|
||||
|
||||
var svc = BuildService();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => svc.CreateMergeHelperTaskAsync(new[] { taskId }, listId, "title", "header", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
|
||||
var t1 = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||
|
||||
var svc = BuildService();
|
||||
var newTaskId = await svc.CreateMergeHelperTaskAsync(
|
||||
new[] { t1 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
var created = await new TaskRepository(readCtx).GetByIdAsync(newTaskId);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("List handler: Alpha", created!.Title);
|
||||
Assert.Equal(TaskStatus.Idle, created.Status);
|
||||
Assert.True(created.IsManual);
|
||||
Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit);
|
||||
Assert.Null(created.HandlerHeadCommit);
|
||||
Assert.Contains("Tasks handled by this run:", created.Description);
|
||||
Assert.Contains("First task", created.Description);
|
||||
Assert.Contains(t1, created.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
|
||||
{
|
||||
|
||||
@@ -77,6 +77,9 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
=> Task.FromResult(Guid.NewGuid().ToString());
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public int PlanningStartSpecCalls { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user