From c9ba1e26455f2dd39c8be01e0c5ef876a0ae3b49 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 11:19:22 +0200 Subject: [PATCH] feat(worker): add post-merge verification gate for list merges Per-list optional VerifyCommand (list_config.verify_command) runs via VerifyCommandRunner in the list's working dir right after a successful merge/continue-merge, before the task is allowed to reach Done. A non-zero exit or timeout leaves the merge in place but keeps the task out of Done and reports StatusVerifyFailed with an output excerpt through MergeResultDto/review_task; no command configured behaves exactly as before. Merges against the same repo are now serialized per working dir so a running verify can't be interrupted by a second merge landing mid-build. Adds the field to the List Settings modal (en/de localized) and covers success/failure/timeout in TaskMergeServiceTests + VerifyCommandRunnerTests. --- docs/open.md | 10 + src/ClaudeDo.Data/CLAUDE.md | 4 +- .../ListConfigEntityConfiguration.cs | 1 + ...0260805090016_AddVerifyCommand.Designer.cs | 832 ++++++++++++++++++ .../20260805090016_AddVerifyCommand.cs | 28 + .../ClaudeDoDbContextModelSnapshot.cs | 4 + src/ClaudeDo.Data/Models/ListConfigEntity.cs | 1 + .../Repositories/ListRepository.cs | 1 + src/ClaudeDo.Localization/locales/de.json | 7 +- src/ClaudeDo.Localization/locales/en.json | 7 +- src/ClaudeDo.Ui/CLAUDE.md | 2 +- src/ClaudeDo.Ui/Services/WorkerClient.cs | 4 +- .../Agent/AgentConfigEditorViewModel.cs | 7 +- .../Islands/DetailsIslandViewModel.cs | 4 + .../Modals/ListSettingsModalViewModel.cs | 7 +- .../Views/Modals/ListSettingsModalView.axaml | 14 + src/ClaudeDo.Worker/CLAUDE.md | 22 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 +- .../Interfaces/IVerifyCommandRunner.cs | 8 + .../Lifecycle/TaskMergeService.cs | 303 ++++--- .../Lifecycle/VerifyCommandRunner.cs | 75 ++ src/ClaudeDo.Worker/Program.cs | 1 + .../External/AddSubtaskToolTests.cs | 2 +- .../External/BatchMcpToolsTests.cs | 2 +- .../External/ExternalMcpServiceTests.cs | 2 +- .../PlanningMergeOrchestratorTests.cs | 1 + .../Planning/TreeMergeTests.cs | 1 + .../Repositories/ListRepositoryConfigTests.cs | 32 + .../Services/TaskMergeServiceTests.cs | 143 ++- .../Services/VerifyCommandRunnerTests.cs | 60 ++ 30 files changed, 1469 insertions(+), 126 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.cs create mode 100644 src/ClaudeDo.Worker/Lifecycle/Interfaces/IVerifyCommandRunner.cs create mode 100644 src/ClaudeDo.Worker/Lifecycle/VerifyCommandRunner.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Services/VerifyCommandRunnerTests.cs diff --git a/docs/open.md b/docs/open.md index 4dd410e2..1c7ef11c 100644 --- a/docs/open.md +++ b/docs/open.md @@ -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) diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 819740e1..6e5c3960 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -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, 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. `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 `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 diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index f4329e42..9a9d3051 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -17,5 +17,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration 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"); } } diff --git a/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.Designer.cs new file mode 100644 index 00000000..e72c471f --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.Designer.cs @@ -0,0 +1,832 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("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("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("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("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("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("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("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 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.cs b/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.cs new file mode 100644 index 00000000..f89a2a43 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805090016_AddVerifyCommand.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddVerifyCommand : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "verify_command", + table: "list_config", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "verify_command", + table: "list_config"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 7f209fe5..dab3b55b 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -198,6 +198,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("system_prompt"); + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + b.HasKey("ListId"); b.ToTable("list_config", (string)null); diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index cb6f538e..bee09fce 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -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!; diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index fd32701c..6875cefe 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -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); } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 4690448c..fb35e994 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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" } } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 7c1fb6bc..44830504 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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" } } } diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 77e3f794..ce975738 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -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`), `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`). diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index c2d5b0c4..375c612b 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -630,9 +630,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList 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? SessionSkills = null); +public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); -public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null); public sealed record SeedResultDto(int Copied, int Skipped); public sealed record WorktreeOverviewDto( diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index c51bcd1b..39fccbe4 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -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? SelectedSessionSkillNames() diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index c4d5ade3..49de027d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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) { diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 0c6ea2b5..6793713d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -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 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(); } diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index fd68d672..6242ab0c 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -83,6 +83,20 @@ + + + + + + + + + + + + diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 480252d8..4ad3e64b 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -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//` 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//` 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) @@ -101,7 +101,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 `, 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` 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`). diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index cb6f545d..4010f656 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -79,9 +79,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList 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? SessionSkills = null); +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null, string? VerifyCommand = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? 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) diff --git a/src/ClaudeDo.Worker/Lifecycle/Interfaces/IVerifyCommandRunner.cs b/src/ClaudeDo.Worker/Lifecycle/Interfaces/IVerifyCommandRunner.cs new file mode 100644 index 00000000..25ff66ea --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/Interfaces/IVerifyCommandRunner.cs @@ -0,0 +1,8 @@ +namespace ClaudeDo.Worker.Lifecycle; + +public sealed record VerifyCommandResult(int ExitCode, bool TimedOut, string Output); + +public interface IVerifyCommandRunner +{ + Task RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct); +} diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 2c1a8574..34bdaa17 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; @@ -34,19 +35,35 @@ public sealed record ConflictDocumentContent( 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 PreviewClean = "clean"; 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 MergeGates = + new(StringComparer.OrdinalIgnoreCase); + + private static SemaphoreSlim GetMergeGate(string workingDir) => + MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1)); + private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; + private readonly IVerifyCommandRunner _verify; private readonly ILogger _logger; public TaskMergeService( @@ -54,25 +71,67 @@ public sealed class TaskMergeService GitService git, HubBroadcaster broadcaster, ITaskStateService state, + IVerifyCommandRunner verify, ILogger 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); + } + + /// + /// Runs the list's configured verify command (if any) in after + /// a successful merge. Returns null when there is nothing to gate on (identical to today's + /// behavior); otherwise returns the terminal 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). + /// + private async Task 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(), + $"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(), $"{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, CancellationToken ct) @@ -101,7 +160,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"); @@ -111,79 +170,95 @@ 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 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 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(), $"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) + 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(), $"merge failed: {stderr}"); - } - - return new MergeResult(StatusConflict, files, null); - } - - 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, 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(), cleanupWarning); } - - await MarkWorktreeMergedAsync(taskId, 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(), cleanupWarning); + finally { gate.Release(); } } public Task MergeAsync( @@ -196,53 +271,69 @@ public sealed class TaskMergeService public async Task 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(); - 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(); + 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}"); } + + await MarkWorktreeMergedAsync(taskId, 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(), 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}"); } - - await MarkWorktreeMergedAsync(taskId, ct); - await ApproveIfWaitingForReviewAsync(task, ct); - _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); - - return new MergeResult(StatusMerged, Array.Empty(), null); + finally { gate.Release(); } } public async Task 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}"); @@ -263,7 +354,7 @@ public sealed class TaskMergeService /// public async Task 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"); @@ -298,7 +389,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"); @@ -309,7 +400,7 @@ public sealed class TaskMergeService public async Task 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()); @@ -321,7 +412,7 @@ public sealed class TaskMergeService public async Task 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(), 0); @@ -348,7 +439,7 @@ public sealed class TaskMergeService public async Task 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"); diff --git a/src/ClaudeDo.Worker/Lifecycle/VerifyCommandRunner.cs b/src/ClaudeDo.Worker/Lifecycle/VerifyCommandRunner.cs new file mode 100644 index 00000000..fadd54cd --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/VerifyCommandRunner.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Text; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// 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). +/// +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 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); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5acfe65e..1cfb8b55 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -83,6 +83,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index d9eaf7ef..43368866 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -77,7 +77,7 @@ public sealed class AddSubtaskToolTests : IDisposable var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); var queue = new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels); var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); - var merge = new TaskMergeService(dbFactory, git, broadcaster, state, NullLogger.Instance); + var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( dbFactory, merge, aggregator, broadcaster, git, state, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 66f17b16..190db470 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -66,7 +66,7 @@ public sealed class BatchMcpToolsTests : IDisposable var factory = _db.CreateFactory(); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(factory).State; - var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger.Instance); + var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 38b490ec..1f2b7cc1 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -128,7 +128,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var factory = _db.CreateFactory(); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(factory).State; - var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger.Instance); + var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index 84633f8f..5e8707fa 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -309,6 +309,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable var merge = new TaskMergeService( factory, git, broadcaster, built.State, + new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator( factory, git, diff --git a/tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs index a20bf0cc..16fa15f0 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs @@ -130,6 +130,7 @@ public sealed class TreeMergeTests : IDisposable var merge = new TaskMergeService( factory, git, broadcaster, built.State, + new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator( factory, git, diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs index c4f4f921..0a2357db 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs @@ -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(); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 6702e85e..b16511f3 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -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.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( @@ -704,6 +712,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() { @@ -776,6 +903,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 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 diff --git a/tests/ClaudeDo.Worker.Tests/Services/VerifyCommandRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Services/VerifyCommandRunnerTests.cs new file mode 100644 index 00000000..3b129e89 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/VerifyCommandRunnerTests.cs @@ -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); + } +}