Merge branch 'claudedo/0b2fbb48d44c41558c21d3464c0bd5c2'

This commit is contained in:
mika kuns
2026-08-05 12:11:55 +02:00
30 changed files with 1473 additions and 130 deletions
+10
View File
@@ -78,6 +78,16 @@ Offene Entscheidungen dazu:
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
specifically so `list_worktrees`/the Worktrees overview never see it.
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
timeout paths via a fake runner), but **not visually verified**: open a list's Settings modal,
confirm the new "VERIFICATION" section renders below Agent with a settable/clearable
`VerifyCommand` field; approve a task on a list with a failing command configured and confirm
the footer/error surfacing (`ShowErrorAsync`) actually shows the verify failure message instead
of silently looking like nothing happened. Also no real-build smoke test (a real `dotnet build`/
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
`ping` for timeout) were exercised.
---
## Bewusst verworfen (nicht erneut vorschlagen)
+2 -2
View File
@@ -6,7 +6,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **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)
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
@@ -45,7 +45,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`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. `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`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `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
@@ -17,5 +17,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
}
}
@@ -0,0 +1,832 @@
// <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("20260805090016_AddVerifyCommand")]
partial class AddVerifyCommand
{
/// <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>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
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,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
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.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
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>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddVerifyCommand : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "verify_command",
table: "list_config",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "verify_command",
table: "list_config");
}
}
}
@@ -198,6 +198,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
@@ -8,6 +8,7 @@ public sealed class ListConfigEntity
public string? AgentPath { get; set; }
public int? MaxTurns { get; set; }
public string? SessionSkills { get; set; }
public string? VerifyCommand { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
@@ -78,6 +78,7 @@ public sealed class ListRepository
existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
}
await _context.SaveChangesAsync(ct);
}
+6 -1
View File
@@ -326,7 +326,11 @@
"manualList": "Manuelle Liste (Erinnerungen)",
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION",
"verifyCommand": "Verifikationskommando",
"verifyCommandPlaceholder": "(keines — Merge verhält sich wie bisher)",
"verifyCommandHint": "Läuft im Arbeitsverzeichnis nach einem Merge, bevor die Aufgabe auf 'Erledigt' gesetzt wird. Ein Exit-Code ungleich 0 (oder Timeout) lässt den Merge bestehen, verhindert aber 'Erledigt' und meldet den Fehler."
},
"merge": {
"title": "WORKTREE MERGEN",
@@ -550,6 +554,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
}
}
+6 -1
View File
@@ -326,7 +326,11 @@
"manualList": "Manual list (reminders)",
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings"
"resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION",
"verifyCommand": "Verify command",
"verifyCommandPlaceholder": "(none — merge behaves as today)",
"verifyCommandHint": "Runs in the working directory after a merge lands, before the task is marked Done. A non-zero exit (or timeout) leaves the merge in place but keeps the task out of Done and reports the failure."
},
"merge": {
"title": "MERGE WORKTREE",
@@ -550,6 +554,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
}
}
+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`, 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`).
- **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, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `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`).
+2 -2
View File
@@ -630,9 +630,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public sealed record SeedResultDto(int Copied, int Skipped);
public sealed record WorktreeOverviewDto(
@@ -184,7 +184,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { }
}
public async System.Threading.Tasks.Task SaveAsync()
// verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor,
// which is also reused for Task scope); the caller passes it through so the single
// UpdateListConfig call carries the full desired row instead of clobbering it.
public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null)
{
if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -196,7 +199,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand));
}
private List<string>? SelectedSessionSkillNames()
@@ -1073,6 +1073,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
}
catch (Exception ex)
{
@@ -30,6 +30,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
[ObservableProperty] private bool _isManual;
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = "";
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
@@ -61,6 +64,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? "";
}
[RelayCommand]
@@ -73,7 +78,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
DefaultCommitType,
IsManual));
await Agent.SaveAsync();
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
CloseAction?.Invoke();
}
@@ -83,6 +83,20 @@
</Border>
</StackPanel>
<!-- VERIFICATION -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="{loc:Tr modals.listSettings.sectionVerify}"/>
<Border Classes="section">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.verifyCommand}"/>
<TextBox Text="{Binding VerifyCommand, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{loc:Tr modals.listSettings.verifyCommandPlaceholder}"/>
<TextBlock Text="{loc:Tr modals.listSettings.verifyCommandHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
+20 -2
View File
@@ -8,7 +8,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
@@ -102,7 +102,25 @@ that has children, drives `PlanningMergeOrchestrator` (merges the parent worktre
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
approve is the single review+merge action. Review transitions live in `TaskStateService`
approve is the single review+merge action.
**Post-merge verify gate.** A list can set `ListConfigEntity.VerifyCommand` (List Settings
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
before this existed. When set, `TaskMergeService` runs it via `VerifyCommandRunner`
(`cmd.exe /c <command>`, 10-minute fixed timeout, output tail-captured) in `list.WorkingDir`
right after a successful `MergeNoFfAsync`/`ContinueMergeAsync` and worktree cleanup, but
*before* the task is allowed to reach `Done`. Exit 0 → unchanged flow (worktree marked
`Merged`, task `Done` if it was `WaitingForReview`). Non-zero exit or a timeout → the git
merge is deliberately left in place (no auto-revert — that's a separate, unbuilt feature),
the worktree is still marked `Merged` (it's already gone from disk when `removeWorktree`
was requested), but the task stays out of `Done` and `MergeResult.Status` comes back
`TaskMergeService.StatusVerifyFailed` (`"verify_failed"`) with an output excerpt in
`ErrorMessage` — this flows through `MergeResultDto` (hub) and `ReviewTaskResult`
(`review_task` MCP tool) unchanged, since both already treat any non-`blocked`/`conflict`
status generically. A process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync`/`ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build. Review transitions live in `TaskStateService`
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
+6 -4
View File
@@ -79,9 +79,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public record SeedResultDto(int Copied, int Skipped);
public record OnlineInboxStateDto(
@@ -521,8 +521,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = dto.AgentPath.NullIfBlank();
var sessionSkills = SkillsToJson(dto.SessionSkills);
var verifyCommand = dto.VerifyCommand.NullIfBlank();
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -536,6 +537,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
AgentPath = agentPath,
MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills,
VerifyCommand = verifyCommand,
});
}
@@ -548,7 +550,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand);
}
public async Task SetTaskStatus(string taskId, string status)
@@ -0,0 +1,8 @@
namespace ClaudeDo.Worker.Lifecycle;
public sealed record VerifyCommandResult(int ExitCode, bool TimedOut, string Output);
public interface IVerifyCommandRunner
{
Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct);
}
+201 -110
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -40,10 +41,11 @@ public sealed record RevertResult(
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
public const string StatusConflict = "conflict";
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusMerged = "merged";
public const string StatusConflict = "conflict";
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string StatusVerifyFailed = "verify_failed";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -52,10 +54,25 @@ public sealed class TaskMergeService
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
// The verify command is a trusted, list-owner-configured build/test invocation (not
// per-request user input), so a generous fixed timeout is enough — no need for a
// per-list configurable value on top of what the spec calls for.
private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10);
// Serializes merge (+ verify) against the same repo working dir: a verify command running
// in list.WorkingDir must not see a second merge land mid-build. Keyed by working dir since
// TaskMergeService is a process-wide singleton and merges across different lists are independent.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> MergeGates =
new(StringComparer.OrdinalIgnoreCase);
private static SemaphoreSlim GetMergeGate(string workingDir) =>
MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1));
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state;
private readonly IVerifyCommandRunner _verify;
private readonly ILogger<TaskMergeService> _logger;
public TaskMergeService(
@@ -63,25 +80,67 @@ public sealed class TaskMergeService
GitService git,
HubBroadcaster broadcaster,
ITaskStateService state,
IVerifyCommandRunner verify,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_state = state;
_verify = verify;
_logger = logger;
}
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree)> LoadMergeContextAsync(
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
return (task, list, wt);
var config = await listRepo.GetConfigAsync(task.ListId, ct);
return (task, list, wt, config?.VerifyCommand);
}
/// <summary>
/// Runs the list's configured verify command (if any) in <paramref name="workingDir"/> after
/// a successful merge. Returns null when there is nothing to gate on (identical to today's
/// behavior); otherwise returns the terminal <see cref="MergeResult"/> to report instead of
/// merged (the merge itself is left in place either way — see the design notes in Worker's
/// CLAUDE.md — only the Done transition is withheld).
/// </summary>
private async Task<MergeResult?> RunVerifyGateAsync(
string? verifyCommand, string workingDir, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
VerifyCommandResult result;
try
{
result = await _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "verify command failed to start: {Command}", verifyCommand);
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(),
$"verify command failed to start: {ex.Message}");
}
if (!result.TimedOut && result.ExitCode == 0) return null;
var reason = result.TimedOut
? $"verify command timed out after {VerifyTimeout.TotalMinutes:0} min: {verifyCommand}"
: $"verify command failed (exit {result.ExitCode}): {verifyCommand}";
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
}
private static string TailOutput(string output, int maxChars = 4000)
{
var trimmed = output.Trim();
return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
}
private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct)
@@ -110,7 +169,7 @@ public sealed class TaskMergeService
bool leaveConflictsInTree,
CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
if (task.Status == TaskStatus.Running)
return Blocked("task is running");
@@ -120,81 +179,97 @@ public sealed class TaskMergeService
return Blocked($"worktree state is {wt.State}");
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory");
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
var gate = GetMergeGate(list.WorkingDir);
await gate.WaitAsync(ct);
try
{
try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
List<string> files;
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
catch { files = new(); }
if (leaveConflictsInTree && files.Count > 0)
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(list.WorkingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct);
if (exitCode != 0)
{
List<string> files;
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
catch { files = new(); }
if (leaveConflictsInTree && files.Count > 0)
{
return new MergeResult(StatusConflict, files, null);
}
// If abort fails the repo is left mid-merge; the caller must resolve manually.
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
}
if (files.Count == 0)
{
// Non-conflict failure (e.g. unrelated histories).
return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
}
return new MergeResult(StatusConflict, files, null);
}
// If abort fails the repo is left mid-merge; the caller must resolve manually.
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
catch (Exception ex)
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null;
if (removeWorktree)
{
_logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge");
return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually");
}
if (files.Count == 0)
{
// Non-conflict failure (e.g. unrelated histories).
return new MergeResult(StatusBlocked, Array.Empty<string>(), $"merge failed: {stderr}");
}
return new MergeResult(StatusConflict, files, null);
}
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
string? cleanupWarning = null;
if (removeWorktree)
{
try
{
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
try
{
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
}
catch (Exception ex)
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
if (verifyFailure is not null)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
finally { gate.Release(); }
}
public Task<MergeResult> MergeAsync(
@@ -207,54 +282,70 @@ public sealed class TaskMergeService
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("repo is not mid-merge");
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
foreach (var path in unresolved)
var gate = GetMergeGate(list.WorkingDir);
await gate.WaitAsync(ct);
try
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("repo is not mid-merge");
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
stillConflicted.Add(path);
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
foreach (var path in unresolved)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
stillConflicted.Add(path);
}
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
await _git.AddAllAsync(list.WorkingDir, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
if (remaining.Count > 0)
return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
}
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
await _git.AddAllAsync(list.WorkingDir, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
if (remaining.Count > 0)
return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
finally { gate.Release(); }
}
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
@@ -282,7 +373,7 @@ public sealed class TaskMergeService
/// </summary>
public async Task<RevertResult> RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.Done)
return RevertBlocked("task is not Done; only a merged task's revert can be undone");
@@ -353,7 +444,7 @@ public sealed class TaskMergeService
/// </summary>
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
@@ -388,7 +479,7 @@ public sealed class TaskMergeService
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
@@ -399,7 +490,7 @@ public sealed class TaskMergeService
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return new MergeTargets("", Array.Empty<string>());
@@ -411,7 +502,7 @@ public sealed class TaskMergeService
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (wt is null || wt.State != WorktreeState.Active)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
@@ -438,7 +529,7 @@ public sealed class TaskMergeService
public async Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
@@ -0,0 +1,75 @@
using System.Diagnostics;
using System.Text;
namespace ClaudeDo.Worker.Lifecycle;
/// <summary>
/// Runs a list's configured post-merge verification command (e.g. a build/test invocation)
/// via cmd.exe, mirroring GitService's ProcessStartInfo discipline (no shell string
/// concatenation beyond the single /c argument cmd.exe itself requires to parse a command line).
/// </summary>
public sealed class VerifyCommandRunner : IVerifyCommandRunner
{
// Safety cap so a runaway/chatty command can't exhaust memory; only the tail matters anyway.
private const int MaxOutputChars = 512_000;
public async Task<VerifyCommandResult> RunAsync(
string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = "cmd.exe",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
psi.ArgumentList.Add("/c");
psi.ArgumentList.Add(command);
using var process = new Process { StartInfo = psi };
var output = new StringBuilder();
var sync = new object();
void Append(string? line)
{
if (line is null) return;
lock (sync)
{
if (output.Length >= MaxOutputChars) return;
output.AppendLine(line);
}
}
process.OutputDataReceived += (_, e) => Append(e.Data);
process.ErrorDataReceived += (_, e) => Append(e.Data);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
var timedOut = false;
// On cancellation (timeout or caller): kill the tree. Killing unblocks WaitForExitAsync
// below and lets the async output readers drain/complete naturally.
await using var ctr = cts.Token.Register(() =>
{
timedOut = !ct.IsCancellationRequested;
try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
await process.WaitForExitAsync(CancellationToken.None);
string finalOutput;
lock (sync) finalOutput = output.ToString();
return new VerifyCommandResult(process.ExitCode, timedOut, finalOutput);
}
}
+1
View File
@@ -83,6 +83,7 @@ builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSp
builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
builder.Services.AddSingleton<TaskMergeService>();
builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
@@ -79,7 +79,7 @@ public sealed class AddSubtaskToolTests : IDisposable
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels,
new FakeUsageGate(), new UsageState(), broadcaster);
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
+1 -1
View File
@@ -67,7 +67,7 @@ public sealed class BatchMcpToolsTests : IDisposable
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
@@ -129,7 +129,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
@@ -309,6 +309,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
var merge = new TaskMergeService(
factory, git, broadcaster,
built.State,
new VerifyCommandRunner(),
NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(
factory, git,
@@ -130,6 +130,7 @@ public sealed class TreeMergeTests : IDisposable
var merge = new TaskMergeService(
factory, git, broadcaster,
built.State,
new VerifyCommandRunner(),
NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(
factory, git,
@@ -96,6 +96,38 @@ public sealed class ListRepositoryConfigTests : IDisposable
Assert.Null(fetched.SessionSkills);
}
[Fact]
public async Task SetConfig_Persists_VerifyCommand_On_Insert()
{
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
var fetched = await _repo.GetConfigAsync(_listId);
Assert.NotNull(fetched);
Assert.Equal("dotnet test", fetched.VerifyCommand);
}
[Fact]
public async Task SetConfig_Persists_VerifyCommand_On_Update()
{
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet build" });
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
var fetched = await _repo.GetConfigAsync(_listId);
Assert.NotNull(fetched);
Assert.Equal("dotnet test", fetched.VerifyCommand);
}
[Fact]
public async Task SetConfig_Null_VerifyCommand_Clears_On_Update()
{
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = null });
var fetched = await _repo.GetConfigAsync(_listId);
Assert.NotNull(fetched);
Assert.Null(fetched.VerifyCommand);
}
public void Dispose()
{
_ctx.Dispose();
@@ -30,7 +30,8 @@ public class TaskMergeServiceTests : IDisposable
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
DbFixture db, IVerifyCommandRunner? verify = null)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
@@ -40,10 +41,17 @@ public class TaskMergeServiceTests : IDisposable
new GitService(),
broadcaster,
state,
verify ?? new VerifyCommandRunner(),
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
{
using var ctx = db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
return new WorktreeManager(
@@ -706,6 +714,125 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Null(fakeVerify.CapturedCommand);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("build broke: CS0103", result.ErrorMessage);
// The git merge itself is left in place — main already has the merged content.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
await SeedVerifyCommand(db, list.Id, "dotnet test");
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
var (svc, _) = BuildService(db, fakeVerify);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
}
[Fact]
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
{
@@ -986,6 +1113,20 @@ public class TaskMergeServiceTests : IDisposable
#region Test doubles
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
{
public VerifyCommandResult Result { get; set; } = new(0, false, "");
public string? CapturedWorkingDir { get; private set; }
public string? CapturedCommand { get; private set; }
public Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
{
CapturedWorkingDir = workingDir;
CapturedCommand = command;
return Task.FromResult(Result);
}
}
internal sealed record MergeHubCall(string Method, object?[] Args);
internal sealed class MergeRecordingClientProxy : IClientProxy
@@ -0,0 +1,60 @@
using ClaudeDo.Worker.Lifecycle;
namespace ClaudeDo.Worker.Tests.Services;
public class VerifyCommandRunnerTests
{
private readonly VerifyCommandRunner _runner = new();
[Fact]
public async Task RunAsync_ExitsZero_ReportsSuccessNotTimedOut()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "exit 0", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal(0, result.ExitCode);
Assert.False(result.TimedOut);
}
[Fact]
public async Task RunAsync_NonZeroExit_ReportsExitCode()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "exit 7", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal(7, result.ExitCode);
Assert.False(result.TimedOut);
}
[Fact]
public async Task RunAsync_CapturesStdoutAndStderr()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "echo hello-out & echo hello-err 1>&2", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Contains("hello-out", result.Output);
Assert.Contains("hello-err", result.Output);
}
[Fact]
public async Task RunAsync_RunsInSpecifiedWorkingDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), $"verify_wd_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var result = await _runner.RunAsync(dir, "cd", TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Contains(new DirectoryInfo(dir).Name, result.Output);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public async Task RunAsync_ExceedsTimeout_KillsProcessAndReportsTimedOut()
{
var result = await _runner.RunAsync(
Path.GetTempPath(), "ping -n 60 127.0.0.1", TimeSpan.FromMilliseconds(300), CancellationToken.None);
Assert.True(result.TimedOut);
}
}