diff --git a/docs/handoff-list-handler-2026-08-05.md b/docs/handoff-list-handler-2026-08-05.md new file mode 100644 index 00000000..2c67a052 --- /dev/null +++ b/docs/handoff-list-handler-2026-08-05.md @@ -0,0 +1,165 @@ +# Handoff — List-handler run on list "Claude do", 2026-08-05 + +Repo: `C:\Private\ClaudeDo` · List id: `5f973815-050a-4136-94f0-1506a5d4560a` · Branch: `main` (nothing pushed) + +Predecessor session ran the five-phase list handler over 11 briefed tasks and, along the way, +absorbed the 9-child "Usage Monitor" unit. **Phases 0–3 are complete for the brief.** What is +left is Phase 4 (review + merge) for three tasks, plus the Usage chain. + +--- + +## 1. Do this first — three brief tasks sit in WaitingForReview + +Merge in **this order** (the order was chosen with the user and matters): + +| # | Task | Id | Note | +|---|------|----|------| +| 1 | Feat: Merge zurücknehmen — Merge-Commit festhalten + `revert_merge` | `9e307199-2eca-4eb7-9057-d2c675cc57ca` | Migration + new tool. Merge **before** #2 | +| 2 | Feat: Verifikations-Gate nach dem Merge | `0b2fbb48-d44c-4155-8c21-d3464c0bd5c2` | Depends on #1's merge-SHA persistence; both edit `TaskMergeService.cs` | +| 3 | Feat: Antwortfeld auf der Roadblock-Karte | `8c1c213004574c4fad6beb75b84b70d7` | UI + localization (en **and** de) | + +For each one: + +1. `get_task_diff(taskId, stat=true)`, then the full diff if non-trivial. Sanity-check against + the task description (they are long and precise — the acceptance criteria are the checklist). +2. `review_task(taskId, decision="approve", leaveConflictsInTree=true)`. +3. On conflict: open the files under the returned `repoPath`, resolve keeping **both** sides' + intent, then `continue_merge(taskId)`. Conflicts are expected and normal here. +4. **After every merge, verify `main`** (see §4). This is non-negotiable — see §5. + +Expected conflicts: `TaskMergeService.cs` between #1 and #2; `src/ClaudeDo.Worker/CLAUDE.md` +and `src/ClaudeDo.Data/CLAUDE.md` in nearly every merge (doc bullet lists — trivial, keep both +sides' entries). + +## 2. Then the Usage Monitor unit + +Parent `439a4daf166f4ab5b0fa415693d2c80d` ("Usage Monitor hinzufügen") is `WaitingForChildren` +and has **no worktree of its own**. It has 9 children. Four are merged, one is in flight, four +are Idle. + +| Child | Id | State | +|---|---|---| +| #38 Data: Usage-Gate-Schwellen + Modell-Spalte | `c1c999b6-b800-4b6b-a821-fbc028c15772` | merged `b1efcdc` | +| #39 Worker: OAuth-Usage-Client + Poller | `f657e316-ad72-4f45-8036-460841fc8997` | merged `b126a21` | +| #40 Worker: UsageGate | `06a7cc32-6ab7-4758-98f4-bee77149b2bf` | merged `1ee21b5` | +| #41 Worker: TranscriptUsageReader | `840fdb98-1c0e-4219-8062-c8769233fc14` | merged `334cf1e` | +| #42 Worker: Hub-Surface für Usage | `c1df5b9a-b911-4fe8-aab4-5876d9d85793` | **re-queued, in flight — read §5 before touching** | +| #43 UI: Usage-Pill | `f74b44d9-7e48-4bfe-9d89-075e194d1fc9` | Idle — queue once #42 is merged | +| #45 UI: Gate-Schwellen im Settings-Modal | `06068810-5b5c-4635-80dd-62eeba89fb8c` | Idle — queue once #42 is merged (parallel with #43) | +| #44 UI: Usage-Monitor-Modal | `82488d2a-8ff7-41b8-b791-367959a8f827` | Idle — needs #42 **and** #43 merged | +| #46 Docs: Usage Monitor | `9c8cffe0-8f7b-401e-a4f0-33b937047082` | Idle — last, after everything is merged | + +**The chain is strictly serial and you must respect it.** Every child forks from `main`, and each +one's own description hard-requires the earlier ones. Queueing them all at once is exactly what +produced the original roadblock: #40 ran, found its prerequisite types only on unmerged sibling +branches, and returned `Done` having written **zero** code. So: merge a child → then queue the +next → verify `main` → repeat. + +When the last child is merged the parent surfaces for review by itself; approve it to close the +unit (it has no worktree, so it approves straight to Done). + +## 3. Cheap status polling — important + +`list_tasks` and `batch_get_tasks` return full descriptions and **blow the token limit** on this +list (`list_tasks` over 52 tasks = ~206,000 chars; that is literally one of the bugs this run +fixed). Do not poll with them. Poll the DB read-only instead: + +```bash +PYTHONIOENCODING=utf-8 python - <<'EOF' +import sqlite3 +c=sqlite3.connect("file:C:/Users/mika.kuns/.todo-app/todo.db?mode=ro",uri=True) +for i,s in c.execute("select id,status from tasks"): + print(s, i) +EOF +``` + +`list_worktrees` is also compact and safe. **New this run:** `wait_for_task_change(taskIds, +timeoutSeconds)` is now merged and is the proper primitive — it returns as soon as any listed +task leaves Queued/Running (server-clamped to 170 s). Prefer it over sleeping. + +## 4. Verify main after every merge + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release +``` + +For the UI/localization task (#3 above, and children #43–#45) also: + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +`.slnx` needs .NET 9 — build individual csproj files, `-c Release` (a running Worker locks Debug). + +Baseline as of this handoff: Worker **753/753**, Data **143/143**, build 0 warnings. + +## 5. The trap that cost this run the most time + +Two children "failed" with `"Claude exited with code 1 and no result"`. **That is a CLI crash, +not bad code.** In both cases the worktree held complete work that built with 0 warnings and +passed the full suite (#40: 732/732, #42: 766/766) — the run just died before the auto-commit. + +- **Never `reset_failed_task` on such a task** — it discards the worktree and destroys the work. +- Instead: `cd` into the worktree, `git status`, build + test it. If green, set the task + `Queued`. The worktree is preserved and the agent resumes its own session (`--resume`), + finds its work and commits it. That is how #40 was recovered. +- #42 is mid-recovery right now via exactly this route. If it failed again, verify its worktree + (`C:\Private\.claudedo-worktrees\claude-do\c1df5b9a-b911-4fe8-aab4-5876d9d85793`) before + doing anything destructive. + +Second trap: git merges cleanly and the **compiler** still breaks. It happened again this run — +`Usage/UsageModels.cs` was an add/add conflict, and `src/ClaudeDo.Worker/CLAUDE.md` merged +"cleanly" into a file with the `Usage/` folder documented **twice**. Always read what a clean +merge produced, and always run §4. + +## 6. Phase 0–3 decisions already made (do not redo) + +Dedupe: four candidate pairs examined, **nothing cancelled**. Decisions: + +- `05827da5` ↔ `81e37801` — kept both, and `05827da5` was **re-scoped**: its "lean status query" + half was removed because `81e37801`'s wait tool covers it. `05827da5` now owns only the + brief-description rendering. Both are merged. +- `a76d9547` ↔ `99732497` — kept both, `99732497` merged first. Done. +- `0b2fbb48` ↔ `9e307199` — kept both, `9e307199` merges first. **This is item #1/#2 in §1.** +- `20c78c95` ↔ `a76d9547`(b) — kept both, different actors. Done. + +Phase 2: all 11 tasks carry acceptance criteria, real file+line references and out-of-scope +sections. Three that were one-liners were researched and rewritten after asking the user +(ConPTY fix approach, maxTurns-only scope, roadblock reply-box design). + +Run config: `maxParallelExecutions` = **3**. No list config exists, so effective max turns was +the global **100**; it was raised to **200** per-task on the five heaviest via `set_task_config`. +`0b2fbb48`, `9e307199` and `8c1c2130` still carry that override. + +## 7. Open follow-ups worth new tasks + +1. **`~/.todo-app/prompts/planning.md` shadows the planning prompt.** `PromptFiles.EnsureExists` + only writes a default when the file is absent, and that file exists (dated Jun 2). The + `maxTurns` guidance merged in `65db1cd` therefore **does not reach real planning sessions** + until that file is updated by hand. `system.md` and `agent.md` are shadowed too. + `merge-helper-system.md`/`merge-helper-initial.md` do **not** exist, so this run's handler + prompt changes are live. +2. **MCP task DTOs expose no parent/child link.** The 9-child Usage unit had to be reconstructed + from `sortOrder` and creation timestamps. `get_task`/`list_tasks` should return + `parentTaskId` / `blockedByTaskId`. +3. **Visual verification open** on: the ConPTY fix (open a tile on a task whose description + contains `->`), and — once merged — the roadblock reply box and the verify-gate field in the + list settings modal. +4. **`"exited with code 1 and no result"` is too common.** Three runs died that way today, two + with finished work. Worth investigating whether the auto-commit step can be made to survive + a late CLI crash. +5. Nothing has been **pushed**. `main` is 22 commits ahead of `8d7ba1e`. + +## 8. Rules this session operated under + +- Drive merges through the MCP tools. Never raw `git merge` / `reset` / `checkout`. +- Hand-resolve only markers the tools left behind, then `continue_merge`. When committing by + hand is unavoidable, stage **explicit paths** — never `git add -A`: the main checkout is + shared with other sessions. +- For a parent/children unit merge, pass the **parent** id to `continue_merge` / `abort_merge`. +- Ask the user on anything ambiguous, risky, or destructive. +- Never `delete_task` to dedupe — `Cancelled` keeps it visible and resettable. diff --git a/docs/usage-optimization.md b/docs/usage-optimization.md new file mode 100644 index 00000000..d9acfc05 --- /dev/null +++ b/docs/usage-optimization.md @@ -0,0 +1,196 @@ +# Usage-Optimierung — Befunde und Messmethodik + +Stand: 2026-08-05. Ausgangsfrage: Wie lassen sich autonome ClaudeDo-Agents +token-effizient betreiben? Abrechnung läuft über **Abo-Session-Limits, nicht über +die API** — relevant ist roher Token-Verbrauch gegen 5h-/7d-Fenster, nicht Geld. + +Dieses Dokument ist bewusst kompakt gehalten. Was eine Session früh in den Prefix +lädt, wird mit jeder weiteren Nachricht multipliziert (siehe Befund 5) — ein +30k-Handover-Dokument würde das Problem reproduzieren, das es beschreibt. + +--- + +## 1. Verbrauchsstruktur + +Gemessen über alle Transcripts unter `C:\Users\mika.kuns\.claude\projects`. + +| Posten | Roh-Token | Anteil | +|---|---:|---:| +| Cache-Read | 3.118.025.644 | 95,6 % | +| Cache-Write | 140.783.227 | 4,3 % | +| Output | 22.466.702 | 0,7 % | +| Input frisch | 141.496 | 0,0 % | + +**Kontext-Resend = 99,3 % des Rohverbrauchs.** Auch unter API-Preisgewichtung +(read 0,1× / write 1,25× / out 5×) bleibt es bei 81,3 %. Die Rangfolge ist gegen +jede plausible Gewichtung robust. + +Verstärkung: ~3,6 Mio einzigartiger Inhalt → 1,59 Mrd abgerechnete Prompt-Token +in ClaudeDo-Sessions. **Jeder Kontext-Token wird im Schnitt ~425× erneut +abgerechnet.** + +## 2. Scope-Split — wer verbraucht + +| Scope | Roh-Token | Anteil | +|---|---:|---:| +| INTERAKTIV (Mikas eigene Sessions) | 2.690.028.785 | **81,6 %** | +| AGENT (ClaudeDo-Runs) | 606.222.055 | **18,4 %** | + +Interaktiv nach Modell: opus-4-8 33,0 % · opus-5 28,5 % · sonnet-5 14,0 % · +fable-5 5,2 %. Agent-Runs fahren überwiegend sonnet-5 — der Default greift, die +Modelldisziplin auf Agent-Seite ist in Ordnung. + +**Konsequenz:** ClaudeDo-Optimierung adressiert maximal 18 % des Limits. Der +größere Block sind die eigenen Opus-Sessions. + +## 3. Session-Länge ist der Treiber + +Verbrauch ≈ Nachrichten × Ø-Kontext, und der Kontext wächst mit den Nachrichten → +**quadratisch**. Eine Session in k Teile schneiden bringt grob 1/k. + +Interaktiv: **Top 20 von 255 Sessions = 55,9 % des Verbrauchs.** + +| Roh-Token | Msgs | Ø Kontext | Modell | +|---:|---:|---:|---| +| 199.516.097 | 716 | 278.653 | opus-4-8 | +| 138.020.247 | 440 | 313.682 | opus-5 | +| 123.586.522 | 463 | 266.925 | opus-4-8 | + +## 4. Was den Kontext füllt + +| Tool | Anteil (Agent) | Anteil (interaktiv) | +|---|---:|---:| +| Read | 59,8 % | 68,2 % | +| Bash | 16,3 % | 15,4 % | +| Grep | 8,3 % | 5,2 % | + +Read ohne `offset`/`limit`: **57 % (Agent) / 62 % (interaktiv)**. +Re-Reads derselben Datei in derselben Session: 18 % der Read-Calls. +Subagent-Nutzung: nur 40 Calls bei 1.325 Reads (Agent-Seite) — die +Kontext-Firewall ist praktisch ungenutzt. Interaktiv sind Subagent-Sessions +dagegen 20 % des Verbrauchs. + +Teuerste Read-Ziele sind God-Files: `TasksIslandViewModel.cs` (1121 Z.), +`ExternalMcpService.cs` (1002 Z.), `WorkerHub.cs` (979 Z.). + +## 5. Prefix-Größe × Nachrichtenzahl schlägt alles + +Fallstudie an der Analyse-Session selbst (136 Msgs, 43.492.835 Roh-Token): + +``` +msg 1 : 39.914 +msg 3 : 273.822 (+233.908) <-- claude-api-Skill geladen +msg 136: 380.327 +``` + +Der Skill in Nachricht 3 wurde danach 136× mitgelesen: **31,8 Mio Token = 73 % +der Session**. Zum Vergleich: **alle tool_results zusammen = 21.600 Token +(0,05 %)**. + +Discovery ist praktisch gratis, wenn man aggregiert statt Dateien dumpt. Teuer ist +ausschließlich, was früh und groß in den Prefix wandert. Derselbe Block in +Nachricht 120 geladen hätte ein Zwanzigstel gekostet. + +## 6. Amortisation von Planungssessions + +| Posten | Token | +|---|---:| +| Analyse-Session gesamt | 43,5 Mio | +| davon vermeidbarer Prefix-Ballast | −31,8 Mio | +| echte Planungsleistung | ≈ 11,7 Mio | + +Gegenrechnung: **10 von 37 Tasks brauchten einen Retry (27 %)**, 13 von 54 Runs +sind Wiederholungen. Ein Agent-Run liegt im Schnitt bei ~11 Mio Roh-Token — ein +vermiedener Retry spart also grob eine ganze Planungssession. Dazu ersparte +Rediscovery: ein Fund von ~8k Token, den ein Agent in Turn 10 eines 60-Turn-Runs +selbst machen müsste, wird danach ~50× mitgelesen = ~400k pro Fund. + +**Fazit: gründliche Planung rechnet sich — aber nur bei schlankem Prefix.** + +--- + +## Was NICHT hilft (geprüft und verworfen) + +- **`--effort` senken.** Thinking ist 12.536 Token über alle interaktiven + Sessions = **0,00 %** des Verbrauchs. Effort zu senken spart nichts und kostet + Qualität. Endgültig erledigt. +- **Skill-Trigger entschärfen.** Der `claude-api`-Skill kostete in einer Session + 73 % — feuerte aber nur **2× in 3 Wochen** (2026-07-14, 2026-08-05). Erwarteter + Nutzen vernachlässigbar, Risiko (Antworten aus veraltetem Prior) real. Er hat + sich in dieser Session sogar bezahlt gemacht: der Hinweis, dass `input_tokens` + nur der uncached Rest ist, hat den Accounting-Bug aufgedeckt. +- **God-Files splitten.** Eigenes Risiko; Read-Disziplin entschärft das Symptom + billiger. + +## Offene Hebel — noch nicht untersucht + +- Interaktive Session-Hygiene: ab wann lohnt `/clear`, messbar an Ø-Kontext? +- Modellrouting interaktiv (opus-4-8 + opus-5 = 61,5 % des Accounts). +- Subagent-Ökonomie: Firewall-Nutzen gegen Eigenverbrauch (20 % interaktiv). +- MCP-Tool-Definitionen im Prefix: Umfang bei ~200 deferred Tools nicht gemessen. +- Kalibrierung der tatsächlichen Limit-Gewichtung gegen die Rohtoken-Zahlen + (braucht `/usage`-Ausgabe; der OAuth-Endpoint ist für Claude nicht zugänglich, + weil `~/.claude/.credentials.json` hart blockiert ist). + +--- + +## Messmethodik (reproduzierbar) + +Datenquelle: `C:\Users\mika.kuns\.claude\projects\**\*.jsonl`, ein Record je +Message, Verbrauch in `message.usage`. + +```python +raw = (u.get('input_tokens',0) + u.get('output_tokens',0) + + u.get('cache_read_input_tokens',0) + u.get('cache_creation_input_tokens',0)) +``` + +Kontextgröße einer Message = `input_tokens + cache_read + cache_write` (ohne +Output). Der Verlauf über die Session zeigt Prefix-Sprünge. + +### Fallstricke + +1. **Scope-Filter.** NICHT nach Pfadname `claudedo` filtern — das zieht + interaktive Sessions im ClaudeDo-Repo mit rein und verfälscht den Split massiv + (53 % statt korrekt 18,4 %). Agent-Runs erkennt man an `claudedo-worktrees` + oder `sandbox` im Projektordner, so wie es `TranscriptUsageReader` macht. +2. **``-Messages** überspringen — keine echten API-Calls. +3. **`task_runs.tokens_in` ist unbrauchbar** — liest nur `input_tokens`, also den + uncached Rest. Lag bei einer 79-Mio-Token-Session bei 200 (Faktor ~400.000). + `tokens_out` zusätzlich 3,8× zu niedrig. Siehe Task `38394081`. +4. **In Bash absolute Pfade verwenden** — `$HOME`/`~` zeigt auf dieser Maschine + auf das P:-Laufwerk, nicht auf `C:\Users\mika.kuns`. +5. **DB nie direkt lesen** während die App läuft — Kopie ziehen (`todo.db` + + `todo.db-wal`). +6. **Grundrate prüfen, bevor ein Hebel empfohlen wird.** Ein teurer Einzelfall ist + kein Muster (siehe Skill-Trigger oben). + +--- + +## Abgeleitete Tasks (Liste „Claude do") + +Empfohlene Reihenfolge: + +| # | ID | Titel | +|---|---|---| +| 1 | `1b599d67` | Prompt-Dateien frieren Default ein — `SuggestImprovement`/`AskUser` tot | +| 2 | `38394081` | Limit-Verbrauch pro Run sichtbar machen (Cache-Token in `task_runs`) | +| 3 | `0d0aa8b0` | Read-Disziplin + Explorer-Subagent im System-Prompt | +| 4 | `2de2f008` | `max_turns` deckeln (Ceiling, Defaults, UI-Warnung) | +| 5 | `87105f5e` | UsageGate: Parallelität stufenweise drosseln | + +(1) zuerst, weil es ein echter Funktionsbug ist und Voraussetzung dafür, dass (3) +den Nutzer überhaupt erreicht. (2) als Nächstes, weil ohne korrektes Accounting +nichts messbar ist. + +### Ist-Zustand zum Zeitpunkt der Analyse + +- `app_settings`: `default_model` = sonnet, `default_max_turns` = 100, + `max_parallel_executions` = 3, `model_presets` = **null** (fällt auf Defaults + zurück: haiku 20 / sonnet 30 / opus 40 / fable 25 — greifen faktisch nie). +- 15 Tasks überschreiben `max_turns` nach oben: 10× auf 100, 5× auf 200. +- UsageGate existiert bereits: 5h @ 80 %, 7d @ 90 % + (Migration `20260805074906_AddUsageGateAndRunModel`, dieselbe Migration hat auch + die `model`-Spalte auf `task_runs` gebracht). +- `~/.todo-app/prompts/system.md` stammt vom 2026-06-04 und weicht vom + `SystemDefault` in `PromptFiles.cs` ab; `agent.md` und `planning.md` dort sind + verwaiste Reste eines alten Namensschemas. diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 819740e1..106f9b91 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -7,7 +7,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) -- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) +- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`) - **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range. - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` @@ -25,7 +25,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them. - **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` -- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` +- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit) - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync` - **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync` @@ -41,11 +41,11 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q ## Git -- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo +- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), revert (`RevertMergeCommitAsync` — `git revert --no-edit -m 1 `, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync` — `REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions. ## 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 `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). ## Conventions diff --git a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs index 2917f475..3b1b2194 100644 --- a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs @@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration w.BaseCommit).HasColumnName("base_commit").IsRequired(); builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); + builder.Property(w => w.MergeCommit).HasColumnName("merge_commit"); builder.Property(w => w.State).HasColumnName("state").IsRequired() .HasDefaultValue(WorktreeState.Active) .HasConversion(StateConverter); diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index e4f28966..1e2585bc 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -280,6 +280,36 @@ public sealed class GitService throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}"); } + public async Task IsMidRevertAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct); + if (exitCode != 0) return false; + var gitDir = stdout.Trim(); + if (!Path.IsPathRooted(gitDir)) + gitDir = Path.Combine(repoDir, gitDir); + return File.Exists(Path.Combine(gitDir, "REVERT_HEAD")); + } + + /// + /// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to + /// revert a merge commit. On success this creates a new commit with the inverse changes; + /// the original commit and all history stay intact (no rewrite, no reset). + /// + public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync( + string repoDir, string mergeCommitSha, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, + ["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct); + return (exitCode, stderr); + } + + public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}"); + } + public async Task> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, diff --git a/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.Designer.cs new file mode 100644 index 00000000..b04c77f6 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.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("20260805091910_AddWorktreeMergeCommit")] + partial class AddWorktreeMergeCommit + { + /// + 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.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("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_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/20260805091910_AddWorktreeMergeCommit.cs b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs new file mode 100644 index 00000000..c403c0e5 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260805091910_AddWorktreeMergeCommit.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddWorktreeMergeCommit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "merge_commit", + table: "worktrees", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "merge_commit", + table: "worktrees"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 7f209fe5..365035ca 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -707,6 +707,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("head_commit"); + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + b.Property("Path") .IsRequired() .HasColumnType("TEXT") diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs index 9ac26b9c..defcb4a9 100644 --- a/src/ClaudeDo.Data/Models/WorktreeEntity.cs +++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs @@ -16,6 +16,7 @@ public sealed class WorktreeEntity public required string BaseCommit { get; set; } public string? HeadCommit { get; set; } public string? DiffStat { get; set; } + public string? MergeCommit { get; set; } public WorktreeState State { get; set; } = WorktreeState.Active; public required DateTime CreatedAt { get; init; } diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index 6499aa5a..04576c04 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -36,6 +36,15 @@ public sealed class WorktreeRepository .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct); } + public async Task SetMergedAsync(string taskId, string mergeCommit, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(w => w.State, WorktreeState.Merged) + .SetProperty(w => w.MergeCommit, mergeCommit), ct); + } + public async Task DeleteAsync(string taskId, CancellationToken ct = default) { await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index b6b2c303..52d4acd5 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -34,7 +34,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an ` - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." **No external tool returns bare `Task` or a nullable payload directly** — an MCP client can't tell an empty/omitted response apart from a dropped one. Write tools return a small confirmation record (`{ ok/deleted/removed/reset/started: true, , ... }`, e.g. `DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`, `RemoveAttachmentResult`; `SetListConfigResult`/`SetTaskConfigResult` additionally echo the resulting config so the caller can see which fields were set vs. cleared to null); read tools that may have nothing to return use an explicit `Found`/`Available` flag alongside the nullable payload (`TaskConfigResult`, pre-existing `BatchGetTaskResult`, `TaskLogResult`) instead of returning null outright. Organized by concern: - - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `PreviewMerge` (non-destructive `git merge-tree --write-tree` mergeability check for one task's worktree branch against `targetBranch`, default the repo's current branch — status/conflictFiles/changedFileCount plus `behind`; throws a clear error instead of TaskMergeService.PreviewAsync's silent "unavailable" when the task has no worktree, the worktree isn't Active, or the list's working dir is missing), `PreviewMergeSet` (same preview for a batch of task ids plus a file→tasks overlap report built from each task's own diff-stat — a same-file-name hint only, blind to cross-file collisions like the CS0103 case that motivated it; a task that fails to preview gets `error` set and is excluded from the overlap instead of aborting the batch), `ListWorktrees`, `CleanupTaskWorktree` + - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done` — `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge` (non-destructive `git merge-tree --write-tree` mergeability check for one task's worktree branch against `targetBranch`, default the repo's current branch — status/conflictFiles/changedFileCount plus `behind`; throws a clear error instead of TaskMergeService.PreviewAsync's silent "unavailable" when the task has no worktree, the worktree isn't Active, or the list's working dir is missing), `PreviewMergeSet` (same preview for a batch of task ids plus a file→tasks overlap report built from each task's own diff-stat — a same-file-name hint only, blind to cross-file collisions like the CS0103 case that motivated it; a task that fails to preview gets `error` set and is excluded from the overlap instead of aborting the batch), `RevertMerge` (undoes a previously merged task's merge commit on `targetBranch` via `git revert -m 1` — a new commit, never a reset/rewrite, since the target working directory is shared with other sessions; requires the task to be `Done` with a `Merged` worktree carrying a recorded `WorktreeEntity.MergeCommit` — a task merged before that field existed has none and is refused rather than guessed via `git log`; on success the task returns to `WaitingForReview` and the worktree moves to `Kept`, not `Active` (its directory/branch are typically already gone from the original merge's cleanup) and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those); a conflicting revert is aborted immediately, no half-resolved state is ever left in the tree), `ListWorktrees`, `CleanupTaskWorktree` - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` - `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index ff7f79c5..9c6bf0f5 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -39,7 +39,7 @@ public sealed record TaskDto( public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, - int Ahead, int Behind, bool IsDirty); + int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); public sealed record TaskDiffDto( string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); @@ -70,6 +70,9 @@ public sealed record WorktreeListItemDto( public sealed record CleanupWorktreeResult( bool Removed, string WorktreePath, bool BranchDeleted); +public sealed record RevertMergeResultDto( + bool Reverted, string? RevertCommit, IReadOnlyList Conflicts, string? Message); + public sealed record DailyPrepCandidateDto( string Id, string ListId, string ListName, string Title, string? Description, bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); @@ -497,7 +500,10 @@ public sealed class ExternalMcpService "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " + "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " + "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " + - "isDirty (has uncommitted changes in the worktree directory). " + + "isDirty (has uncommitted changes in the worktree directory), " + + "mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " + + "if it has been merged and that succeeded after this field was introduced; null otherwise — " + + "required by revert_merge). " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { @@ -511,7 +517,7 @@ public sealed class ExternalMcpService var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); - return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty); + return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit); } [McpServerTool, Description( @@ -838,6 +844,34 @@ public sealed class ExternalMcpService return (preview, behind, changedFiles); } + [McpServerTool, Description( + "Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " + + "a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " + + "Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " + + "before this feature existed have no recorded commit and are refused rather than guessed via git log. " + + "On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " + + "WaitingForReview so it can be reconsidered. " + + "On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " + + "left in the tree) and conflicts lists the files that would have conflicted. " + + "Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " + + "tree has uncommitted changes from another session.")] + public async Task RevertMerge( + string taskId, string targetBranch = "main", CancellationToken cancellationToken = default) + { + var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); + + if (result.Status == TaskMergeService.StatusReverted) + { + await _broadcaster.TaskUpdated(taskId); + return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty(), null); + } + + if (result.Status == TaskMergeService.StatusConflictAborted) + return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage); + + throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}"); + } + [McpServerTool, Description( "List all ClaudeDo-tracked worktrees. " + "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 2c1a8574..acbdf8af 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -32,6 +32,12 @@ public sealed record ConflictDocumentContent( bool IsBinary, IReadOnlyList Segments); +public sealed record RevertResult( + string Status, + string? RevertCommit, + IReadOnlyList ConflictFiles, + string? ErrorMessage); + public sealed class TaskMergeService { public const string StatusMerged = "merged"; @@ -39,6 +45,9 @@ public sealed class TaskMergeService public const string StatusBlocked = "blocked"; public const string StatusAborted = "aborted"; + public const string StatusReverted = "reverted"; + public const string StatusConflictAborted = "conflict_aborted"; + public const string PreviewClean = "clean"; public const string PreviewConflict = "conflict"; public const string PreviewUnavailable = "unavailable"; @@ -75,11 +84,11 @@ public sealed class TaskMergeService return (task, list, wt); } - private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct) + private async Task MarkWorktreeMergedAsync(string taskId, string mergeCommitSha, CancellationToken ct) { using (var ctx = _dbFactory.CreateDbContext()) { - await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); + await new WorktreeRepository(ctx).SetMergedAsync(taskId, mergeCommitSha, ct); } await _broadcaster.WorktreeUpdated(taskId); } @@ -155,6 +164,8 @@ public sealed class TaskMergeService return new MergeResult(StatusConflict, files, null); } + var mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + string? cleanupWarning = null; if (removeWorktree) { @@ -175,7 +186,7 @@ public sealed class TaskMergeService } } - await MarkWorktreeMergedAsync(taskId, ct); + await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation( @@ -233,7 +244,8 @@ public sealed class TaskMergeService 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 mergeSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await ApproveIfWaitingForReviewAsync(task, ct); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); @@ -257,6 +269,84 @@ public sealed class TaskMergeService return new MergeResult(StatusAborted, Array.Empty(), null); } + /// + /// Reverts the merge commit recorded for this task () + /// via `git revert -m 1`, a new commit that undoes the merge without rewriting history — the + /// target working directory is shared with other sessions, so a reset/rebase is never an option. + /// On success the task returns to WaitingForReview so it can be reconsidered, and the worktree + /// state moves to Kept: Merged/Discarded are swept by WorktreeMaintenanceService, and by the time + /// a merge can be reverted its worktree directory and branch are typically already gone (removed + /// during the original merge cleanup), so Active — which implies a live, resumable worktree — + /// would be misleading. A conflicting revert is aborted immediately (`git revert --abort`); no + /// partial/half-resolved state is ever left in the tree. + /// + public async Task RevertMergeAsync(string taskId, string targetBranch, CancellationToken ct) + { + var (task, list, wt) = await LoadMergeContextAsync(taskId, ct); + + if (task.Status != TaskStatus.Done) + return RevertBlocked("task is not Done; only a merged task's revert can be undone"); + if (wt is null) + return RevertBlocked("task has no worktree"); + if (wt.State != WorktreeState.Merged) + return RevertBlocked($"worktree state is {wt.State}, expected Merged"); + if (string.IsNullOrWhiteSpace(wt.MergeCommit)) + return RevertBlocked("no merge commit recorded for this task; cannot revert"); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + return RevertBlocked("list has no working directory"); + if (!await _git.IsGitRepoAsync(list.WorkingDir, ct)) + return RevertBlocked("working directory is not a git repository"); + if (await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return RevertBlocked("target working directory is mid-merge"); + if (await _git.IsMidRevertAsync(list.WorkingDir, ct)) + return RevertBlocked("target working directory is mid-revert"); + if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct)) + return RevertBlocked("target working tree has uncommitted changes"); + + 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 RevertBlocked($"failed to switch target branch: {ex.Message}"); } + } + + var (exitCode, stderr) = await _git.RevertMergeCommitAsync(list.WorkingDir, wt.MergeCommit!, ct); + if (exitCode != 0) + { + List files; + try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } + catch { files = new(); } + + try { await _git.RevertAbortAsync(list.WorkingDir, ct); } + catch (Exception ex) + { + _logger.LogError(ex, "git revert --abort failed after conflict — repo is mid-revert"); + return RevertBlocked($"revert conflict and abort failed: {ex.Message} — repo is mid-revert, resolve manually"); + } + + if (files.Count == 0) + return RevertBlocked($"revert failed: {stderr}"); + + return new RevertResult(StatusConflictAborted, null, files, "revert conflicted; aborted cleanly, no changes made"); + } + + var revertSha = await _git.RevParseHeadAsync(list.WorkingDir, ct); + + using (var ctx = _dbFactory.CreateDbContext()) + { + await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Kept, ct); + } + await _broadcaster.WorktreeUpdated(taskId); + await _state.ForceSetStatusAsync(taskId, TaskStatus.WaitingForReview, ct); + + _logger.LogInformation( + "Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}", + taskId, wt.MergeCommit, revertSha); + await _broadcaster.WorkerLog($"Reverted merge of \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); + + return new RevertResult(StatusReverted, revertSha, Array.Empty(), null); + } + /// /// Reads each conflicted working-tree file and parses its conflict markers into line-level /// segments (with the diff3 merge base when present). Binary files are flagged and skipped. @@ -376,4 +466,7 @@ public sealed class TaskMergeService private static MergeResult Blocked(string reason) => new(StatusBlocked, Array.Empty(), reason); + + private static RevertResult RevertBlocked(string reason) => + new(StatusBlocked, null, Array.Empty(), reason); } diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index b0929312..1811bd59 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -626,6 +626,39 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.False(info.IsDirty); } + [Fact] + public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + Assert.Null(info.MergeCommit); + } + + [Fact] + public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + Assert.Equal(expected, info.MergeCommit); + } + // ── GetTaskDiff ──────────────────────────────────────────────────────────── [Fact] @@ -1138,6 +1171,78 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Contains("mid-merge", ex.Message); } + // ── RevertMerge ──────────────────────────────────────────────────────────── + + [Fact] + public async Task RevertMerge_NotMerged_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); + Assert.Contains("expected Merged", ex.Message); + } + + [Fact] + public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.True(result.Reverted); + Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); + Assert.Empty(result.Conflicts); + Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); + } + + [Fact] + public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); + File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); + GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); + GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); + + var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); + var sut = BuildSut(CreateQueue()); + await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); + + // A later commit on main edits the exact line the merge introduced, so the revert conflicts. + File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); + GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); + + var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); + + Assert.False(result.Reverted); + Assert.Contains("README.md", result.Conflicts); + Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); + + var reloaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, reloaded!.Status); + } + [Fact] public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index 3886cb64..f01c3cb7 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -226,4 +226,95 @@ public class GitServiceMergeTests : IDisposable await git.MergeAbortAsync(repo.RepoDir); } + + [Fact] + public async Task IsMidRevertAsync_FreshRepo_ReturnsFalse() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var git = new GitService(); + + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + } + + [Fact] + public async Task IsMidRevertAsync_RevertHeadPresent_ReturnsTrue() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var revertHead = Path.Combine(repo.RepoDir, ".git", "REVERT_HEAD"); + File.WriteAllText(revertHead, "0000000000000000000000000000000000000000\n"); + + var git = new GitService(); + Assert.True(await git.IsMidRevertAsync(repo.RepoDir)); + } + + [Fact] + public async Task RevertMergeCommitAsync_CleanMerge_ReturnsZero_AndUndoesMergedChange() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/revert"); + File.WriteAllText(Path.Combine(repo.RepoDir, "feature.txt"), "hello\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: add feature.txt"); + + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); } + + var git = new GitService(); + var (mergeExit, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/revert", "Merge feature/revert"); + Assert.Equal(0, mergeExit); + var mergeSha = (await git.RevParseHeadAsync(repo.RepoDir)).Trim(); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feature.txt"))); + + var (revertExit, _) = await git.RevertMergeCommitAsync(repo.RepoDir, mergeSha); + + Assert.Equal(0, revertExit); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, "feature.txt"))); + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + // History stays intact — both the merge and the new revert commit are reachable. + var log = GitRepoFixture.RunGit(repo.RepoDir, "log", "--oneline"); + Assert.Contains(mergeSha[..7], log); + } + + [Fact] + public async Task RevertMergeCommitAsync_ConflictingSubsequentEdit_ReturnsNonZero_AndAbortCleansUp() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + + GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature/revert-conflict"); + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\nfeature\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: append feature line"); + + try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } + catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); } + + var git = new GitService(); + var (mergeExit, _) = await git.MergeNoFfAsync(repo.RepoDir, "feature/revert-conflict", "Merge feature/revert-conflict"); + Assert.Equal(0, mergeExit); + var mergeSha = (await git.RevParseHeadAsync(repo.RepoDir)).Trim(); + + // A direct edit on main to the same line the merge introduced makes the revert conflict. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var (revertExit, _) = await git.RevertMergeCommitAsync(repo.RepoDir, mergeSha); + + Assert.NotEqual(0, revertExit); + Assert.True(await git.IsMidRevertAsync(repo.RepoDir)); + var conflicted = await git.ListConflictedFilesAsync(repo.RepoDir); + Assert.Contains("README.md", conflicted); + + await git.RevertAbortAsync(repo.RepoDir); + + Assert.False(await git.IsMidRevertAsync(repo.RepoDir)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"))); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 6702e85e..30e2e79b 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -54,7 +54,8 @@ public class TaskMergeServiceTests : IDisposable } private static async Task SeedWorktree( - DbFixture db, string taskId, string path, string branchName, string baseCommit) + DbFixture db, string taskId, string path, string branchName, string baseCommit, + WorktreeState state = WorktreeState.Active, string? mergeCommit = null) { var wt = new WorktreeEntity { @@ -62,7 +63,8 @@ public class TaskMergeServiceTests : IDisposable Path = path, BranchName = branchName, BaseCommit = baseCommit, - State = WorktreeState.Active, + State = state, + MergeCommit = mergeCommit, CreatedAt = DateTime.UtcNow, }; using var ctx = db.CreateContext(); @@ -772,6 +774,214 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); } + + [Fact] + public async Task MergeAsync_Success_RecordsMergeCommitOnWorktree() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + 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 (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", result.Status); + + var headSha = (await new GitService().RevParseHeadAsync(repo.RepoDir)).Trim(); + + using var ctx = db.CreateContext(); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(headSha, wt!.MergeCommit); + } + + [Fact] + public async Task RevertMergeAsync_NoWorktree_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("no worktree", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_TaskNotDone_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Merged, mergeCommit: "cafebabe"); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("not Done", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_NoMergeCommitRecorded_ReturnsBlocked() + { + // Simulates a worktree merged before this feature existed — Merged state, no SHA. + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Merged, mergeCommit: null); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("no merge commit recorded", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_WorktreeNotMerged_ReturnsBlocked() + { + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done); + await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef", + state: WorktreeState.Active, mergeCommit: null); + var (svc, _) = BuildService(db); + + var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, result.Status); + Assert.Contains("expected Merged", result.ErrorMessage ?? ""); + } + + [Fact] + public async Task RevertMergeAsync_AfterCleanMerge_CreatesRevertCommitAndResetsState() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + 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 (svc, proxy) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt"))); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status); + Assert.False(string.IsNullOrWhiteSpace(revertResult.RevertCommit)); + Assert.Empty(revertResult.ConflictFiles); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt"))); + + using var ctx = db.CreateContext(); + var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, updatedTask!.Status); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Kept, wt!.State); + + Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id); + } + + [Fact] + public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + 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 (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + + // A concurrent session leaves an uncommitted, tracked-file change in the shared checkout. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty from another session\n"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusBlocked, revertResult.Status); + Assert.Contains("uncommitted", revertResult.ErrorMessage ?? ""); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + + using var ctx = db.CreateContext(); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Merged, wt!.State); + } + + [Fact] + public async Task RevertMergeAsync_ConflictingRevert_AbortsCleanlyAndReportsConflictFiles() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + 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, "README.md"), "# test repo\nfeature\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true, + commitMessage: "Merge task", ct: CancellationToken.None); + Assert.Equal("merged", mergeResult.Status); + + // A later commit on the target edits the exact line the merge introduced, so the revert conflicts. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line"); + var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); + + var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflictAborted, revertResult.Status); + Assert.Contains("README.md", revertResult.ConflictFiles); + Assert.False(await new GitService().IsMidRevertAsync(repo.RepoDir)); + Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim()); + Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"))); + + // Task/worktree state must not change on a conflicted, aborted revert. + using var ctx = db.CreateContext(); + var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, updatedTask!.Status); + var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Merged, wt!.State); + } } #region Test doubles