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:
mika kuns
2026-08-05 09:16:34 +02:00
parent 63d8b5c28d
commit c07c1f70a8
28 changed files with 1590 additions and 43 deletions
+2 -2
View File
@@ -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");
@@ -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")
+9
View File
@@ -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",
+1 -1
View File
@@ -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);
+4
View File
@@ -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))));
}
+1 -1
View File
@@ -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
View File
@@ -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). " +
+58 -18
View File
@@ -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);
}