Merge branch 'claudedo/9e3071992eca4eb79057d2c675cc57ca'
This commit is contained in:
@@ -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.
|
||||
@@ -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. **`<synthetic>`-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.
|
||||
@@ -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 <sha>`, 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
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
|
||||
builder.Property(w => 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);
|
||||
|
||||
@@ -280,6 +280,36 @@ public sealed class GitService
|
||||
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
|
||||
}
|
||||
|
||||
public async Task<bool> 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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
|
||||
+832
@@ -0,0 +1,832 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260805091910_AddWorktreeMergeCommit")]
|
||||
partial class AddWorktreeMergeCommit
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(80)
|
||||
.HasColumnName("usage_gate_five_hour_pct");
|
||||
|
||||
b.Property<int>("UsageGateSevenDayPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(90)
|
||||
.HasColumnName("usage_gate_seven_day_pct");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
UsageGateFiveHourPct = 80,
|
||||
UsageGateSevenDayPct = 90,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("HandlerBaseCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_base_commit");
|
||||
|
||||
b.Property<string>("HandlerHeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("handler_head_commit");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorktreeMergeCommit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "merge_commit",
|
||||
table: "worktrees",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "merge_commit",
|
||||
table: "worktrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,6 +707,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("MergeCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("merge_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T>()`. 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, <id>, ... }`, 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`
|
||||
|
||||
+37
-3
@@ -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<string> 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<string> 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<WorktreeInfoDto> 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<RevertMergeResultDto> 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<string>(), 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), " +
|
||||
|
||||
@@ -32,6 +32,12 @@ public sealed record ConflictDocumentContent(
|
||||
bool IsBinary,
|
||||
IReadOnlyList<MergeSegment> Segments);
|
||||
|
||||
public sealed record RevertResult(
|
||||
string Status,
|
||||
string? RevertCommit,
|
||||
IReadOnlyList<string> 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<string>(), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverts the merge commit recorded for this task (<see cref="WorktreeEntity.MergeCommit"/>)
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<RevertResult> 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<string> 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<string>(), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<string>(), reason);
|
||||
|
||||
private static RevertResult RevertBlocked(string reason) =>
|
||||
new(StatusBlocked, null, Array.Empty<string>(), reason);
|
||||
}
|
||||
|
||||
@@ -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<InvalidOperationException>(
|
||||
() => 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()
|
||||
{
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user