Compare commits

...
71 Commits
Author SHA1 Message Date
mika kuns 5c3ec4810e Merge origin/main (v2.1.0 changelog)
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
2026-07-24 13:25:42 +02:00
mika kuns 49222a92a4 Merge claudedo/5d627df8 — #12 planning permission-mode default 2026-07-24 13:20:19 +02:00
mika kuns 109a35c505 feat(review): submit interactive (ConPTY) work for review
An embedded ConPTY session leaves its worktree changed but never touches
task status, so hand-driven work had no path into the review/merge flow.

Add SubmitTaskForReview: commit the worktree (same auto-commit as a headless
run), then transition Idle/Failed -> WaitingForReview via the new
TaskStateService.SubmitInteractiveForReviewAsync. Approve then merges it.

Surfaces: a 'Submit for review' button in the detail work console (shown for
an Idle/Failed task with a worktree) and on the ConPTY Command Center pane
header (task-based panes; closes the pane on success). Tests cover the new
transition (Idle/Failed accepted, Running/Queued/Done/Review rejected).
2026-07-24 13:05:22 +02:00
mika kuns 34b17537fc Merge claudedo/f9809a93 — Approve & Merge diff gate 2026-07-24 12:53:07 +02:00
mika kuns 2aaaa23912 feat(review): gate Approve & Merge behind opening the diff
Approve & Merge is disabled until the pending changes have been inspected:
DetailsIslandViewModel tracks ReviewDiffViewed (reset on task switch and on
every state change), MergeSectionViewModel raises DiffViewed when a diff or
combined diff is opened and exposes HasReviewableDiff, and a hint sits next
to the button. A review with nothing to inspect (sandbox run, no worktree)
approves straight through.

ClaudeDo-Task: f9809a93
2026-07-24 12:52:57 +02:00
mika kuns 624ec7a668 fix(planning): use default permission mode so MCP planning tools don't prompt
Interactive planning sessions launched with --permission-mode plan, which
gates EVERY MCP tool call regardless of --allowedTools (verified: even a
read-only mcp__claudedo__list_task_lists is denied under plan mode). So the
session prompted the user on the first CreateChildTask -- the whole point of
a planning session.

Switch BuildPlanningStartArgs/BuildPlanningResumeArgs to --permission-mode
default, which honours the allowlist. File edits stay blocked via the planning
system prompt + AllowedTools omitting Write/Edit/Bash. Resume also re-passes
--allowedTools, since the CLI does not restore it across --resume.

The earlier 'glob does not match' hypothesis was empirically falsified:
mcp__claudedo__*, the bare server name, and the explicit tool name all allow
the tool with zero permission_denials in default mode.
2026-07-24 12:38:45 +02:00
mika kuns 3e9ea3ad58 Merge claudedo/c14606f009e44e4ba04bfe5bcbc884ad 2026-07-24 12:13:18 +02:00
mika kuns ef285b21fd feat(planning): run interactive planning sessions via embedded ConPTY
Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
2026-07-24 12:12:23 +02:00
mika kuns 2612831a5e docs(planning): spec for ConPTY planning sessions 2026-07-24 12:12:22 +02:00
mika kuns 5e27486b18 chore(claude-do): Update notif popup is still not in the bottom Right hand cor
THe update notification should be in the Bottom Rught hand corner as a Small Popup card. IT should have the Update and Dismiss Butotns. It should only show when there is a new Update available, not if everything is up to date

ClaudeDo-Task: c14606f009e44e4ba04bfe5bcbc884ad
2026-07-24 11:55:29 +02:00
mika kuns 04044bd115 docs(fix-plan): record session progress (A/B done, C#9+#11, D#13+#14; C#10/#12 + group E deferred) 2026-07-24 11:44:21 +02:00
mika kuns 798d100636 feat(ui): AskUser question banner in the detail island
The inline answer banner for a running task's AskUser question only existed
in Mission Control. Surface the same banner in the detail pane, bound to the
detail island's existing TaskMonitorViewModel (shared state — no new state,
no duplication) so a question can be answered without opening Mission
Control. Mirrors MC: live TaskQuestionAsked events for the selected task.
2026-07-24 11:41:12 +02:00
mika kuns e8f7e3a47a fix(ui): hide misleading Idle chip on planning parents
A planning parent stays Status=Idle while its session is Active (or
Finalized-but-not-queued), so the lifecycle chip read "Idle"/"Parked"
next to the PLANNING/PLANNED badge. Hide the chip in that case; the badge
already conveys the state.
2026-07-24 11:38:49 +02:00
mika kuns 8a7275a75f fix: show structured-output summary instead of raw JSON in OUTCOME
A --json-schema run can echo the structured {"summary":...} JSON as the
result text, which then landed verbatim in task.Result and rendered raw in
the OUTCOME card. Unwrap the summary in StreamAnalyzer when the result text
is itself such a JSON object (a plain markdown result is kept as-is), and
add a UI safety net in ApplyOutcome for rows already persisted as raw JSON.
2026-07-24 11:36:25 +02:00
mika kuns f4dd67d595 fix(ui): live-refresh child rows on parent planning transitions
Finalize/Discard broadcast only the parent's TaskUpdated; the delta path
updated the parent row but never recomputed child-derived flags or dropped
discarded children, so subtasks stayed "Draft" after finalize and deleted
rows lingered after discard until a manual reload. Reconcile the whole
list when the updated task is (or owns) a planning subtree.
2026-07-24 11:26:19 +02:00
mika kuns 0226c98076 fix(ui): restore turn/token counts on task reload
Turns/Tokens were never populated from history, so a reloaded terminal
task showed "0/max". Restore them from the latest task_run.
2026-07-24 11:21:21 +02:00
mika kuns b9b3053051 fix(ui): clearer rename display in diff viewer
Show renames as old → new in the file header and suppress the misleading
"+0 −0" stats for a pure rename (the R badge already conveys it).
2026-07-24 11:21:20 +02:00
mika kuns 671c886c75 fix(ui): diagnostic error surfacing on attachment drop
Opening the dropped IStorageFile stream ran outside the try, so a
first-drop failure escaped the async-void handler as an unobserved fault
(generic "An error occurred"). Wrap the stream-open loop and surface the
exception type via DropStatus; include the exception type in the generic
add-file failure too, so the intermittent case is analyzable.
2026-07-24 11:18:06 +02:00
mika kuns ffff1ee183 fix(ui): surface resume-planning-session failures
ResumePlanningSessionAsync wrapped everything in an empty catch, so a
failed resume vanished silently. Report it via the footer error channel
with a dedicated planningResumeFailed message.
2026-07-24 11:17:57 +02:00
mika kuns da6a70aec1 feat(ui): conflict resolver shows why Continue is disabled
Continue was already gated on CanContinue (all files resolved); surface a
footer hint counting the still-unresolved conflicts/files so the disabled
state is explained, not just for binary files.
2026-07-24 11:14:06 +02:00
mika kuns 85d0f9dcec feat(ui): session-skills empty-state + neutral subtask terminology
Show an explanatory empty-state under the install row when no skills are
installed. Rename the unified-parent status/labels from "Improvements" to
neutral "Subtasks" (waitingForChildren, agentStatus.children,
childOutcomesLabel) since WaitingForChildren now covers planning too.
2026-07-24 11:14:01 +02:00
mika kuns ad2acddc9a fix(ui): render Plus and agent-settings gear icons
Icon.Plus was stroke-only geometry, invisible when used in a PathIcon
(fills). Author it as a filled cross, fixing every PathIcon use at once
(New-session, add-list, add-task). Replace the header bar's raw gear
TextBlock with the shared Icon.Settings PathIcon for consistency.
2026-07-24 11:13:52 +02:00
mika kuns efd7cc9b0a docs(verification): add fix-plan for fresh session (findings grouped by fixability); defer §10, mark §11 OK per Mika 2026-07-24 11:06:57 +02:00
mika kuns 75a6e0efc4 docs(verification): §3 UnfinishedPlanning modal (Finalize/Discard PASS, Resume BUG); generalize child-row live-refresh finding; edges done 2026-07-24 11:00:56 +02:00
mika kuns 9efc5c90f4 docs(verification): finding — Resume planning session is broken (session_id never captured) + error swallowed by empty catch 2026-07-24 10:58:37 +02:00
mika kuns 6ca8cac79f docs(verification): §1 DiffModal error-state resolved via code analysis (defensive/unreachable, gates prevent it) 2026-07-24 10:34:05 +02:00
mika kuns 9a1fa3dbaf docs(verification): §4 merge-editor Abort PASS (tree clean, task stays WaitingForReview) 2026-07-24 10:32:12 +02:00
mika kuns 9b4d3431b0 docs(verification): §9 attachments drag&drop UI PASS (overlay/drop/picker/remove); finding — intermittent first-drop error 2026-07-24 10:26:42 +02:00
mika kuns a1ba3b6ebb docs(verification): §8 Session Skills complete (Remove PASS); refresh handoff summary + fixture state 2026-07-24 10:15:45 +02:00
mika kuns 9b041ba791 docs(verification): §8 per-task activation + no-leak counterprobe PASS; UI partial (cards/general-tab open) 2026-07-24 10:12:39 +02:00
mika kuns 39fc594b98 docs(verification): finding — agent-settings gear uses Unicode glyph, not Icon.Settings PathIcon (inconsistent) 2026-07-24 10:10:15 +02:00
mika kuns b353ed6cfd docs(verification): §8 skill install PASS (6 skills, commit-pinned); finding — Skills tab has no empty-state 2026-07-24 10:06:31 +02:00
mika kuns 416e47ecef docs(verification): note Mika explicitly wants AskUser interaction in detail island 2026-07-24 10:04:06 +02:00
mika kuns e8b5e97a9b docs(verification): §7 AskUser complete — timeout UI-cleanup visually verified (banner clears) 2026-07-24 10:01:56 +02:00
mika kuns d19ef5403f docs(verification): §7 AskUser PASS (happy-path + backend timeout); finding — banner only in Mission Control, absent in detail island 2026-07-24 09:54:07 +02:00
mika kuns ded068c564 docs(verification): note fixture cleanup (verif tasks/worktrees removed, ClaudeDoTests reset) 2026-07-24 09:37:00 +02:00
mika kuns 9556e0e848 docs(verification): refresh handoff for next session — progress, remaining (§7-§11+edges), gotchas, fixture state 2026-07-24 09:33:53 +02:00
mika kuns 9d39a8fc69 docs(open): trim to actively-verified 2026-07-24 findings; drop stale manual-verif/historical blocks 2026-07-24 09:30:56 +02:00
mika kuns b536b6fb66 docs(verification): §5 ConPTY/Mission Control PASS (prompt-send, close kills proc); ad-hoc icon invisible + re-open re-sends noted 2026-07-24 09:27:22 +02:00
mika kuns 1b80bb0a2d docs(open): §5 findings — invisible New-session icon (Icon.Plus stroke-only), re-open re-sends prompt 2026-07-24 09:24:15 +02:00
mika kuns 4394623bb0 docs(verification): §3 PASS end-to-end (+§1 children-band, +§4 planning-conflict); dequeue-X UX nit 2026-07-24 09:18:10 +02:00
mika kuns f0b0582517 docs(open): §3 finalize findings — improvements-mislabel, child-badge live-refresh, chain not visualized 2026-07-24 09:10:44 +02:00
mika kuns 07de897147 docs(open): planning session permission-prompt bug + planning-active parent shows Idle (UX) 2026-07-24 09:06:03 +02:00
mika kuns 21012283a9 docs(verification): clean additive approve PASS 2026-07-24 08:56:57 +02:00
mika kuns 8241bf8d41 docs(verification): §1 commit-range-after-merge PASS; blocked-merge silent-fail confirmed on clean path too 2026-07-24 08:52:12 +02:00
mika kuns 255705d8bf docs(verification): §4 merge editor PASS end-to-end + UX findings (continue-btn, multi-file, blocked-merge) 2026-07-24 08:48:03 +02:00
mika kuns 3dfd75fba8 docs(open): Approve & Merge silently swallows a blocked merge (no footer error) 2026-07-24 08:43:37 +02:00
mika kuns 26c03a5a5f docs(verification): §1 findings — raw-JSON outcome bug, rename/turns nits, session-tab expected 2026-07-24 08:26:33 +02:00
mika kuns 3211bfc0f9 docs: correct permission finding — auto+haiku denies writes (not a CLI regression), §2 happy-path PASS 2026-07-24 08:08:15 +02:00
mika kuns 79ce7afe46 docs(verification): log autonomous-batch results (§2/§6/§9/§12) 2026-07-24 07:54:20 +02:00
mika kuns 0ad93f48b5 docs(open): track CLI 2.1.207 --permission-mode auto write-denial regression 2026-07-24 07:50:44 +02:00
mika kuns fee69998f8 fix(worker): kill cancelled runs' processes and make MCP approve actually merge
- CancelAsync now signals the running Claude process of the cancelled task and
  its cascaded children via the new RunCancellationRegistry (queue + override
  slots register their CTS there) instead of only flipping DB state.
- external MCP review_task 'approve' now mirrors the hub's ApproveReview:
  unit merge for parents, ApproveAndMergeAsync for childless tasks, optional
  targetBranch; ReviewTaskResult carries mergeStatus/conflicts.
2026-07-23 20:24:36 +02:00
mika kuns 451afc80f8 Merge task branch for: fix(worker): external MCP optional params are generated as required; errors are opaque 2026-07-23 20:08:26 +02:00
mika kuns 4b2667062f Merge task branch for: chore: remove dead GitService.MergeFfOnlyAsync, move AgentMcpTools to its own file, fix stale Zitadel doc note 2026-07-23 20:08:15 +02:00
mika kuns 305eb6ee8b fix(ui): UnifiedDiffParser handles paths with spaces and git-quoted paths
# Conflicts:
#	docs/open.md
2026-07-23 20:07:53 +02:00
mika kuns 15bef2a2e3 fix(git): merge preflights ignore untracked files (HasChangesAsync includeUntracked)
# Conflicts:
#	docs/open.md
2026-07-23 20:07:33 +02:00
mika kuns d7ebafd556 fix(worker): make external MCP filter params optional, surface tool errors
Nullable filter/patch params across the External/ MCP tool classes (ListTasks,
UpdateTask, AddSubtask, ReviewTask, SetMyDay, SetListConfig/SetTaskConfig,
CreateList/UpdateList) lacked C# default values, so the generated tool schema
marked them required — MCP clients omitting them (the common case) failed.
Gave every such parameter a default value.

Also registered a call-tool filter (ExternalMcpExceptionFilter) on the external
MCP host that translates InvalidOperationException/ArgumentException into
McpException, since the SDK's own catch-all discards ex.Message for any other
exception type and returns a generic "An error occurred invoking 'X'." string.

Added a reflection-based schema test sweeping every [McpServerToolType] class
to guard against reintroducing a required-but-nullable parameter.
2026-07-23 18:21:46 +02:00
mika kuns 14e4c086e2 fix(data): merge preflights ignore untracked files in target working tree
HasChangesAsync counted untracked files, so a stray file in the shared
target working dir (e.g. left by a concurrent session) blocked merge
preflights even though nothing tracked changed. Add an includeUntracked
overload defaulting to true, and pass includeUntracked: false only from
the two target-working-tree merge preflights (TaskMergeService.MergeAsync,
PlanningMergeOrchestrator.StartAsync). Auto-commit and the worktree
cleanup data-loss guard keep counting untracked files, since those
callers need to know about them.
2026-07-23 18:14:46 +02:00
mika kuns 0f2d202b01 fix(ui): UnifiedDiffParser mishandles paths with spaces and git-quoted paths
diff --git headers pack two paths on one space-delimited line, which broke
for unquoted paths containing spaces and for git's C-style octal-quoted
paths (non-ASCII filenames). Add quote-aware header splitting plus a git
unquote helper, and prefer the unambiguous "--- a/"/"+++ b/" lines to
correct the file's identity when present.
2026-07-23 18:11:52 +02:00
mika kuns 2a4633fdd8 chore: remove dead MergeFfOnlyAsync, split AgentMcpTools into its own file, fix stale Zitadel doc
- GitService.MergeFfOnlyAsync had no callers anywhere in code; removed.
- AgentMcpTools moved out of LifecycleMcpTools.cs into External/AgentMcpTools.cs (no behavior change).
- Worker CLAUDE.md described ZitadelAuthProvider as a Phase 2 stub; it's fully implemented (OIDC discovery + refresh-token flow), updated both mentions.
2026-07-23 18:07:48 +02:00
mika kuns 3d668da65c docs(open): remove chain-cascade bug bullet (fixed in 110364a) 2026-07-23 18:03:15 +02:00
mika kuns f18e03354a fix(merge): validate conflict resolution before staging in ContinueMergeAsync
# Conflicts:
#	docs/open.md
2026-07-23 18:01:49 +02:00
mika kuns a1986dd485 Merge task branch for: fix(worker): deleting the last non-terminal child leaves the parent stuck in WaitingForChildren 2026-07-23 18:00:48 +02:00
mika kuns 110364aa6c Merge task branch for: fix(worker): planning-chain cascade stalls at an Idle middle link 2026-07-23 18:00:40 +02:00
mika kuns 0b86ccd74b Merge task branch for: fix(worker): cascade cancel of a WaitingForChildren parent to its non-terminal children 2026-07-23 18:00:34 +02:00
mika kuns d6891b8bd4 docs: explore-notes convention + verification handoff for manual checks 2026-07-23 17:58:48 +02:00
mika kuns 377409e633 fix(worker): validate conflict markers before staging in ContinueMergeAsync
git add -A resolves the index U-stage regardless of file content, so a
conflicted file whose markers were never removed still got staged and
committed as-is. Check previously-conflicted files for leftover
<<<<<<< markers before staging; refuse with a conflict result if any
remain, leaving the repo mid-merge so Abort still cleans up.

Adds a regression test that leaves one conflict unresolved and asserts
ContinueMergeAsync refuses (no commit) and AbortMergeAsync still
restores a clean tree.
2026-07-23 17:16:46 +02:00
mika kuns 941c8b98cc fix(worker): cascade cancel of a WaitingForChildren parent to its non-terminal children
Confirmed via docs/open.md (Korrektheits-Review 2026-06-09): TaskStateService.CancelAsync
only flipped the target row to Cancelled - a Queued/Running child kept going and could
still commit into its worktree after the parent was gone.

CancelAsync now also cancels the task's own non-terminal children (Idle/Queued/Running/
WaitingForReview/WaitingForChildren) in the same pass, clearing BlockedByTaskId so no
successor is left wedged, and broadcasts TaskUpdated for each.
2026-07-23 17:14:33 +02:00
mika kuns 2452e39345 fix(worker): advance parent when the last non-terminal child is deleted
DeleteTask never re-evaluated a WaitingForChildren parent, so deleting
the last non-terminal child left it stuck (the BlockedByTaskId SET NULL
FK only repairs the blocked chain, not parent status). Expose
TaskStateService.TryAdvanceParentAsync on the interface and call it
from ExternalMcpService.DeleteTask after a child delete.
2026-07-23 17:13:11 +02:00
mika kuns 816f247d90 fix(worker): keep planning-chain cascade moving past an Idle middle link
OnChildFinishedAsync ignored CancelAsync's result. If a chain successor
sat in a non-cancellable state (e.g. parked to Idle out of band) when
its predecessor failed/was cancelled, CancelAsync was a silent no-op and
the cascade stopped there, leaving the rest of the chain Queued+blocked
forever. Now it walks past any link CancelAsync can't touch and keeps
cancelling downstream.
2026-07-23 17:09:55 +02:00
ClaudeDo CI ad58129194 docs(changelog): update for v2.1.0 2026-07-23 14:47:45 +00:00
80 changed files with 2411 additions and 293 deletions
+42
View File
@@ -1,5 +1,47 @@
# Changelog
## v2.1.0 — 2026-07-23
### Features
- seed fresh task session with the task prompt (d8194ad)
- New session button for ad-hoc ConPTY sessions (3feb08d)
- fresh-worktree-on-demand + ad-hoc launch specs (9ab48d7)
- host task-based ConPTY sessions in Command Center (0513265)
- embedded ConPTY terminal host in UI (5f740c0)
- worker launch-spec for embedded ConPTY sessions (1245e75)
- session skills registry tab + per-level selectors (7c3c061)
- session skills SignalR surface + per-level persistence (b4c5808)
- resolve and seed session skills before each run (4626481)
- session skill registry (install/update/remove, pinned clone) (dea2b7d)
- session skills entity, repository, and migration (54cdaf8)
- pick up a task's session in a terminal (eb88dc1)
- resume a task's claude session in a terminal (140ae2f)
### Fixes
- set monospace font + stretch on ConPTY terminal (d9a4627)
- use library LaunchProcess instead of custom pty bypass (2b06ab0)
- correct ConPTY terminal size + reduce lag (bb62740)
- forward keyboard input to ConPTY terminal (25922a2)
- seed planning brief via file to avoid newline truncation (865e12c)
### Refactoring
- remove streaming interactive stack (superseded by ConPTY) (c412a84)
### Documentation
- update ConPTY spec + open items to final state (85c7e65)
- record ConPTY library + binding decision from spike (d28c63d)
- ConPTY interactive sessions spec + plan (d91ad2d)
- session skills verification items (f33838d)
- mark cwd-skill discovery verified in headless mode (dbaefe9)
- revise for multi-skill plugin repos (ponytail) (62b245a)
- spec + plan for per-level session skills (4e5057d)
- pick up a task's session in a terminal — verification (1bf08ec)
- update for v2.0.0 (914fa5a)
## v2.0.0 — 2026-06-26
### Features
+2 -1
View File
@@ -39,7 +39,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
- Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -84,3 +84,4 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
+38
View File
@@ -0,0 +1,38 @@
# Explore-notes
Distilled, reusable maps of complex subsystems, produced by deep code exploration.
The goal: stop re-exploring the same subsystem from scratch in every new session.
These sit **between** the CLAUDE.md files and the code:
- **CLAUDE.md** — high-level orientation, hand-maintained, always-loaded.
- **explore-notes** — deeper subsystem detail (flows, who-calls-whom, invariants) that is
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
- **code** — the only source of truth.
## Rules
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
file paths. **No line numbers**, no exhaustive symbol dumps — those rot fastest.
- **Verify before trusting.** A note is a starting map, not authority. Always confirm
against current code before acting on it. Each note records the commit it was verified
against so you can diff for drift.
- **Not a substitute for CLAUDE.md.** If a fact belongs in orientation, put it there.
## Header every note must carry
```
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `<short-hash>` (<date>).
> Drift check: `git log --oneline <short-hash>..HEAD -- <paths this note covers>`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
```
## Workflow
1. **Before** deep-exploring a subsystem, check for a matching note here and read it first;
explore only to fill gaps or confirm.
2. **After** a deep explore, distill the durable findings into a new/updated note and bump
its "verified against" commit line.
3. If the drift check shows the covered paths changed a lot since the verified commit, treat
the note as suspect and re-verify the parts you rely on.
+124
View File
@@ -0,0 +1,124 @@
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `896d4b5` (2026-07-23).
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
# Worker: Task Execution Pipeline
How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
(Queue, Runner, Lifecycle, State, Agents, Worktrees, Hub).
## End-to-End Flow (Queued → Terminal)
1. **Enqueue**`ITaskStateService.EnqueueAsync()` (State/TaskStateService.cs)
- Idle → Queued, then wakes the dispatcher via `IQueueWaker.Wake()`.
2. **Dispatch**`QueueService` loop (Queue/QueueService.cs)
- `BackgroundService`; waits for a wake signal or a backstop timer.
- Reads the max-parallel limit from settings; claims a free slot if under limit.
3. **Atomic Claim**`IQueuePicker.ClaimNextAsync()` (Queue/QueuePicker.cs)
- Raw SQL `UPDATE ... RETURNING` in one transaction: picks an eligible Queued task
(unblocked, due or unscheduled; sorted by sort_order/created_at), sets status→Running
+ started_at, returns the row. Prevents two workers claiming the same task (TOCTOU).
4. **Slot Execution**`QueueService.RunInSlotAsync()` (Queue/QueueService.cs)
- For review feedback: resume the prior session if one exists, else fold feedback into
the prompt. Calls `TaskRunner.RunAsync()` / `ContinueAsync()` with `alreadyClaimed=true`.
5. **Run Preparation**`TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
- Loads task, list config, subtasks, attachments from the DB.
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
6. **Claude Execution**`TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
- Creates a TaskRunEntity, points the task at the run's log path.
- Builds claude CLI args (ClaudeArgsBuilder), spawns the process via `IClaudeProcess.RunAsync()`
with prompt + working dir + streaming callback.
- Stream lines → NDJSON log + broadcast via TaskMessage. MCP tools (AskUser, SuggestImprovement)
are scoped by the per-run token.
7. **Result Handling**`TaskRunner.HandleSuccess()` / `MarkFailed()` (Runner/TaskRunner.cs)
- Success (exit 0 + result markdown): if worktree, commit + broadcast WorktreeUpdated; then
transition to Done / WaitingForReview / WaitingForChildren (CompleteAsync / SubmitForReviewAsync
/ SubmitForChildrenAsync).
- Failure: if a session exists, auto-retry once via ContinueAsync; else MarkFailed → FailAsync.
- All terminal writes use `CancellationToken.None` so a task is never left Running.
8. **Terminal States**`ITaskStateService` transitions (State/TaskStateService.cs)
- **Done** CompleteAsync (Running → Done) — top-level success.
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## Component Responsibilities
**Queue/**
- `QueueService` — main dispatch loop; slot limit; decides when to start tasks.
- `QueuePicker` — atomic Queued→Running claim via raw SQL.
- `QueueWaker` — semaphore for non-blocking, idempotent wake signals.
- `OverrideSlotService` — owns the RunNow / ContinueTask slot (bypasses the queue).
**Runner/**
- `TaskRunner` — orchestrates the run (prepare, execute, handle result).
- `WorktreeManager` — creates/manages git worktrees; self-heals stale branches.
- `ClaudeProcess` — spawns the claude CLI subprocess; manages streams/logs.
- `TaskRunMcpService` — runtime MCP tools (AskUser, SuggestImprovement).
- `TaskRunTokenRegistry` — per-run MCP identity for tool-access control.
- `InteractiveLaunchSpecService` — config for the task's claude run.
**State/**
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
**Lifecycle/** (startup recovery)
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
- `TaskResetService` — manual reset to Idle.
- `TaskMergeService` — conflict resolution for worktree merges.
**Hub/**
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
- `WorkerHub` — SignalR hub + client methods.
**Agents/**
- `AgentFileService` — file I/O for custom agents.
- `DefaultAgentSeeder` — seeds built-in agents on startup.
**Worktrees/**
- `WorktreeMaintenanceService` — cleanup, state tracking, overview reporting.
## Entry Points & Call Chain
```
Program.cs (DI setup)
├─ QueueService (BackgroundService) → ExecuteAsync loop
│ ├─ waits: IQueueWaker.WaitAsync() or timer
│ ├─ claims: IQueuePicker.ClaimNextAsync()
│ └─ runs: TaskRunner.RunAsync() / ContinueAsync()
├─ Hub clients → WorkerHub methods
│ ├─ Enqueue → ITaskStateService.EnqueueAsync() → Wake()
│ ├─ RunNow → OverrideSlotService.RunNow() → TaskRunner.RunAsync()
│ ├─ ContinueTask→ OverrideSlotService.ContinueTask()→ TaskRunner.ContinueAsync()
│ └─ CancelTask → QueueService.CancelTask()
├─ Lifecycle recovery (startup): StaleTaskRecovery / OrphanRecovery / AttachmentOrphanRecovery
└─ State transitions → HubBroadcaster.TaskUpdated()
```
## Invariants & Conventions
- **Atomic claiming** — QueuePicker's `UPDATE ... RETURNING` makes Queued→Running atomic.
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
- **Worktree self-heal** — on branch collision, remove phantom worktrees, prune, delete branch, retry add.
- **Review feedback** — stored on the task; consumed once a run reaches a terminal state; a re-queued
task resumes the session or folds feedback into the prompt.
- **Child tasks** — planning creates draft children; finalization requires no Queued children remain;
OrphanRecovery dequeues children if the parent is not planning.
- **Lifecycle recovery** runs at startup: stale-Running → Failed; orphaned children → dequeued but attached.
+189
View File
@@ -0,0 +1,189 @@
# Fix-Plan — Verifikations-Findings (Stand 2026-07-24)
Einstiegspunkt für eine **frische Fix-Session**. Sammelt die in der manuellen Verifikation
(§7–§9 + Kanten §1/§3/§4) gefundenen Probleme, gruppiert nach **Fixbarkeit**. Volltext je
Finding (mit Kontext/Wiederholschritten) steht in `docs/open.md`; hier steht der Fix-Blick:
Root-Cause, konkreter Ansatz, Loc/Test-Hinweise, und **welche Punkte vor der Umsetzung eine
Entscheidung brauchen**.
**Immer zuerst:** file:line-Angaben gegen den aktuellen Code prüfen (können minimal driften).
Build/Test-Regeln + Gotchas s. Projekt-`CLAUDE.md` (u.a. `.slnx` braucht .NET 9 → einzelne
`.csproj -c Release`; Localization.Tests erzwingt en/de-Parität; Subagents `sonnet`, Dateien
pfad-scoped stagen). Pro Fix ein Conventional Commit.
---
## Bearbeitungsstand (Session 2026-07-24, nicht gepusht)
**Erledigt & committed:**
- Gruppe A #15 + beide Optional-Nits (Icon.Plus gefüllt, Gear-PathIcon, Skills-Empty-State,
„Subtasks"-Terminologie, Conflict-Continue-Hinweis, Rename-Darstellung, Turns/Tokens-Reload).
- Gruppe B #7 (Resume-Fehler surfacen) + #8 (Attachment-Drop-Diagnose).
- Gruppe C #9 (AskUser-Banner in Detail-Insel via geteiltem `TaskMonitorViewModel`),
#11 (OUTCOME zeigt `summary` statt rohem JSON: Worker-Unwrap + UI-Sicherheitsnetz).
- Gruppe D #13 (Kind-Rows live-refresh bei Parent-Planning-Transitionen) + #14 (Idle-Chip auf
Planning-Parents ausgeblendet). *Visual-Verification für #13 (Finalize/Discard live) offen.*
**#6** war im aktuellen Code bereits abgedeckt (Worker wirft `HubException` bei `blocked`
→ UI-Dialog); zusätzlich als ClaudeDo-Task `f9809a93` erfasst. Nicht angefasst.
**Offen:**
- **#10 + #12** — bewusst gebündelt mit dem ConPTY-Planning-Task `5d627df8` (dort lässt sich
die Session-Id sauber greifen bzw. das MCP-Permission-Verhalten klären). Sofort-Schutz für
#10 (Resume ausgrauen wenn keine Id) wurde NICHT gebaut — bräuchte Worker-Plumbing, das der
ConPTY-Umbau ohnehin liefert; der #7-Fix verhindert bereits das stille Scheitern.
- **Gruppe E** — nur noch design-/feature-behaftete Punkte. Der scheinbare Quick-Win
„Dequeue-X auf blockierten Kettengliedern" wurde bewusst NICHT umgesetzt: einzelnes Dequeue
eines Kettenglieds hinterlässt hängende Nachfolger (deren `BlockedByTaskId` zeigt weiter auf
das nun idle Glied) → braucht Chain-Repair-Design.
---
## Gruppe A — Mechanisch, sofort fixbar (keine Entscheidung nötig)
Ideale Kandidaten für den Start / parallele Subagents (disjunkte Dateien).
1. **„New session"-Button unsichtbar (Icon.Plus strich-only)**
`IslandStyles.axaml` (`Icon.Plus` = `M12 5v14M5 12h14`, reine Linie) wird in einem
`<PathIcon>` (MissionControlView.axaml, füllt Geometrie) unsichtbar gerendert.
**Fix:** `Icon.Plus` als gefüllte Geometrie authoren ODER als gestricheltes `Path`
rendern (vgl. `Path.plan-icon`). **Andere `Icon.Plus`-Verwendungen mitprüfen.**
2. **Agent-Settings-Gear weicht vom Listen-Gear ab**
`TaskHeaderBar.axaml:68` rendert `<TextBlock Text="⚙">`; überall sonst `Icon.Settings`
(PathIcon, IslandStyles.axaml:110). **Fix:** den `⚙`-TextBlock durch
`<PathIcon Data="{StaticResource Icon.Settings}" Width=".." Height=".."/>` ersetzen.
3. **Session-Skills-Tab ohne Empty-State**
Bei 0 Skills nur nackte Fläche. **Fix:** Empty-State-Text unter der Install-Zeile
(z.B. „No skills installed — paste a GitHub URL above"). **Loc:** neue Keys in en.json
**und** de.json (Parität!). Datei: `SessionSkillsSettingsTab*`.
4. **„Waiting for Improvements" für Planning-Parents (Terminologie)**
`en.json` `taskStatus.waitingForChildren`/`agentStatus.children`/`childOutcomesLabel`
sagen „Improvements". Seit unified-parent gilt `WaitingForChildren` auch für Planning.
**Fix:** auf neutrales „Waiting for Subtasks"/„Subtasks"/„SUBTASKS" umstellen — en **und**
de (Parität).
5. **Conflict-Resolver: Continue-Button klickbar trotz offener Konflikte**
Merge passiert korrekt erst nach Auflösung, aber der Button ist nicht disabled → früher
Klick = stummer No-op. **Fix:** `CanContinue`/`AllResolved` an `IsEnabled` binden (ggf.
Hinweis „N Konflikte in M Dateien offen"). Datei: `ConflictResolverView(.axaml)` +
`ConflictResolverViewModel`.
Optional-Nits (gleiche Gruppe, niedrige Prio):
- **Diff-Viewer Rename schwach dargestellt** (alt→neu-Pfad + „renamed"-Label statt „+0 0").
- **Header-TurnsText `0/max`** bei terminalem Reload — `Turns` aus `task_runs.turnCount`
restaurieren.
---
## Gruppe B — Error-Surfacing (klare Richtung: kein stiller/leerer Fehlerpfad)
Leitlinie `feedback_ui_error_surfacing`: User-Action-Fehler in den Footer
(`FlashFooterError`) bzw. Dialog, nie leerer `catch`/stiller No-op.
6. **Approve & Merge schluckt „blocked" still**
`DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `Status == "conflict"`; bei
`"blocked"` (z.B. dirty Ziel-Tree) passiert nichts. **Fix:** bei `blocked`/unerwartetem
Status `result.ErrorMessage` surfacen. *(Als ClaudeDo-Task `f9809a93` erfasst — koppelt
„Approve erzwingt Diff/Review vor Merge".)*
7. **„Resume planning session" verschluckt den Fehler (Teil-Fix hier, Rest → Gruppe C #10)**
`TasksIslandViewModel.ResumePlanningSessionAsync` (~Zeile 870) hüllt alles in `catch { }`.
**Sofort-Fix:** den Fehler surfacen statt schlucken. Der eigentliche Resume-Defekt braucht
eine Entscheidung → #10.
8. **Attachments: intermittenter erster-Drop-Fehler („An error occurred")**
Einmalig beobachtet (erster Drop der Session, nichts persistiert), nicht reproduzierbar.
**Fix (diagnostisch):** `AddFilesAsync`/`OnDrop` robustes Error-Logging geben (die genaue
Exception fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt) — damit der
nächste Fall auswertbar ist. Kandidaten-Ursachen: SQLite-Contention (UI schreibt `todo.db`
direkt, während der Worker sie hält) oder Drop-Stream-Pfad (`IStorageFile.OpenReadAsync`
im Code-Behind, außerhalb des `try`).
---
## Gruppe C — Erst Entscheidung/Brainstorm, DANN umsetzen (nicht blind fixen)
9. **AskUser-Interaktion auch in der Detail-Insel** *(von Mika ausdrücklich gewünscht)*
Der `ask_user`-Banner + Inline-Antwort existiert nur in Mission Control
(`MonitorPaneView`); die Detail-Insel zeigt für den laufenden Task nichts.
**Entscheidung:** wie den Zustand teilen — `TaskMonitorViewModel` hält ihn bereits; für
die Detail-Insel replizieren, teilen, oder ein gemeinsames Banner-Control? Danach:
Banner + `AnswerDraft`/`SubmitAnswer` in `DetailsIslandView(Model)` einhängen.
10. **„Resume planning session" grundsätzlich kaputt (Session-Id nie erfasst)**
`PlanningSessionManager.ResumeAsync:238` wirft immer „No Claude session ID captured yet",
weil `TaskRepository.UpdatePlanningSessionIdAsync:322` **keinen Aufrufer** hat →
`planning_session_id` bleibt NULL. **Entscheidung:** (a) claude-Session-Id der
wt-Planning-Session erfassen + via `UpdatePlanningSessionIdAsync` persistieren (echtes
Resume) ODER (b) Resume entfernen/deaktivieren, wenn keine Id vorliegt. Hängt mit der
Design-Entscheidung „Planning über embedded ConPTY statt wt" zusammen (ClaudeDo-Task
`5d627df8`) — dort ließe sich die Session-Id sauber greifen.
11. **OUTCOME-Karte rendert rohes Structured-Output-JSON**
`TaskMonitorViewModel.ApplyOutcome` setzt bei Tasks ohne Roadblock `SessionOutcome`
= `task.Result` wörtlich; der Worker legt dort rohes `{"summary":…}` ab.
**Entscheidung:** (a) UI parst JSON-Result und zeigt `summary`, oder (b) Worker schreibt
`summary`/`resultMarkdown` statt JSON in `task.Result`.
12. **Planning-Session prompted nach MCP-Tool-Permission**
Trotz `--allowedTools "mcp__claudedo__*,…"` + `--permission-mode plan` prompted die
wt-Planning-Session beim ersten `create_child_task`. **Untersuchen/Entscheiden:** matcht
der `mcp__claudedo__*`-Glob in CLI 2.1.207 nicht (Syntax evtl. ganzer Server-Name), oder
gated Plan-Mode MCP-Writes generell? Gekoppelt an ConPTY-Planning-Task `5d627df8`.
---
## Gruppe D — Erst Root-Cause pinnen (Investigation)
13. **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live**
Nach **Finalize** bleiben Kind-Badges „Draft" statt „Planned"; nach **Discard** bleiben
die (in der DB gelöschten) Kind-Rows sichtbar — bis Listen-Reload. Doppelt verifiziert §3.
**Untersuchen:** wie wird die Kinderliste/-gruppierung auf ein Parent-`TaskUpdated`
reagierend neu aufgelöst? Vermutlich fehlt ein Regroup/Refetch der Children beim
Parent-Broadcast (`TasksIslandViewModel` hierarchie-Regrouping). Fix danach: Children bei
Parent-Transition live neu auflösen.
14. **Planning-aktiver Parent zeigt weiter „Idle"**
Parent `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Chip
zeigt „Idle"; `PlanningBadge` überschreibt das nicht sichtbar. **Untersuchen/Design:** ein
klarer „Planning/Draft aktiv"-Zustand, der den Idle-Chip überschreibt. (Verwandt mit #13
Row-Statusdarstellung.)
---
## Gruppe E — UX-Nits / Feature-Wünsche (niedrige Prio, sammeln)
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar** — prominentere Datei-Liste
/ „x von y Dateien".
- **Blocked-by-Kette nicht visualisiert** — Reihenfolge/Abhängigkeit darstellen
(„wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf blockierten Kettengliedern** — `CanRemoveFromQueue` erweitern
(`IsWaiting` einschließen).
- **„Open ConPTY session" erneut = Prompt wird neu gesendet** — Resume-Affordance / Re-Open-
Warnung (bewusst kein Session-Persist).
- **Conflict-Resolver: farbliches Hervorheben übernommener Zeilen im Result-Pane** (Feature).
---
## Nicht anfassen / Kontext
- **§1 DiffModal-Fehler-State** (`vm.diff.unavailable`) ist **defensiver, über die UI
unerreichbarer** Code — alle Aufrufer sind gegated (`CanDiffMergedRange` verlangt base+head
non-null; `ConfigureWorktree` nur mit existierendem Pfad). Kein Fix nötig.
- **`--permission-mode auto` + `haiku` denied Writes** — modellabhängiges Verhalten, keine
Regression; Default (sonnet) unbetroffen. Beobachten (Memory `auto_permission_haiku_footgun`).
- **§10 Daily Prep/Weekly** — Verifikation zurückgestellt bis zum geplanten Rework.
---
## Empfohlene Reihenfolge
1. **Gruppe A** (mechanisch, schnell, teils parallel) → sofort sichtbare Wins.
2. **Gruppe B** (Error-Surfacing, klein & risikoarm).
3. **Gruppe C** — pro Punkt kurz brainstormen/entscheiden, dann umsetzen (#10 + #12 zusammen
mit der ConPTY-Planning-Entscheidung betrachten).
4. **Gruppe D** — Investigation, dann Fix (#13 zuerst — betrifft mehrere Planning-Flows).
5. **Gruppe E** — nach Bedarf.
+30 -53
View File
@@ -1,72 +1,49 @@
# ClaudeDo — Offene Punkte
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code nicht hier.
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
---
## Manuelle Verifikation (offen)
## Bugs (verifiziert 2026-07-24)
Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der Großteil der Pipeline ist laut User bereits in der Praxis getestet; hier das, was noch ein falsifizierbares Observable braucht.
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
- **Worktree-Pipeline:**
- Worktree-Happy-Path → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk.
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept ``/`` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
- **In-App Interactive Sessions (2026-06-26, REMOVED 2026-07-23):** der In-App-Streaming-Chat (`StreamingClaudeSession`, Composer/Queue auf `TaskMonitorViewModel`/`SessionTerminalView`) wurde komplett entfernt und durch die **embedded ConPTY**-Sessions ersetzt (echte `claude`-TUI im UI-Prozess, siehe `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`). Kein offener Punkt mehr — nur zur Historie.
- **Embedded ConPTY Sessions (neu, 2026-07-23):** Command Center hostet echte `claude`-TUI-Kacheln (`Iciclecreek.Avalonia.Terminal` 2.0.3 via `TerminalControl.LaunchProcess()`). Rendering/Input/Tempo vom User verifiziert. **Noch durchzuspielen:**
- Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet mit Task-Prompt (Title+Description als positionaler Prompt) — **verifizieren, dass `claude "<prompt>"` interaktiv wirklich SENDET**, nicht nur vorbefüllt.
- Ad-hoc („New session"-Button → Ordnerwahl): freie Session im gewählten Verzeichnis.
- Grid↔Tabs-Toggle, Close killt Session + entfernt Kachel, mehrere Sessions parallel, Pane-Resize reflowt.
- Resume einer interaktiven Session: nur via claude-eigenes `claude --continue`/`--resume` im Worktree-Dir (ClaudeDo speichert die Session-Id NICHT — ConPTY ist opak).
- **Zurückgestellt:** Avalonia-12.1-Upgrade (braucht .NET-9-SDK-Floor wg. Roslyn-4.14-XAML-Generator; CI-Risiko) — bleibt auf 12.0.x.
- **Pick up in terminal (neu, 2026-07-01):** neue Aktion, die die Claude-Session einer Task per `claude --resume <id>` in einem **echten** `wt`-Terminal fortsetzt (echte TUI: Permission-Prompts/Fragen inklusive) — bewusst NICHT der In-App-Streaming-Chat. Real-CLI-Smoke (kein Claude in Tests):
- Kontextmenü einer Task in **WaitingForReview** oder **Failed** → „Pick up in terminal" sowie der Terminal-Button (Icon `ArrowOut`) im Detail-Header sind sichtbar; bei anderen Status (Idle/Running/Queued/Done) NICHT.
- Klick → neues Windows-Terminal im Worktree-Verzeichnis der Task, Claude nimmt die letzte Session mit erhaltenem Kontext wieder auf.
- Fehlerfälle surfacen sauber (Footer-Strip aus der Task-Insel bzw. Fehler-Dialog im Detail): laufende/gequeuete Task (verboten), keine persistierte Session-Id, kein aktiver Worktree.
- **Gating-Kante:** parked-Idle (reject-park) hat oft noch Session+Worktree, wird aber bewusst NICHT angezeigt (Idle nicht von fresh-Idle unterscheidbar). Falls das nervt → `CanPickUpInTerminal` erweitern.
- **Session Skills (neu, 2026-07-03):** per-Ebene (global/list/task, additiv-union) Skills für headless Task-Agenten; Registry klont+pinnt ein GitHub-Repo, Worker seedet aktivierte Skills in `<cwd>/.claude/skills/` vor jedem Run (worktree-`info/exclude`, damit `git add -A` sie nicht committet). Discovery-Mechanismus ist bereits verifiziert (headless `claude -p` lädt cwd-Skills). Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`. Offen (kein Code, nur Durchspielen):
- **E2E-Smoke (echter Worker, kein Claude in Tests):** In Settings → Skills `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), gepinnt auf einen Commit, Dateien unter `~/.todo-app/session-skills/<name>/`. Skill per-Task (Agent-Settings-Flyout) und/oder global aktivieren → eine Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, der Skill ist dem Agenten verfügbar, und er wird **nicht** mitcommittet (`git status` im Worktree sauber). Gegenprobe: eine **nicht** aktivierte/andere interaktive Session sieht den Skill nicht (kein global-Leak in `~/.claude`).
- **UI-Sichtprüfung:** neuer Skills-Tab (Install-Zeile, installierte-Skill-Karten mit Update/Remove), Session-Skills-Checkbox-Liste im General-Tab und im `AgentConfigEditor` (List-Settings-Modal + per-Task-Flyout — Flyout-Höhe prüfen). Leerer Zustand (0 installierte Skills) rendert eine leere Liste ohne Platzhaltertext — ok oder Empty-State ergänzen. Lange Namen/URLs (Trimming).
- **Drag-and-drop file attachments on the detail pane:** verify the "Drop to attach" hover overlay, drop round-trip (file appears in the list), "Add file…" picker, remove button, and that files land under `~/.todo-app/attachments/<taskId>/`. Also verify the MCP `AddTaskAttachment`/`ListTaskAttachments`/`RemoveTaskAttachment` tools and that a Running task refuses add/remove. (Manual; can't be unit-tested.)
## UX / Nits (verifiziert 2026-07-24)
## Offene Code-Punkte
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein.
- **`AgentMcpTools` liegt in `LifecycleMcpTools.cs`** — beim Suchen irreführend; in eigene Datei verschieben. Ein-Minuten-Fix, lohnt keinen Agent-Lauf — beim nächsten Worker-Touch mitnehmen.
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser``DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}``Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
## Feature-Wünsche (aus der Session)
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
- **`DetailsIslandViewModel` ist nach dem Split noch 1258 Zeilen** (Ziel war ~800) — die drei Sektions-VMs (AgentSettings, Merge, Prep) sind extrahiert, weitere Extraktion (z.B. ChildOutcomes/Subtasks-Sektion) lohnt erst, wenn die Datei wieder wächst.
- **Bewusst zurückgestellt:** WorkerHub-Split nach Concern (~60 Methoden in einer Hub-Klasse). Die Interface-Parität löst das akute Testbarkeits-Problem; ein Hub-Split ist eine größere Architekturentscheidung → erst besprechen.
- **Lessons learned:** Der `StartRunningAsync`-Guard-Task hat isoliert grün getestet, aber den Queue-Pfad gebrochen (Picker claimt vor dem Dispatch) — Integrationsfix `74ca2e0`. Bei parallelen Tasks, die denselben Pfad berühren, nach JEDEM Merge-Schwung die volle Suite auf main fahren.
## Beobachtung (offen — Entscheidung Mika)
## Bug-Befunde (Korrektheits-Review 2026-06-09)
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
- Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits).
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`.
- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette).
- `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber).
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
**Geprüft und verworfen (keine Bugs):** ReviewFeedback-„Endlosschleife" (Fallback existiert), Cross-Thread-Crashes im DetailsIslandViewModel (Dispatcher-Marshalling im WorkerClient), Chain-Wedge nach Child-Delete (FK `ON DELETE SET NULL`), `\ No newline`-Parsing.
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
---
## Bewusst verworfen (nicht erneut vorschlagen)
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check (siehe oben). Tests nutzen `FakeClaudeProcess`.
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku; ADRs lohnen solo nicht.
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (Begründung in `mailbox-proposal.md`).
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt (Tag-System entfernt, Notes/Autostart anders gelöst).
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check. Tests nutzen `FakeClaudeProcess`.
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (`mailbox-proposal.md`).
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
@@ -0,0 +1,61 @@
# ConPTY Planning Sessions — Design (2026-07-24)
**Task:** `5d627df8` — interaktive Planning-Session über embedded ConPTY statt externem `wt`-Fenster.
## Problem
Planning-Sessions (`StartPlanningSession`/`ResumePlanningSession`) öffnen heute ein externes
Windows-Terminal (`WindowsTerminalLauncher``wt.exe`). Seit ConPTY existiert (embedded
claude-TUI im UI-Prozess, Command Center), soll die interaktive Planning-Session denselben
Weg nutzen: konsistente UX, keine externen Fenster.
## Approved decisions (Brainstorm 2026-07-24)
- **Env-Isolation:** Prozess-global akzeptiert (Sessions werden sequenziell per Klick geöffnet;
jedes claude-Child snapshottet Env beim Spawn). Keine Änderung an `PtyTerminalSession`.
- **wt-Launcher:** Code bleibt; nur das Routing für Planning wird auf ConPTY umgestellt
(kein Rip-out von `LaunchPlanningStart/ResumeAsync`).
- Planning-Kachel im Command Center (Mission Control), wie andere ConPTY-Sessions.
Finalize/Discard/Queue-Plan bleiben unverändert auf den bestehenden Buttons.
## Change map
### Worker
1. `WindowsTerminalLauncher`: Planning-Start-Args in einen bare Builder
`BuildPlanningStartArgs(PlanningSessionStartContext) -> IReadOnlyList<string>` herausziehen
(analog `BuildResumeArgs`); `BuildPlanningStartCommand` nutzt ihn weiter (wt unverändert).
Resume-Args `BuildPlanningResumeArgs(sessionId) = ["--permission-mode","plan","--resume",id]`
(bisher inline in `LaunchPlanningResumeAsync`).
2. `InteractiveLaunchSpecService`: `BuildPlanningStart(PlanningSessionStartContext) -> LaunchSpec`
und `BuildPlanningResume(PlanningSessionResumeContext) -> LaunchSpec` — resolve claude,
Args aus (1), Env `MAX_THINKING_TOKENS=20000` + `CLAUDEDO_PLANNING_TOKEN=<token>`
(+ `MCP_TOOL_TIMEOUT` wie interaktiv), Cwd = ctx.WorkingDir. Nimmt den Kontext (keine
Manager-Kopplung).
3. Hub: `GetPlanningStartLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.StartAsync`,
broadcastet `TaskUpdated`, baut Spec; bei Fehler discard+rethrow wie heute),
`GetPlanningResumeLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.ResumeAsync`).
### UI
4. `IWorkerClient`/`WorkerClient`: `GetPlanningStartLaunchSpecAsync`/`GetPlanningResumeLaunchSpecAsync`
(mirror `GetInteractiveLaunchSpecAsync`).
5. `MissionControlViewModel`: `OpenPlanningConPtySessionAsync(taskId, resume)` — dedupt nach
TaskId, holt die Planning-Spec, baut `TerminalLaunchDescriptor``ConPtyPaneViewModel`
(Titel „<task> (Planning)").
6. `TasksIslandViewModel`: `OpenPlanningSessionAsync`/`ResumePlanningSessionAsync` (Resume-Zweig
der `UnfinishedPlanningModal`) öffnen die Planning-ConPTY-Kachel via neuem Event
`OpenPlanningConPtyRequested(taskId, resume)`, statt `StartPlanningSessionAsync`/wt.
`IslandsShellViewModel` verdrahtet das Event → `OpenMissionControl()` +
`MissionControl.OpenPlanningConPtySessionAsync`.
### #12 (Nebenbefund)
MCP-Permission-Prompt trotz `--allowedTools "mcp__claudedo__*"`: im interaktiven ConPTY vom
User bestätigbar (kein Blocker wie headless). Beim Testen prüfen, ob der Glob den Prompt in
der aktuellen CLI unterdrückt; falls nicht, allowedTools/`--permission-mode`-Kombi nachziehen.
## Out of scope
- Entfernen des wt-Codes / `ResumeTaskInTerminal`.
- Per-Child-Env-Isolation in `PtyTerminalSession`.
- Änderungen an Finalize/Discard/Queue-Plan-Lifecycle.
## Verification
- Build Worker + App (`-c Release`), Worker.Tests + Ui.Tests grün.
- Visual (User): Planning-Start öffnet Command-Center-Kachel mit claude-TUI im Plan-Modus;
create_child_task erzeugt Draft-Kinder live; Resume greift die Session; kein wt-Fenster.
+113
View File
@@ -0,0 +1,113 @@
# Verifikations-Handoff (Stand 2026-07-24)
Manuelle Verifikation am laufenden System. **Mika bedient die UI, die Session protokolliert Pass/Fail** und bereitet Fixtures vor (Tasks via ClaudeDo-MCP, Git-Setups im Testrepo). Aktive Findings landen in `docs/open.md`; hier steht der Fortschritt je Abschnitt.
## Handoff für die nächste Session
**Erledigt (2026-07-24):** §1 Detail-Insel/Diff-Viewer (bis auf DiffModal-Fehler-State), §2 Worktree-Pipeline (alle 3), §3 Planning-Walkthrough (inkl. UnfinishedPlanning-Modal: dismiss/Finalize/Discard PASS, **Resume BUG**), §4 Merge-Editor (Single-Task **und** Planning-Unit-Konflikt **und** Abort), §6 Pick-up-Gating (Code), §7 AskUser (Happy-Path + Timeout + UI-Cleanup; Finding: Banner nur in Mission Control), §8 Session Skills (Install/Aktivierung/Seeding/Gegenprobe/Remove; Findings: fehlender Empty-State + abweichendes Agent-Gear-Icon), §9 Attachments (Drag&Drop-UI + MCP + ComposedPreview; Finding: intermittenter erster-Drop-Fehler), §12 RunNow (moot).
**Noch offen:**
- **§10 Daily Prep/Weekly** — **Bewusst zurückgestellt (2026-07-24)**: Mika will Daily Prep + Weekly Report ohnehin überarbeiten — Verifikation lohnt erst nach dem Rework.
- **§11 Self-Update/Autostart** — **Nicht formell gefahren, aber laut Mika „läuft bisher sehr gut" (2026-07-24)** → als OK behandelt; bei Bedarf später gezielt nachtesten.
- ~~**Kanten:** §1 DiffModal-Fehler-State, §3 UnfinishedPlanning-Modal, §4 Abort~~ — **alle erledigt (2026-07-24)**: §4 Abort PASS, §3 Modal (Finalize/Discard PASS, Resume BUG), §1 via Code-Analyse geklärt (defensiv/unerreichbar).
**Vorbedingungen:** Worker + App laufen (SignalR 37821, External MCP 47822 — lt. `~/.todo-app/worker.config.json`). Testliste **`ClaudeDoTests`** (`C:\TestRepos\ClaudeDoTests`, listId `e7992fee6035448394b646d069690e05`) für zerstörungsfreie Runs.
**Wichtige Gotchas / Lessons (diese Session):**
- **NIE `model:"haiku"` für schreibende Task-/Verifikations-Runs** — unter dem Default `--permission-mode auto` werden haiku-Writes denied (still no-op). Immer **sonnet**. (Memory `auto_permission_haiku_footgun`.)
- **Merge-Preflight blockt bei dirty Ziel-Working-Tree** (getrackte uncommittete Änderung im `main`-Checkout) — und **Approve schluckt das still** (Bug, open.md). Vor Merge-Checks `git -C C:\TestRepos\ClaudeDoTests status --short` prüfen; fremde WIP nur mit Rücksprache verwerfen (Memory `shared_worktree_partial_commits`: nur pfad-scoped committen).
- **Konflikt-Fixture-Rezept:** Task (sonnet) überschreibt eine getrackte Datei im Branch → danach dieselbe Datei auf `main` divergent ändern + `git commit -- <pfad>` → Approve konfliktet. Für Planning-Unit-Konflikt: main-Edit erst scharfschalten, wenn der Parent in WaitingForReview steht.
- **Datenwahrheit** notfalls direkt aus der DB: `sqlite3 -readonly ~/.todo-app/todo.db "SELECT substr(id,1,8),status,planning_phase,substr(blocked_by_task_id,1,8),title FROM tasks WHERE …"`.
**Testrepo-/Fixture-Zustand:** Aufgeräumt (2026-07-24, §7+§8-Session-Ende) — alle `verif §7/§8`-Tasks gelöscht, deren Worktrees + Branches entfernt, `ClaudeDoTests`-`main` unangetastet bei `ecad650` (Tree clean; die §7-Runs liefen isoliert in Worktrees, main nie berührt). ponytail-Skills nach dem Remove-Test wieder deinstalliert (`session_skills` leer, `~/.todo-app/session-skills/` leer). Keine `verif`-Tasks und keine claudedotests-Worktrees übrig. Die übrigen `claudedo/*`-Branches im Testrepo stammen aus früheren Sessions — nicht anfassen. **Die nächste Session baut Fixtures frisch** (Rezepte s. Gotchas oben).
**Erfasste Folge-Tasks** (Liste „Claude do", Idle, brauchen Brainstorm vor Umsetzung): `f9809a93` Approve erzwingt Diff/Review vor Merge (+ blocked-Merge-Silent-Fail-Fix), `5d627df8` Planning-Session über embedded ConPTY statt wt (+ Planning-Permission-Prompt).
**Fix-Session:** Alle fixbaren Findings sind für eine frische Session in **`docs/fix-plan-2026-07-24.md`** aufbereitet (gruppiert nach Fixbarkeit: A mechanisch, B error-surfacing, C entscheidungsbedürftig, D investigation, E nits). Volltext je Finding bleibt in `docs/open.md`.
**Nacharbeit:** Erledigtes aus `docs/open.md` austragen; dieses File löschen, sobald §10 (nach Rework) + evtl. §11-Nachtest erledigt sind.
---
## 1. Detail-Insel & Diff-Viewer (reines Durchklicken)
- [~] Detail-Insel komplett: Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides (InheritedBadge korrekt), Prep-Panel — nach dem VM-Split (`DetailsIslandViewModel` → Sektions-VMs) alles gebunden, keine leeren Panels. — **TEILWEISE (2026-07-24, User-Sichtprüfung an `verif §1 diff matrix`):** Output/Git-Tabs, Merge-Sektion, Agent-Badges vorhanden & gebunden, keine leeren Panels. Git-Tab: `+1 37`, „merges cleanly" korrekt. **BUG:** OUTCOME-Karte zeigt rohes JSON (s. open.md). Session-Tab fehlt — **erwartet** (nur bei Parent mit Kindern, `HasChildOutcomes`; wird in §4/§3 mit echtem Parent geprüft). Minor: TurnsText `0/max` bei terminalem Reload (open.md). Prep-Panel → §10.
- [~] Diff-Viewer: Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach einem Merge. — **Added/Deleted/Binary PASS**; **Renamed** erkannt + „R"-Badge, aber schwach dargestellt (kein alt→neu-Pfad, „+0 0" — nit, s. open.md); „Review Combined Diff" korrekt ausgegraut (kein Planning-Parent). **Commit-Range-Diff nach Merge: PASS (2026-07-24)** — Diff des gemergten Done-Tasks rendert über `base..head` trotz entferntem Worktree.
- [x] DiffModal-Fehler-State: Commit-Range ohne aufgezeichnete Commits → „Diff nicht mehr verfügbar" statt Crash/leer. — **GEKLÄRT via Code-Analyse (2026-07-24): der Fehler-State ist defensiv/unerreichbar.** `vm.diff.unavailable` (DiffViewerViewModel.cs:117-119) feuert nur bei `FromCommitRange && (BaseRef==null || HeadCommit==null)` oder `WorktreePath==null`. Alle Aufrufer sind gegated: `MergeSectionViewModel.OpenDiffAsync` ruft `ConfigureCommitRange` nur unter `CanDiffMergedRange` (base **und** head non-null) und `ConfigureWorktree` nur unter `hasLiveWorktree` (Pfad non-null + existiert); `WorktreesOverviewModalViewModel` übergibt `row.Path` (non-null). Damit können die Null-Checks über die UI nie wahr werden — der „Crash/leer"-Fall, den der Check absichern wollte, ist durch die CanExecute-/Modus-Gates ausgeschlossen. (Fehlende Commit-Objekte bei vorhandenem base+head → `GetCommitRangeDiffAsync` wirft → „loadFailed", nicht „unavailable".) Nicht manuell auslösbar; kein Bug.
- [x] „children need attention"-Band auf dem Session-Tab eines Parents mit failed/blocked Kind. — **PASS (2026-07-24, in §3)**: Band + OUTCOMES-Liste mit Roadblock-Hinweis auf dem Session-Tab des Planning-Parents (`verif §3`, Roadblock-Kind).
## 2. Worktree-Pipeline (3 falsifizierbare Fälle)
- [x] Happy-Path: Task mit WorkingDir → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id[:8]>` existiert auf Disk. — **PASS (2026-07-24)** via Task `e63eb2f5` (sonnet): Worktree active, `headCommit 8a3ae86` (ahead=1 vom Base), `diff_stat` non-empty (`review-cancel.txt`), Branch `claudedo/e63eb2f5…` auf Disk. (Mein erster Versuch mit `model=haiku` schlug fehl — das war ein haiku/auto-Footgun, keine Pipeline-Sache; s. open.md.)
- [x] No-Changes-Run: → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`. — **PASS (2026-07-24)**; Präzisierung: aktueller Flow endet in `WaitingForReview` (nicht `Done`, das ist erst nach Approve) — Doc-„Done" ist veraltet. Kein neuer Commit, leerer Diff bestätigt.
- [x] Kein Git-Repo (WorkingDir = `C:\Temp`): → `status='Failed'`, KEINE `worktrees`-Row, Git-Fehler im Log. — **PASS (2026-07-24)**; Fehler: „Worktree creation failed: working_dir is not a git repository".
## 3. Planning-Flow-Walkthrough
- [x] Draft → Finalize → Kette: Finalize queued NICHT automatisch (Kinder bleiben Idle); „Queue plan" setzt alle nicht-terminalen Kinder Queued, Kette läuft sequenziell durch (blocked-by löst sich je Vorgänger). — **PASS (2026-07-24, End-to-End mit `verif §3`)**: 3 Kinder gedraftet, Finalize → Parent WaitingForChildren+finalized, Kinder Idle+Kette (clean→conflict→roadblock), Queue plan → sequenzieller Durchlauf, alle Done. **Begleit-Findings (open.md):** „Waiting for Improvements"-Mislabel, Kind-Badge-Live-Refresh (Draft→Planned erst nach Listenwechsel), Kette nicht visualisiert, planning-aktiver Parent zeigt „Idle", Dequeue-X fehlt auf wartenden Kindern, Planning-Permission-Prompt, Planning nutzt wt statt ConPTY.
- [x] Parent landet nach letztem terminalen Kind in WaitingForReview; Approve merged die ganze Unit (Parent-Worktree falls Active + jedes Done-Kind in Reihenfolge). — **PASS (2026-07-24)**: Parent → WaitingForReview auch mit Roadblock-Kind; Approve → Unit-Merge (child-clean clean, child-conflict → Konflikt-Editor pro Subtask → aufgelöst → Continue, child-roadblock no-op) → Parent Done, Merge gelandet.
- [x] UnfinishedPlanning-Modal: Resume / FinalizeNow / Discard. — **GETESTET (2026-07-24, `verif §3 unfinished-planning` + `verif §3 resume-discard`)**: Modal wird via Rechtsklick→„Resume planning session" ausgelöst, zeigt Titel + „N draft task(s) waiting to be finalized" + Buttons Discard/Finalize/Resume + ×. **× (dismiss)**: No-op (State bleibt `active`). **Finalize**: Parent → `finalized`+`WaitingForChildren`, Kinder Idle+Blocked-by-Kette (= Planned) — PASS. **Discard**: Draft-Kinder gelöscht, Parent → `none`+`idle` — funktional PASS. **Resume: BUG — macht nichts** (`planning_session_id` nie erfasst → `ResumeAsync` wirft „No Claude session ID captured yet", vom UI im leeren `catch` verschluckt; s. open.md). Beide State-ändernden Aktionen reproduzieren das Kind-Rows-Live-Refresh-Finding (Finalize: Badges stale; Discard: gelöschte Rows bleiben bis Reload; open.md).
## 4. Merge-Editor (Rider-Style 3-Pane) mit echtem Konflikt
Konflikt provozieren: gleiche Datei auf main ändern, während der Task-Branch sie ändert. Beide Wege testen: **(a)** Single-Task-Approve mit Konflikt, **(b)** Planning-Unit-Merge mit Konflikt in einem Subtask (`PlanningMergeConflict` → Editor öffnet pro Subtask).
**Verifiziert 2026-07-24 (Single-Task-Approve mit Konflikt, 2 Dateien, `verif §4`): End-to-End PASS** — Approve→Konflikt→3-Pane-Resolver→beide Dateien auflösen→Continue→Merge→Task Done (Merge-Commit gelandet). Sauberer additiver Approve (`verif §1b`, kein Konflikt) → direkt gemergt → Done, kein Editor: **PASS (2026-07-24)**. ⚠ **Vorbedingung-Fund:** ein dirty Ziel-Working-Tree (getrackte uncommittete Änderung) blockt den Merge und Approve schluckt das **still** (s. open.md „Approve & Merge schluckt blocked"). Planning-Unit-Merge-Konflikt (Weg b): **PASS (2026-07-24, in §3)** — Approve eines Planning-Parents mit einem konfliktenden Subtask öffnet den 3-Pane-Editor pro Subtask (`PlanningMergeConflict`), Continue führt den Unit-Merge fort → Parent Done.
- [x] Drei Panes: MAIN read-only | Result editierbar | INCOMING read-only; Konfliktblöcke rot, aufgelöst grün, in allen Panes. — **PASS**.
- [x] Gutter-Toggle ``/``: Seite rein/raus, Klickreihenfolge = Reihenfolge im Result; main/incoming/beide/keine möglich. — **PASS**.
- [x] Nur Konfliktregionen im Result editierbar (Stable read-only); Edits fließen in den Block zurück. — **PASS**.
- [~] Synchrones vertikales Scrollen; File-Switcher bei mehreren Dateien; `M conflicts · K resolved`-Readout; Conflict-Ruler (Klick springt). — **PASS**, aber **Multi-File schlecht erkennbar** (2 Konfliktdateien schwer zu sehen — UX-Nit, open.md).
- [~] Continue erst aktiv, wenn ALLE Konflikte in ALLEN Dateien gelöst; Binär-Guard greift. — **funktional PASS** (merged erst nach Auflösung beider Dateien), aber **Button ist klickbar statt disabled** solange offen → stummer No-op (UX-Bug, open.md). Binär-Guard hier n/a.
- [x] Abort: Tree sauber, Task bleibt WaitingForReview. — **PASS (2026-07-24, `verif §4 abort`)**: Single-Task-Approve mit divergentem main→Konflikt→3-Pane-Editor→**Abort**: Editor schließt, Task bleibt `WaitingForReview`, `main`-Tree sauber (kein `MERGE_HEAD`, kein dirty), `main`-Inhalt + HEAD unverändert.
- [FEATURE-WUNSCH] farbliches Hervorheben der übernommenen/eingefügten Zeilen im Result-Pane (open.md).
- Bekannte Kanten (nur gegenprüfen, nicht als Fail werten): leere Ours-Seite → null-lange Result-Region (Accept geht, Handtippen fummelig); Gutter-Y-Ausrichtung bei sehr hohen Fenstern/großem Scroll; vertikaler Drift nachfolgender Blöcke nach Konflikt mit ungleicher Zeilenzahl.
## 5. Embedded ConPTY / Mission Control
- [x] **Kritisch:** Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet — Task-Prompt wirklich GESENDET. — **PASS (2026-07-24)**: Agent antwortete mit dem Marker `CONPTY-PROMPT-RECEIVED-4Q7`, Worktree on-demand angelegt.
- [~] Ad-hoc: „New session"-Button → Ordnerwahl → freie Session im gewählten Verzeichnis. — **funktioniert, aber Button unsichtbar**: `Icon.Plus` ist Strich-Only → `PathIcon` rendert nichts (Bug, open.md). Über Tooltip/Klick auf die leere Header-Fläche erreichbar; Ordnerwahl + Ad-hoc-Session funktionieren.
- [x] Grid↔Tabs-Toggle; Close killt den Prozess + entfernt die Kachel; mehrere Sessions parallel; Pane-Resize reflowt das TUI. — **PASS (2026-07-24)**: Resize/Reflow ok; Close entfernt Kachel UND killt den Prozess (verifiziert: ConPTY-PID 50964, Kind von ClaudeDo.App, nach Close tot).
- [~] Resume: im Worktree-Dir per `claude --continue` möglich (ClaudeDo persistiert die ConPTY-Session-Id bewusst nicht). — **wie designt**: erneutes „Open ConPTY session" resumt NICHT, sondern startet frisch und **sendet den Prompt erneut** (UX-Falle, open.md). In-App-Resume gibt es nicht; nur claude-eigenes `--continue`/`--resume` im Worktree-Dir.
## 6. Pick up in terminal
- [x] Sichtbarkeit: Kontextmenü-Eintrag + Terminal-Button (ArrowOut) NUR bei WaitingForReview und Failed; bei Idle/Running/Queued/Done nicht. — **PASS (2026-07-24, Code+Test)**: `CanPickUpInTerminal => Status is WaitingForReview or Failed` (TaskRowViewModel.cs:69, DetailsIslandViewModel.cs:898), gebunden in TaskRowView.axaml:56 + TaskHeaderBar.axaml:34, Notify bei Statuswechsel, Unit-Test `CanPickUpInTerminal_OnlyForReviewOrFailed`. (Klick→Terminal + Fehler-Surfacing bleiben manueller UI/CLI-Check.)
- [ ] Klick → neues Windows-Terminal im Worktree-Verzeichnis, `claude --resume <id>` nimmt die Session mit Kontext wieder auf.
- [ ] Fehlerfälle surfacen sauber (Footer-Strip bzw. Fehlerdialog): laufende/gequeuete Task, keine persistierte Session-Id, kein aktiver Worktree.
- Bekannte Kante: parked-Idle (reject-park) hat oft Session+Worktree, zeigt die Aktion aber bewusst NICHT (Idle nicht unterscheidbar). Nervt das in der Praxis → `CanPickUpInTerminal` erweitern.
## 7. AskUser (Frage aus laufendem Task)
Runtime-Toolname ist `mcp__claudedo_run__ask_user` (snake_case, nicht `AskUser`). Fixture: sonnet-Task mit Prompt „zwei sich widersprechende, irreversible Optionen — du MUSST `ask_user` fragen, nicht raten" erzwingt den Call zuverlässig.
- [x] Task, dessen Prompt eine Rückfrage erzwingt → Frage erscheint im Task-Monitor (Mission Control), Prozess wartet. — **PASS (2026-07-24, `verif §7 askuser 2`)**: Banner in Mission Control, Run blockiert im `ask_user`-Tool-Call. **BUG:** in der Task-Detail-Insel erscheint KEIN Banner (nur Mission Control) → s. open.md.
- [x] Antwort inline absenden → Run läuft mit der Antwort weiter. — **PASS (2026-07-24)**: Antwort „A" inline gesendet → Agent bekam „option A", benannte `merge-playground.txt``renamed-by-agent.txt` um, schrieb `askuser-result.txt` = „USER CHOSE: A", Task → WaitingForReview.
- [x] Timeout-/Cleanup-Verhalten der PendingQuestionRegistry (Frage unbeantwortet lassen): Task schlägt kontrolliert fehl, UI räumt die Frage auf (MCP_TOOL_TIMEOUT-Gotcha). — **PASS (2026-07-24)**: Backend (`verif §7 askuser`) nach exakt 3 min Fallback zurück, Agent hielt sich an „MUST NOT guess", meldete `CLAUDEDO_BLOCKED`, Repo untouched, Registry im `finally` aufgeräumt, Run endete `success` → WaitingForReview. **UI-Cleanup visuell bestätigt** (`verif §7 timeout-ui`, MC-Kachel offen/subscribt bis zum Timeout): Banner verschwindet beim Timeout automatisch (`TaskQuestionResolved``ClearPendingQuestion`).
## 8. Session Skills (E2E + UI)
- [x] Settings → Skills: `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), auf Commit gepinnt, Dateien unter `~/.todo-app/session-skills/<name>/`. — **PASS (2026-07-24)**: 6 Rows in `session_skills`, alle auf Commit `16f29800` gepinnt, `subpath=skills/<name>`, Dateien inkl. `SKILL.md` am erwarteten Ort. UI-Install nahm die URL an. **Nit:** Skills-Tab hat keinen Empty-State (s. open.md).
- [x] Skill per-Task (Agent-Settings-Flyout) oder global aktivieren → Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, Agent kann ihn nutzen, `git status` im Worktree bleibt sauber (info/exclude greift). — **PASS (2026-07-24, per-Task, `verif §8 skill-activate`)**: `ponytail-help` per Flyout aktiviert (`task.SessionSkills=["ponytail-help"]`), Run → `.claude/skills/ponytail-help/SKILL.md` im Worktree, `info/exclude` bekam `/.claude/skills/ponytail-help/`, `git status` clean, Auto-Commit enthält NUR `skill-proof.txt` (Skill NICHT im Tree). Agent hat den Skill nachweislich genutzt (`skill-proof.txt` = ponytail-help-Referenzkarte verbatim). Global-Aktivierung (AppSettings.SessionSkills, General-Tab) nutzt denselben Seeding-Union-Pfad — funktional äquivalent, nicht separat gefahren.
- [x] Gegenprobe: nicht-aktivierte/andere interaktive Session sieht den Skill NICHT (kein Leak nach `~/.claude`). — **PASS (2026-07-24)**: `~/.claude/skills/` enthält nur die vorbestehenden globalen Skills, KEIN ponytail. Seeder schreibt per Konstruktion nur nach `<workingDir>/.claude/skills` (SessionSkillSeeder), nie nach `~/.claude`.
- [~] UI: Skills-Tab (Install-Zeile, Karten mit Update/Remove), Checkbox-Listen im General-Tab + AgentConfigEditor (Flyout-Höhe!), leerer Zustand (0 Skills — Empty-State fehlt evtl., dann entscheiden), lange Namen/URLs (Trimming). — **TEILWEISE (2026-07-24)**: Install-Zeile nimmt URL an (PASS); AgentConfigEditor-Flyout-Checkbox-Liste zeigt alle 6 Skills, Höhe ok, Trimming ok (User: „funktioniert wie erhofft"). **Empty-State fehlt** (nackte Fläche — open.md). **Agent-Settings-Gear ⚙ weicht vom Listen-Gear ab** (open.md). Skills-Tab-**Karten** (Name/Beschreibung/Pinned-Commit + Update/Remove) und **General-Tab-Checkbox-Liste** (6 Skills) sichtgeprüft (User: „sieht alles gut aus"). **Remove PASS (2026-07-24)**: ein Remove-Klick auf eine ponytail-Karte entfernte alle 6 Skills per Quell-URL (`session_skills` leer, alle Verzeichnisse unter `~/.todo-app/session-skills/` weg). **Update** nicht separat gefahren (Code-Pfad `SessionSkillRegistry.UpdateAsync`).
## 9. Attachments (Drag & Drop + MCP)
- [x] Drop aufs Detail-Pane: „Drop to attach"-Overlay, Datei erscheint in der Liste, landet unter `~/.todo-app/attachments/<taskId>/`; „Add file…"-Picker; Remove-Button. — **PASS (2026-07-24, `verif §9 attachments-ui`)**: Overlay/Highlight beim Drüberziehen, Drop-Round-Trip (`drop-me.txt` in Liste + Disk + DB, 29 B), „Add file…"-Picker (`pick-me.md`), Remove (Datei aus Liste + Disk + DB) — alle bestätigt. **Finding:** der ALLERERSTE Drop der Session schlug einmalig mit inline „An error occurred" fehl (nichts persistiert), danach fehlerfrei — intermittierend, nicht reproduzierbar (s. open.md).
- [x] `ComposedPreview` enthält die Attachment-Pfade („## Reference files"). — **PASS (2026-07-24, Code+Test)**: TaskPromptComposer.cs:31 emittiert „## Reference files", ComposedPreview reicht Pfade durch, TaskRunner.cs:132 injiziert zur Laufzeit; TaskPromptComposerTests decken es ab.
- [x] MCP: `add_task_attachment` / `list_task_attachments` / `remove_task_attachment`; Running-Task verweigert add/remove. — **PASS (2026-07-24)** für add/list/remove-Round-Trip inkl. Datei unter `~/.todo-app/attachments/<taskId>/` (71 B, korrekt, nach Remove weg). Running-Task-Verweigerung ist code-guarded (AttachmentMcpTools), aber ohne dauerhaft laufende Task nicht live geprüft → manueller Rest.
## 10. Daily Prep (Prime) & Weekly Report
- [ ] Prime-Trigger: Schedule feuert bzw. „Plan day" manuell → Prep-Log streamt live, `daily-prep.log` enthält letzten Run, MyDay-Auswahl respektiert `DailyPrepMaxTasks`.
- [ ] Weekly Report: Range-Default „seit letztem Standup-Wochentag → heute", Markdown rendert, Cache pro Range.
## 11. Self-Update & Autostart (am Gerät)
- [ ] Update-Banner → Update durchführen → danach „up to date".
- [ ] Autostart: Logoff/Logon startet den Worker (Startup-`.lnk`); Update-Pfad erhält den Autostart; Uninstall entfernt die `.lnk`.
## 12. Status-Bar / RunNow (Mini-Codecheck, erst messen)
- [x] Worker trennen/verbinden → prüfen, ob RunNow-Enable pro Task-Row sauber re-evaluiert (Connection-State lebt in `IslandsShellViewModel`). Nur fixen, wenn tatsächlich kaputt. — **PASS/moot (2026-07-24, Code)**: Es gibt kein per-Row-RunNow-Control in der UI; `RunNowAsync` (IWorkerClient/WorkerClient) hat keinen VM/View-Aufrufer (RunNow nur via MCP `run_task_now`). Die realen Detail-Pane-Aktionen (Enqueue/Dequeue/Continue/ResetAndRetry) re-evaluieren korrekt bei Connection-Change (DetailsIslandViewModel.cs:317-323). Nebenbefund: `RunNowAsync` in der UI ist toter Code.
+15 -9
View File
@@ -71,9 +71,22 @@ public sealed class GitService
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
HasChangesAsync(worktreePath, includeUntracked: true, ct);
/// <summary>
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
/// files — use this for merge preflights on a shared target working dir, where stray
/// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
/// and data-loss-guard callers keep the default (true): a new file a task created, or an
/// untracked file about to be discarded, is a real uncommitted change.
/// </summary>
public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
string[] args = includeUntracked
? ["status", "--porcelain"]
: ["status", "--porcelain", "--untracked-files=no"];
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return !string.IsNullOrWhiteSpace(stdout);
@@ -330,13 +343,6 @@ public sealed class GitService
.Count(s => s.Length > 0);
}
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
if (exitCode != 0)
throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}");
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
{
+11 -5
View File
@@ -108,7 +108,8 @@
"installButton": "Installieren",
"installedSection": "INSTALLIERT",
"updateButton": "Aktualisieren",
"removeButton": "Entfernen"
"removeButton": "Entfernen",
"emptyState": "Keine Skills installiert — oben eine GitHub-URL einfügen, um einen hinzuzufügen."
}
},
"tasks": {
@@ -188,7 +189,7 @@
"mergeTargetLabel": "Merge-Ziel",
"reviewCombinedDiff": "Kombiniertes Diff prüfen",
"mergeAllSubtasks": "Alle Teilaufgaben mergen",
"childOutcomesLabel": "VERBESSERUNGEN",
"childOutcomesLabel": "TEILAUFGABEN",
"stepsLabel": "SCHRITTE",
"addStepPlaceholder": "Schritt hinzufügen...",
"detailsLabel": "DETAILS",
@@ -209,6 +210,7 @@
"addedSummary": "✓ Hinzugefügt: {0} ({1} Datei(en))",
"overLimitError": "Konnte {0} nicht hinzufügen: {1}",
"invalidNameError": "Konnte {0} nicht hinzufügen: {1}",
"dropReadError": "Abgelegte Dateien konnten nicht gelesen werden: {0}",
"selectIdleTask": "Zuerst eine inaktive Aufgabe auswählen"
},
"sections": {
@@ -264,6 +266,10 @@
"overviewMode": "Übersicht",
"closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
"submitForReview": "Zum Review einreichen",
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
"planningTitleSuffix": " (Planung)",
"question": {
"title": "Claude fragt nach",
"placeholder": "Antwort eingeben…",
@@ -500,11 +506,11 @@
"vm": {
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
+11 -5
View File
@@ -108,7 +108,8 @@
"installButton": "Install",
"installedSection": "INSTALLED",
"updateButton": "Update",
"removeButton": "Remove"
"removeButton": "Remove",
"emptyState": "No skills installed — paste a GitHub URL above to add one."
}
},
"tasks": {
@@ -188,7 +189,7 @@
"mergeTargetLabel": "Merge target",
"reviewCombinedDiff": "Review combined diff",
"mergeAllSubtasks": "Merge all subtasks",
"childOutcomesLabel": "IMPROVEMENTS",
"childOutcomesLabel": "SUBTASKS",
"stepsLabel": "STEPS",
"addStepPlaceholder": "Add a step...",
"detailsLabel": "DETAILS",
@@ -209,6 +210,7 @@
"addedSummary": "✓ Added {0} ({1} file(s))",
"overLimitError": "Could not add {0}: {1}",
"invalidNameError": "Could not add {0}: {1}",
"dropReadError": "Couldn't read the dropped files: {0}",
"selectIdleTask": "Select an idle task first"
},
"sections": {
@@ -264,6 +266,10 @@
"overviewMode": "Overview",
"closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
"submitForReviewFailed": "Couldn't submit for review: {0}",
"submitForReview": "Submit for review",
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
"planningTitleSuffix": " (Planning)",
"question": {
"title": "Claude is asking",
"placeholder": "Type your answer…",
@@ -500,11 +506,11 @@
"vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
+1 -1
View File
@@ -36,7 +36,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel` and call `ShowDiffViewer`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
+9 -2
View File
@@ -57,8 +57,8 @@
<!-- Icon.Search -->
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry>
<!-- Icon.Plus -->
<StreamGeometry x:Key="Icon.Plus">M12 5v14M5 12h14</StreamGeometry>
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
<!-- Icon.MoreHorizontal (three filled dots) — uses fill so rendered via PathIcon with fill brush -->
<StreamGeometry x:Key="Icon.MoreHorizontal">M5 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M12 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M19 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0</StreamGeometry>
@@ -1162,6 +1162,13 @@
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Disabled: drop the accent so a gated action (e.g. Approve before the diff is
opened) reads clearly as unavailable instead of looking clickable. -->
<Style Selector="Button.accent:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- ============================================================ -->
<!-- DAY TOGGLE -->
@@ -76,12 +76,20 @@ public interface IWorkerClient : INotifyPropertyChanged
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
// Picks up a task's Claude session in a real terminal window (--resume).
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
/// <summary>Commits an interactively-worked task's worktree and moves it to WaitingForReview
/// (the only path that flips a hand-driven ConPTY session into the review/merge pipeline).</summary>
Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY terminal to open an interactive session
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Resumes a planning session and returns its embedded-ConPTY launch spec.</summary>
Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
+9
View File
@@ -513,12 +513,21 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
@@ -103,9 +103,19 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
}
}
public string ContinueHint => HasBinaryFiles
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
: "";
public string ContinueHint
{
get
{
if (HasBinaryFiles)
return "Binary conflicts must be resolved externally — abort and resolve in your editor.";
var openFiles = Files.Count(f => !f.AllResolved);
if (openFiles == 0) return "";
var openConflicts = _flat.Count(x => !x.Block.IsResolved);
return $"{openConflicts} {(openConflicts == 1 ? "conflict" : "conflicts")} in " +
$"{openFiles} {(openFiles == 1 ? "file" : "files")} still unresolved.";
}
}
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
@@ -188,6 +198,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
OnPropertyChanged(nameof(HasBinaryFiles));
OnPropertyChanged(nameof(HasMultipleFiles));
OnPropertyChanged(nameof(FilesSummary));
OnPropertyChanged(nameof(ContinueHint));
RecomputeCanContinue();
if (_flat.Count > 0)
MoveTo(0); // also sets ActiveFile via MoveTo
@@ -211,6 +222,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
OnPropertyChanged(nameof(PositionText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(FilesSummary));
OnPropertyChanged(nameof(ContinueHint));
}
}
@@ -296,6 +296,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services);
Merge.DiffViewed = () => ReviewDiffViewed = true;
Merge.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff))
{
ApproveReviewCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowReviewDiffHint));
}
};
Prep = new PrepPanelViewModel(worker);
Notes = new NotesEditorViewModel(_notesApi);
@@ -399,6 +408,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
// A state change means a new run/review cycle: the diff must be
// re-inspected before merge can be approved again.
ReviewDiffViewed = false;
ApproveReviewCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ShowReviewDiffHint));
OnPropertyChanged(nameof(CanSubmitForReview));
SubmitForReviewCommand.NotifyCanExecuteChanged();
AgentSettings.IsRunning = IsRunning;
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
@@ -536,6 +552,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId;
// Restore turn/token counts from the last run so a reloaded terminal task
// shows its real turns instead of "0/max".
Turns = latestRun?.TurnCount ?? 0;
Tokens = (latestRun?.TokensIn ?? 0) + (latestRun?.TokensOut ?? 0);
Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
Monitor.SetTaskId(row.Id);
@@ -740,6 +760,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
NotifySessionSections();
OnPropertyChanged(nameof(CanSubmitForReview));
SubmitForReviewCommand.NotifyCanExecuteChanged();
}
partial void OnWorktreeHeadCommitChanged(string? value) =>
@@ -748,6 +770,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
partial void OnTaskChanged(TaskRowViewModel? value)
{
ReviewDiffViewed = false;
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
@@ -977,7 +1000,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool CanResetAndRetry() =>
Task != null && _worker.IsConnected && ShowResetAndRetry;
[RelayCommand]
// Set once the user opens the diff/combined-diff for the current review. Reset on
// task switch and on every state change (a new run means a new diff to read), so
// Approve & Merge stays blocked until the pending changes have been inspected.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApproveReviewCommand))]
[NotifyPropertyChangedFor(nameof(ShowReviewDiffHint))]
private bool _reviewDiffViewed;
// True while a review is pending, there is a diff to inspect, and it has not been
// opened yet — drives the "open the diff first" hint next to Approve & Merge.
public bool ShowReviewDiffHint =>
IsWaitingForReview && Merge.HasReviewableDiff && !ReviewDiffViewed;
[RelayCommand(CanExecute = nameof(CanApproveReview))]
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
@@ -995,6 +1031,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
// Force the diff to have been opened before a merge can happen — but only when
// there is actually something to inspect (a childless sandbox run with no worktree
// has no diff, so it approves straight through).
private bool CanApproveReview() =>
Task != null && _worker.IsConnected && IsWaitingForReview
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
public bool CanSubmitForReview =>
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath);
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private async System.Threading.Tasks.Task SubmitForReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
}
}
[RelayCommand(CanExecute = nameof(HasReviewFeedback))]
private async System.Threading.Tasks.Task RejectReviewAsync()
{
@@ -1099,7 +1159,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (Exception ex)
{
failures.Add($"{fileName}: {ex.Message}");
// Keep the exception type in the message so an intermittent failure is analyzable.
failures.Add($"{fileName}: {ex.GetType().Name}: {ex.Message}");
}
}
@@ -48,6 +48,15 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
// Raised when the user opens a diff/combined-diff for the current task, so the
// review gate can record that the changes were inspected before merging.
public Action? DiffViewed { get; set; }
// True when there is something to inspect before merging (a live worktree diff,
// a merged commit range, or a planning/children combined diff). When false there
// is nothing to read, so the review gate must not block approve.
public bool HasReviewableDiff => CanOpenDiff() || CanReviewDiff();
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
{
_worker = worker;
@@ -69,6 +78,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
_worktreeStateLabel = worktreeState;
_listWorkingDir = listWorkDir;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
}
@@ -79,6 +89,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
TaskTitle = taskTitle;
_isPlanningParent = isPlanningParent;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
}
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
@@ -86,6 +97,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
_hasChildOutcomes = hasChildOutcomes;
_subtaskCount = subtaskCount;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
}
@@ -128,6 +140,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
var vm = _services.GetRequiredService<DiffViewerViewModel>();
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
@@ -157,6 +170,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
else return;
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Text;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
@@ -258,16 +259,38 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionContro
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
if (idx < 0)
{
SessionOutcome = result;
SessionOutcome = UnwrapSummary(result);
Roadblocks = null;
return;
}
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : UnwrapSummary(summary);
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
}
/// <summary>Safety net for older rows persisted as raw structured-output JSON: if the outcome
/// is a JSON object with a string <c>summary</c>, show that; otherwise keep the text as-is.</summary>
private static string? UnwrapSummary(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return text;
var trimmed = text.TrimStart();
if (trimmed.Length == 0 || trimmed[0] != '{') return text;
try
{
using var doc = JsonDocument.Parse(trimmed);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("summary", out var s) &&
s.ValueKind == JsonValueKind.String)
{
var summary = s.GetString();
if (!string.IsNullOrWhiteSpace(summary)) return summary;
}
}
catch { }
return text;
}
public async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(logPath)) return;
@@ -128,6 +128,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
_ => Status.ToString(),
};
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case.
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{
(TaskStatus.Running, _) => "running",
@@ -144,6 +149,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(CanPickUpInTerminal));
@@ -189,6 +195,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(IsPlanActive));
OnPropertyChanged(nameof(IsPlanFinalized));
OnPropertyChanged(nameof(CanOpenPlanningSession));
@@ -151,6 +151,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
@@ -801,19 +813,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
private void OpenPlanningSession(TaskRowViewModel? row)
{
if (row is null) return;
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
ForegroundHelper.AllowAny();
try { await _worker!.StartPlanningSessionAsync(row.Id); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
// Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt
// window). The shell owns Mission Control, so raise an event; the actual StartAsync happens
// server-side inside GetPlanningStartLaunchSpec when the pane opens.
OpenPlanningConPtyRequested?.Invoke(row.Id, false);
}
// Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
// the Mission Control view model, so this just raises an event for it to act on.
public event Action<string>? OpenConPtySessionRequested;
// Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
// pane in the Command Center.
public event Action<string, bool>? OpenPlanningConPtyRequested;
[RelayCommand]
private void OpenConPtySession(TaskRowViewModel? row)
{
@@ -853,8 +870,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
switch (choice)
{
case UnfinishedPlanningModalResult.Resume:
ForegroundHelper.AllowAny();
await _worker.ResumePlanningSessionAsync(row.Id);
// Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside
// GetPlanningResumeLaunchSpec when the pane opens).
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
@@ -867,7 +885,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
break;
}
}
catch { }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningResumeFailed", ex.Message)); }
}
[RelayCommand]
@@ -97,7 +97,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _isUpdateBannerVisible;
[ObservableProperty] private string? _updateBannerLatestVersion;
[ObservableProperty] private string? _inlineUpdateStatus;
private bool _bannerDismissedThisSession;
[ObservableProperty]
@@ -234,6 +233,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
OpenMissionControl();
_ = MissionControl.OpenConPtySessionAsync(taskId);
};
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
{
OpenMissionControl();
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
};
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Tasks.OpenListSettingsRequested += (_, _) =>
{
@@ -305,25 +309,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
IsUpdateBannerVisible = true;
InlineUpdateStatus = null;
break;
case UpdateCheckStatus.UpToDate:
IsUpdateBannerVisible = false;
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
break;
case UpdateCheckStatus.CheckFailed:
ShowInlineStatus("Could not check for updates");
break;
}
}
private async void ShowInlineStatus(string text)
{
InlineUpdateStatus = text;
await Task.Delay(3000);
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
}
[RelayCommand]
private void OpenMissionControl()
{
@@ -17,6 +17,9 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
{
public string? TaskId { get; }
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
[ObservableProperty] private string _displayTitle;
public InteractiveTerminalViewModel Terminal { get; } = new();
@@ -27,6 +30,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
/// <summary>Raised when the user submits this task's hand-driven work for review; the host
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.</summary>
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
@@ -50,6 +57,14 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
[RelayCommand]
private void Close() => CloseRequested?.Invoke(this);
private bool CanSubmitForReview() => IsTaskBased;
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private void SubmitForReview()
{
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
}
public void Dispose()
{
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
@@ -248,6 +248,47 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
}
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
// pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
// like OpenConPtySessionAsync.
public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
{
if (string.IsNullOrEmpty(taskId)) return;
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
{
FocusedPane = existing;
return;
}
var title = taskId;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
if (entity?.Title is { Length: > 0 } t) title = t;
}
catch { /* best-effort title lookup */ }
title += Loc.T("missionControl.planningTitleSuffix");
try
{
var spec = resume
? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
: await _worker.GetPlanningStartLaunchSpecAsync(taskId);
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
@@ -272,6 +313,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
@@ -282,6 +324,22 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
private async void OnPaneSubmitForReview(string taskId)
{
try
{
await _worker.SubmitTaskForReviewAsync(taskId);
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane)
CloseConPtySession(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
}
}
private void CloseConPtySession(ConPtyPaneViewModel pane)
{
if (!ConPtySessions.Contains(pane)) return;
@@ -54,6 +54,14 @@ public sealed class DiffFileViewModel
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
/// A rename with no content change — showing "+0 0" for it is misleading.
public bool IsPureRename => Status == DiffFileStatus.Renamed && Additions == 0 && Deletions == 0;
/// Header path label: for a rename, spell out old → new; otherwise just the path.
public string HeaderPath => Status == DiffFileStatus.Renamed && !string.IsNullOrEmpty(OldPath)
? $"{OldPath} → {Path}"
: Path;
}
/// One row in the planning subtask list (left pane in Planning mode).
@@ -72,7 +80,7 @@ public sealed partial class DiffTreeNodeViewModel : ViewModelBase
[ObservableProperty] private bool _isExpanded = true;
public string? StatusCode => File?.StatusCode;
public bool ShowStats => File is { IsBinary: false };
public bool ShowStats => File is { IsBinary: false, IsPureRename: false };
public int Additions => File?.Additions ?? 0;
public int Deletions => File?.Deletions ?? 0;
}
@@ -13,6 +13,7 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
[ObservableProperty] private string _installUrl = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _isEmpty = true;
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
@@ -26,6 +27,7 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
var skills = await _worker.GetSessionSkillsAsync();
Skills.Clear();
foreach (var s in skills) Skills.Add(s);
IsEmpty = Skills.Count == 0;
}
finally { IsBusy = false; }
}
@@ -1,3 +1,5 @@
using System.Text;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// Shared unified-diff parser used by both the per-task diff viewer and the
@@ -16,10 +18,13 @@ public static class UnifiedDiffParser
{
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
{
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs"
var parts = line.Split(' ');
var path = parts.Length >= 4 ? parts[3][2..] : line;
current = new DiffFileViewModel { Path = path };
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs". Paths may each be
// git-quoted (C-style, octal-escaped) when they contain non-ASCII
// bytes, and unquoted paths may themselves contain spaces, so the
// two paths can't be split on a plain ' ' - this is only a best
// effort default; "--- "/"+++ "/rename lines below correct it.
var (_, newPath) = SplitGitHeaderPaths(line["diff --git ".Length..]);
current = new DiffFileViewModel { Path = newPath };
files.Add(current);
oldLine = 0; newLine = 0;
continue;
@@ -41,13 +46,13 @@ public static class UnifiedDiffParser
if (line.StartsWith("rename from ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.OldPath = line["rename from ".Length..];
current.OldPath = UnquotePath(line["rename from ".Length..]);
continue;
}
if (line.StartsWith("rename to ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.Path = line["rename to ".Length..];
current.Path = UnquotePath(line["rename to ".Length..]);
continue;
}
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
@@ -64,10 +69,27 @@ public static class UnifiedDiffParser
continue;
}
// "--- a/..." / "+++ b/..." each carry a single unambiguous path (unlike
// the two-paths-on-one-line "diff --git" header above), so use them to
// correct the file's identity - git also appends a disambiguating tab
// after unquoted paths that contain whitespace, which must be trimmed.
if (line.StartsWith("--- ", StringComparison.Ordinal))
{
var oldPath = UnquotePath(line[4..].TrimEnd('\t'));
if (oldPath != "/dev/null" && current.Status == DiffFileStatus.Deleted)
current.Path = StripPrefix(oldPath, "a/");
continue;
}
if (line.StartsWith("+++ ", StringComparison.Ordinal))
{
var newPath = UnquotePath(line[4..].TrimEnd('\t'));
if (newPath != "/dev/null" && current.Status != DiffFileStatus.Renamed)
current.Path = StripPrefix(newPath, "b/");
continue;
}
// Skip remaining diff metadata lines
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
line.StartsWith("index ", StringComparison.Ordinal) ||
if (line.StartsWith("index ", StringComparison.Ordinal) ||
line.StartsWith("old mode", StringComparison.Ordinal) ||
line.StartsWith("new mode", StringComparison.Ordinal) ||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
@@ -140,4 +162,111 @@ public static class UnifiedDiffParser
newStart = n;
}
}
/// Splits a "diff --git" header's remainder ("a/&lt;old&gt; b/&lt;new&gt;", where
/// either side may be individually git-quoted) into its two paths.
private static (string OldPath, string NewPath) SplitGitHeaderPaths(string content)
{
string oldToken, newToken;
if (content.Length > 0 && content[0] == '"')
{
var close = FindClosingQuote(content, 0);
oldToken = content[..(close + 1)];
newToken = content[(close + 1)..].TrimStart(' ');
}
else
{
var quoteAt = content.IndexOf('"');
if (quoteAt >= 0)
{
// Mixed quoting: an unquoted path never contains a literal quote
// (git would have quoted it), so the quote marks the other side.
oldToken = content[..quoteAt].TrimEnd(' ');
newToken = content[quoteAt..];
}
else
{
var splitAt = content.IndexOf(" b/", StringComparison.Ordinal);
oldToken = splitAt < 0 ? content : content[..splitAt];
newToken = splitAt < 0 ? content : content[(splitAt + 1)..];
}
}
return (StripPrefix(UnquotePath(oldToken), "a/"), StripPrefix(UnquotePath(newToken), "b/"));
}
private static int FindClosingQuote(string s, int start)
{
var i = start + 1;
while (i < s.Length)
{
if (s[i] == '\\') { i += 2; continue; }
if (s[i] == '"') return i;
i++;
}
return s.Length - 1;
}
private static string StripPrefix(string path, string prefix) =>
path.StartsWith(prefix, StringComparison.Ordinal) ? path[prefix.Length..] : path;
/// Reverses git's C-style path quoting: a quoted token is wrapped in double
/// quotes with '\\', '"' and non-ASCII bytes escaped as \nnn octal sequences
/// (used whenever core.quotePath would otherwise consider the path "unusual").
private static string UnquotePath(string token)
{
if (token.Length < 2 || token[0] != '"' || token[^1] != '"')
return token;
var bytes = new List<byte>();
var end = token.Length - 1;
var i = 1;
while (i < end)
{
var c = token[i];
if (c != '\\')
{
bytes.Add((byte)c);
i++;
continue;
}
i++;
if (i >= end) break;
switch (token[i])
{
case '"': bytes.Add((byte)'"'); i++; break;
case '\\': bytes.Add((byte)'\\'); i++; break;
case 'a': bytes.Add(0x07); i++; break;
case 'b': bytes.Add(0x08); i++; break;
case 'f': bytes.Add(0x0C); i++; break;
case 'n': bytes.Add((byte)'\n'); i++; break;
case 'r': bytes.Add((byte)'\r'); i++; break;
case 't': bytes.Add((byte)'\t'); i++; break;
case 'v': bytes.Add(0x0B); i++; break;
default:
if (token[i] is >= '0' and <= '7')
{
var value = 0;
var digits = 0;
while (digits < 3 && i < end && token[i] is >= '0' and <= '7')
{
value = value * 8 + (token[i] - '0');
i++;
digits++;
}
bytes.Add((byte)value);
}
else
{
bytes.Add((byte)token[i]);
i++;
}
break;
}
}
return Encoding.UTF8.GetString(bytes.ToArray());
}
}
@@ -67,7 +67,7 @@
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}"
Text="{Binding ContinueHint}"
IsVisible="{Binding HasBinaryFiles}"/>
IsVisible="{Binding ContinueHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
@@ -65,7 +65,7 @@
IsEnabled="{Binding AgentSettings.IsEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
<PathIcon Data="{StaticResource Icon.Settings}" Width="15" Height="15"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
<StackPanel Width="340" Spacing="10" Margin="4">
@@ -348,6 +348,16 @@
<Border Height="1" Background="{DynamicResource LineBrush}"
IsVisible="{Binding Merge.ShowMergeSection}" />
<!-- Gate: you must open the diff before Approve & Merge unlocks. -->
<StackPanel Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowReviewDiffHint}">
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11"
Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" />
<TextBlock Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource AmberBrush}"
Text="Open the diff to enable merge" />
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}" />
@@ -362,6 +372,17 @@
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</StackPanel>
<!-- Submit an interactive (ConPTY) session for review: an Idle/Failed task that
still has a worktree. Commits the worktree, then moves it to WaitingForReview. -->
<StackPanel Spacing="8" IsVisible="{Binding CanSubmitForReview}">
<Border Height="1" Background="{DynamicResource LineBrush}" />
<TextBlock Classes="meta" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
Text="Worked on this by hand? Submit the worktree for review to merge it." />
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
Command="{Binding SubmitForReviewCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -80,6 +80,37 @@
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
</Border>
<!-- Question prompt (AskUser): answer the running task's question inline, mirroring
the Mission Control banner. State is shared via the same TaskMonitorViewModel. -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding Monitor.HasPendingQuestion}"
Margin="0,0,0,10" Padding="12,8"
Background="{DynamicResource AccentSoftBrush}"
BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="1" CornerRadius="10">
<StackPanel Spacing="6">
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.question.title}"
Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Monitor.PendingQuestion}" TextWrapping="Wrap"
Foreground="{DynamicResource TextBrush}"/>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
<TextBox Grid.Column="0"
Text="{Binding Monitor.AnswerDraft, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{loc:Tr missionControl.question.placeholder}"
AcceptsReturn="False">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitAnswerCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1"
Content="{loc:Tr missionControl.question.send}"
Command="{Binding Monitor.SubmitAnswerCommand}"/>
</Grid>
</StackPanel>
</Border>
<detail:WorkConsole/>
</DockPanel>
@@ -81,14 +81,28 @@ public partial class DetailsIslandView : UserControl
if (items is null) return;
var files = new List<(string FileName, System.IO.Stream Content)>();
foreach (var item in items)
try
{
if (item is IStorageFile sf)
foreach (var item in items)
{
var stream = await sf.OpenReadAsync();
files.Add((sf.Name, stream));
if (item is IStorageFile sf)
{
var stream = await sf.OpenReadAsync();
files.Add((sf.Name, stream));
}
}
}
catch (Exception ex)
{
// Opening the dropped stream can fail (shell IO, file lock) before AddFilesAsync
// ever runs; surface it with the exception type so the next occurrence is analyzable
// instead of vanishing as an unobserved async-void fault.
_vm.DropStatus = string.Format(
ClaudeDo.Ui.Localization.Loc.T("details.attachments.dropReadError"),
$"{ex.GetType().Name}: {ex.Message}");
foreach (var (_, s) in files) await s.DisposeAsync();
return;
}
if (files.Count == 0) return;
@@ -156,6 +156,7 @@
<!-- Status chip -->
<Border Classes="chip"
IsVisible="{Binding ShowStatusChip}"
Classes.parked="{Binding IsParked}"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
@@ -55,10 +55,10 @@ public partial class TaskRowView : UserControl
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
private void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
}
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
+34 -41
View File
@@ -22,7 +22,7 @@
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings>
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
<Grid x:Name="RootGrid" RowDefinitions="36,*,22">
<!-- Custom title bar -->
<Border Grid.Row="0"
Background="{DynamicResource DeepBrush}"
@@ -107,45 +107,8 @@
</Grid>
</Border>
<!-- Update banner -->
<Border Grid.Row="1"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="14,6"
IsVisible="{Binding IsUpdateBannerVisible}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
Classes="body"
VerticalAlignment="Center">
<Run Text="{loc:Tr shell.update.available}"/>
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
<Run Text=" → v"/>
<Run Text="{Binding UpdateBannerLatestVersion}"/>
</TextBlock>
<Button Grid.Column="1"
Classes="btn"
Margin="0,0,8,0"
Content="{loc:Tr shell.update.updateNow}"
Command="{Binding UpdateNowCommand}"/>
<Button Grid.Column="2"
Classes="btn"
Content="{loc:Tr shell.update.dismiss}"
Command="{Binding DismissBannerCommand}"/>
</Grid>
</Border>
<!-- Inline update status (appears at right of banner row when no banner) -->
<TextBlock Grid.Row="1"
Classes="meta"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,14,0"
Text="{Binding InlineUpdateStatus}"
IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<!-- Background gradient layer -->
<Border Grid.Row="2">
<Border Grid.Row="1">
<Border.Background>
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
@@ -155,7 +118,7 @@
</Border>
<!-- Three islands (user-resizable) -->
<Grid Grid.Row="2" Margin="7">
<Grid Grid.Row="1" Margin="7">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" MinWidth="200"/>
<ColumnDefinition Width="Auto"/>
@@ -196,7 +159,7 @@
</Grid>
<!-- Footer: connection status -->
<Border Grid.Row="3"
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
@@ -255,5 +218,35 @@
<Panel/>
</DockPanel>
</Border>
<!-- Update popup card — bottom-right corner, only when update available -->
<Border Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Bottom" HorizontalAlignment="Right"
Margin="0,0,16,30"
ZIndex="100"
IsVisible="{Binding IsUpdateBannerVisible}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="14,10">
<StackPanel Spacing="8" Width="260">
<TextBlock Classes="body">
<Run Text="{loc:Tr shell.update.available}"/>
<Run Text=" v"/>
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
<Run Text=" → v"/>
<Run Text="{Binding UpdateBannerLatestVersion}"/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="btn"
Content="{loc:Tr shell.update.dismiss}"
Command="{Binding DismissBannerCommand}"/>
<Button Classes="btn"
Content="{loc:Tr shell.update.updateNow}"
Command="{Binding UpdateNowCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Window>
@@ -14,13 +14,18 @@
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3">
<Grid ColumnDefinitions="*,Auto">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding DisplayTitle}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center" Margin="4,0,0,0" />
<Button Grid.Column="1" Classes="title-ctrl"
<Button Grid.Column="1" Classes="btn" Margin="0,0,6,0"
IsVisible="{Binding IsTaskBased}"
Content="{loc:Tr missionControl.submitForReview}"
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
Command="{Binding SubmitForReviewCommand}" />
<Button Grid.Column="2" Classes="title-ctrl"
Command="{Binding CloseCommand}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
@@ -131,7 +131,7 @@
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.HeaderPath}"
VerticalAlignment="Center" TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
</Border>
@@ -413,6 +413,9 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{loc:Tr settings.skills.emptyState}"
IsVisible="{Binding SessionSkills.IsEmpty}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
+6 -6
View File
@@ -7,7 +7,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
```
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
@@ -20,7 +20,7 @@ Worker/
Logging/ — LogRingBuffer (30-min in-memory log window) + BroadcastLogSink (Serilog sink → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
@@ -32,13 +32,13 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
- **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>()`. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
- **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'." Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `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`, `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`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents` (class lives in `LifecycleMcpTools.cs`)
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
@@ -177,7 +177,7 @@ Loaded from `~/.todo-app/worker.config.json`:
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
- `poll_interval_seconds` (int, default 60)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Agents;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
+4 -2
View File
@@ -31,7 +31,8 @@ public sealed class ConfigMcpTools
[McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
int? maxTurns = null, CancellationToken cancellationToken = default)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
@@ -53,7 +54,8 @@ public sealed class ConfigMcpTools
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
int? maxTurns = null, CancellationToken cancellationToken = default)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -0,0 +1,32 @@
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
/// <summary>
/// The MCP SDK's own call-tool catch-all only preserves ex.Message for <see cref="McpException"/> —
/// any other exception type is replaced with a generic "An error occurred invoking 'X'." with no detail.
/// This filter translates the expected validation exceptions thrown across the External/ tool classes
/// (task/list not found, bad status, unknown model, etc.) into McpException so callers see why a call failed.
/// </summary>
public static class ExternalMcpExceptionFilter
{
public static McpRequestHandler<CallToolRequestParams, CallToolResult> Wrap(
McpRequestHandler<CallToolRequestParams, CallToolResult> next) =>
async (request, cancellationToken) =>
{
try
{
return await next(request, cancellationToken);
}
catch (InvalidOperationException ex)
{
throw new McpException(ex.Message, ex);
}
catch (ArgumentException ex)
{
throw new McpException(ex.Message, ex);
}
};
}
+74 -27
View File
@@ -6,6 +6,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
@@ -18,6 +19,7 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto(
@@ -72,6 +74,7 @@ public sealed class ExternalMcpService
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
public ExternalMcpService(
TaskRepository tasks,
@@ -82,7 +85,8 @@ public sealed class ExternalMcpService
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge)
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
{
_tasks = tasks;
_lists = lists;
@@ -93,6 +97,7 @@ public sealed class ExternalMcpService
_dbFactory = dbFactory;
_maintenance = maintenance;
_merge = merge;
_planningMerge = planningMerge;
}
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
@@ -107,9 +112,9 @@ public sealed class ExternalMcpService
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId,
string? createdBy,
string? status,
CancellationToken cancellationToken)
string? createdBy = null,
string? status = null,
CancellationToken cancellationToken = default)
{
TaskStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
@@ -193,10 +198,10 @@ public sealed class ExternalMcpService
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask(
string taskId,
string? title,
string? description,
string? commitType,
CancellationToken cancellationToken)
string? title = null,
string? description = null,
string? commitType = null,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -219,8 +224,8 @@ public sealed class ExternalMcpService
public async Task<TaskDto> AddSubtask(
string taskId,
string title,
int? orderNum,
CancellationToken cancellationToken)
int? orderNum = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
@@ -292,34 +297,74 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Review a task that is WaitingForReview. " +
"decision='approve' → Done. " +
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — resolve them in the ClaudeDo UI. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
public async Task<TaskDto> ReviewTask(
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
string decision,
string? feedback,
CancellationToken cancellationToken)
string? feedback = null,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult result = decision.Trim().ToLowerInvariant() switch
string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null;
if (decision.Trim().ToLowerInvariant() == "approve")
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
// Approve is the single review+merge action — mirror the hub's ApproveReview
// routing instead of only flipping the status (which left branches unmerged).
bool hasChildren;
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
if (hasChildren)
{
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
if (!parentDone)
mergeMessage = "unit merge paused on a conflict — resolve and continue it in the ClaudeDo UI";
}
else
{
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
mergeStatus = r.Status;
mergeConflicts = r.ConflictFiles;
mergeMessage = r.Status == TaskMergeService.StatusConflict
? "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI"
: r.ErrorMessage;
}
}
else
{
TransitionResult result = decision.Trim().ToLowerInvariant() switch
{
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
}
return new ReviewTaskResult(
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
@@ -357,6 +402,8 @@ public sealed class ExternalMcpService
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
await _tasks.DeleteAsync(taskId, cancellationToken);
if (task.ParentTaskId is not null)
await _state.TryAdvanceParentAsync(task.ParentTaskId);
await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId);
}
@@ -617,8 +664,8 @@ public sealed class ExternalMcpService
public async Task<TaskDto> SetMyDay(
string taskId,
bool isMyDay,
int? sortOrder,
CancellationToken cancellationToken)
int? sortOrder = null,
CancellationToken cancellationToken = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
-14
View File
@@ -1,25 +1,11 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
[McpServerToolType]
public sealed class LifecycleMcpTools
{
+3 -2
View File
@@ -22,7 +22,7 @@ public sealed class ListMcpTools
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
public async Task<ListSummaryDto> CreateList(
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("name is required.");
@@ -42,7 +42,8 @@ public sealed class ListMcpTools
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
public async Task<ListSummaryDto> UpdateList(
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
string listId, string? name = null, string? workingDir = null, string? commitType = null,
CancellationToken cancellationToken = default)
{
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
+70 -1
View File
@@ -130,6 +130,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
private readonly WorktreeManager? _worktreeManager;
public WorkerHub(
QueueService queue,
@@ -157,7 +158,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Runner.PendingQuestionRegistry pendingQuestions,
ISessionSkillRegistry skillRegistry,
LogRingBuffer? logBuffer = null,
IInteractiveLaunchSpecService? interactiveLaunchSpec = null)
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
WorktreeManager? worktreeManager = null)
{
_queue = queue;
_waker = waker;
@@ -185,6 +187,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_skillRegistry = skillRegistry;
_logBuffer = logBuffer;
_interactiveLaunchSpec = interactiveLaunchSpec;
_worktreeManager = worktreeManager;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -673,6 +676,72 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
});
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
// session is discarded (no children exist yet), mirroring StartPlanningSessionAsync.
public Task<LaunchSpec> GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
try
{
var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx);
await Clients.All.SendAsync("TaskUpdated", taskId);
return spec;
}
catch
{
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
throw;
}
});
// Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan
// --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window.
public Task<LaunchSpec> GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
return _interactiveLaunchSpec.BuildPlanningResume(ctx);
});
// Submits an interactively-worked task for review: commits whatever the ConPTY session left
// in the worktree (so there is a diff to merge), then transitions Idle/Failed -> WaitingForReview.
// The normal Approve flow then merges it. This is the only path that flips a hand-driven session
// into the review pipeline — a ConPTY session never touches task status on its own.
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
{
if (_worktreeManager is null)
throw new InvalidOperationException("Worktree manager is not configured.");
await using var ctx = await _dbFactory.CreateDbContextAsync();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
?? throw new KeyNotFoundException();
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
throw new InvalidOperationException("Task is already awaiting review.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
throw new InvalidOperationException("This task has no active worktree to submit.");
if (!Directory.Exists(worktree.Path))
throw new InvalidOperationException("The task's worktree directory no longer exists.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
?? throw new InvalidOperationException("Task list not found.");
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
});
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
{
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
@@ -115,7 +115,7 @@ public sealed class TaskMergeService
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, ct))
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
@@ -204,6 +204,26 @@ public sealed class TaskMergeService
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("repo is not mid-merge");
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
// its content, so an unresolved file with markers still in it would otherwise get
// staged (and committed) as-is. Check text content for markers first; binary files
// can't carry markers, so they're left to the post-stage index check below.
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var stillConflicted = new List<string>();
foreach (var path in unresolved)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { continue; }
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
stillConflicted.Add(path);
}
if (stillConflicted.Count > 0)
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
await _git.AddAllAsync(list.WorkingDir, ct);
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
@@ -92,14 +92,7 @@ public sealed class PlanningChainCoordinator
public async Task<string?> OnChildFinishedAsync(
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// The successor is whichever sibling explicitly blocks on this child.
var nextId = await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == childTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
var nextId = await FindSuccessorAsync(childTaskId, ct);
if (nextId is null) return null;
if (finalStatus == TaskStatus.Done)
@@ -108,10 +101,33 @@ public sealed class PlanningChainCoordinator
return nextId;
}
// Child failed or was cancelled: cancel the immediate successor so the chain
// is not left wedged. CancelAsync triggers OnChildTerminalAsync → OnChildFinishedAsync
// for that successor, cascading cancellation through the rest of the chain.
await _state().CancelAsync(nextId, DateTime.UtcNow, ct);
return null;
// Child failed or was cancelled: cancel the immediate successor so the chain is
// not left wedged. If it's cancellable, CancelAsync's own OnChildTerminalAsync
// callback recurses into this method for its successor, cascading cancellation
// through the rest of the chain. If it's not (e.g. it was parked back to Idle
// out of band), CancelAsync is a no-op and nothing will ever call back for it —
// keep walking the chain ourselves so the tail isn't left wedged forever.
var predecessorId = nextId;
while (true)
{
var result = await _state().CancelAsync(predecessorId, DateTime.UtcNow, ct);
if (result.Ok) return null;
var following = await FindSuccessorAsync(predecessorId, ct);
if (following is null) return null;
predecessorId = following;
}
}
// The successor is whichever sibling explicitly blocks on this task.
private async Task<string?> FindSuccessorAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
return await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == taskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => t.Id)
.FirstOrDefaultAsync(ct);
}
}
@@ -87,7 +87,7 @@ public sealed class PlanningMergeOrchestrator
if (await _git.IsMidMergeAsync(workingDir, ct))
throw new InvalidOperationException(
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
if (await _git.HasChangesAsync(workingDir, ct))
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
throw new InvalidOperationException("working tree has uncommitted changes");
var idsToMerge = new List<string>();
@@ -74,11 +74,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
var resolvedWt = ResolveWtOrThrow();
var resolvedClaude = ResolveClaudeOrThrow();
var command = BuildPwshCommand(resolvedClaude, new[]
{
"--permission-mode", "plan",
"--resume", ctx.ClaudeSessionId,
});
var command = BuildPwshCommand(resolvedClaude, BuildPlanningResumeArgs(ctx.ClaudeSessionId));
StartInWindowsTerminal(resolvedWt, ctx.WorkingDir, command, env =>
{
@@ -103,8 +99,8 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
return Task.CompletedTask;
}
// Resumes a session by id in default (interactive) permission mode: the user drives
// tool approvals in the terminal, unlike planning which pins --permission-mode plan.
// Resumes a session by id with no extra flags: the user drives every tool approval in the
// terminal, unlike planning which additionally allowlists its MCP planning tools.
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId));
@@ -117,23 +113,42 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
// Arg order matters: variadic flags (--allowedTools, --add-dir) come first; the
// single-line kickoff prompt is positional, so it must follow a single-value flag
// (--append-system-prompt-file) or a variadic flag would swallow it.
internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx)
internal static string BuildPlanningStartCommand(string claudePath, PlanningSessionStartContext ctx) =>
BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
// The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
// planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
// a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir)
// come first; the single-line kickoff prompt is positional, so it must follow a single-value
// flag (--append-system-prompt-file) or a variadic flag would swallow it.
internal static IReadOnlyList<string> BuildPlanningStartArgs(PlanningSessionStartContext ctx)
{
var kickoff =
$"Read the file {ctx.Files.InitialPromptPath} first. It contains the task you must plan. " +
"After reading it, begin the planning session as your instructions describe.";
return BuildPwshCommand(claudePath, new[]
return new[]
{
"--model", Model,
"--permission-mode", "plan",
// NOT --permission-mode plan: plan mode gates EVERY MCP tool call regardless of
// --allowedTools, so the session would prompt the user on the first CreateChildTask
// (the whole point of a planning session). Default mode honours the allowlist below,
// and file edits stay blocked anyway — the planning system prompt forbids them and
// AllowedTools omits Write/Edit/Bash (an unexpected edit would prompt, not run silently).
"--permission-mode", "default",
"--allowedTools", AllowedTools,
"--add-dir", ctx.Files.SessionDirectory,
"--append-system-prompt-file", ctx.Files.SystemPromptPath,
kickoff,
});
};
}
// The raw claude CLI args for an interactive planning RESUME, shared with the embedded-ConPTY
// planning path. Re-passes the planning allowlist in default mode (not --resume alone): the CLI
// does not restore --allowedTools across a resume, and plan mode would gate the MCP tools.
internal static IReadOnlyList<string> BuildPlanningResumeArgs(string claudeSessionId) =>
new[] { "--permission-mode", "default", "--allowedTools", AllowedTools, "--resume", claudeSessionId };
private string ResolveWtOrThrow() =>
Resolve(_wtPath) ?? throw new TerminalLaunchException($"Windows Terminal not found: {_wtPath}");
+4
View File
@@ -92,6 +92,7 @@ builder.Services.AddSingleton<PlanningChainCoordinator>();
builder.Services.AddSingleton<QueueWaker>();
builder.Services.AddSingleton<IQueueWaker>(sp => sp.GetRequiredService<QueueWaker>());
builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
builder.Services.AddSingleton<RunCancellationRegistry>();
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
@@ -99,6 +100,7 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
sp.GetRequiredService<HubBroadcaster>(),
sp.GetRequiredService<IQueueWaker>(),
sp.GetRequiredService<PlanningChainCoordinator>(),
sp.GetRequiredService<RunCancellationRegistry>(),
sp.GetRequiredService<ILogger<TaskStateService>>()));
// Agent file management.
@@ -269,6 +271,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddScoped<BatchMcpTools>();
externalBuilder.Services.AddScoped<ListMcpTools>();
@@ -282,6 +285,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
.WithTools<ExternalMcpService>()
.WithTools<BatchMcpTools>()
.WithTools<ListMcpTools>()
@@ -10,6 +10,7 @@ public sealed class OverrideSlotService
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly TaskRunner _runner;
private readonly ILogger<OverrideSlotService> _logger;
private readonly RunCancellationRegistry _runCancels;
private readonly object _lock = new();
private volatile QueueSlotState? _slot;
@@ -17,11 +18,13 @@ public sealed class OverrideSlotService
public OverrideSlotService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskRunner runner,
ILogger<OverrideSlotService> logger)
ILogger<OverrideSlotService> logger,
RunCancellationRegistry runCancels)
{
_dbFactory = dbFactory;
_runner = runner;
_logger = logger;
_runCancels = runCancels;
}
public QueueSlotState? CurrentSlot => _slot;
@@ -66,12 +69,14 @@ public sealed class OverrideSlotService
var cts = new CancellationTokenSource();
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_runCancels.Register(taskId, cts);
_ = work(cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, faultMessage, taskId);
lock (_lock) { _slot = null; }
_runCancels.Unregister(taskId, cts);
cts.Dispose();
}, TaskScheduler.Default);
}
+6 -1
View File
@@ -19,6 +19,7 @@ public sealed class QueueService : BackgroundService
private readonly IQueuePicker _picker;
private readonly OverrideSlotService _override;
private readonly ITaskStateService _state;
private readonly RunCancellationRegistry _runCancels;
private readonly object _lock = new();
private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
@@ -31,7 +32,8 @@ public sealed class QueueService : BackgroundService
QueueWaker waker,
IQueuePicker picker,
OverrideSlotService overrideSlot,
ITaskStateService state)
ITaskStateService state,
RunCancellationRegistry runCancels)
{
_dbFactory = dbFactory;
_runner = runner;
@@ -41,6 +43,7 @@ public sealed class QueueService : BackgroundService
_picker = picker;
_override = overrideSlot;
_state = state;
_runCancels = runCancels;
}
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
@@ -125,12 +128,14 @@ public sealed class QueueService : BackgroundService
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_runCancels.Register(task.Id, cts);
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
lock (_lock) { _queueSlots.Remove(task.Id); }
_runCancels.Unregister(task.Id, cts);
cts.Dispose();
_waker.Wake(); // Check for next task immediately.
}, TaskScheduler.Default);
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
namespace ClaudeDo.Worker.Queue;
/// Maps a running task id to the CancellationTokenSource driving its Claude process.
/// QueueService and OverrideSlotService register their slots here so components that
/// cancel tasks DB-side (TaskStateService.CancelAsync and its child cascade) can also
/// stop the process — without depending on the queue services, which would create a
/// DI cycle (QueueService → TaskRunner → ITaskStateService).
public sealed class RunCancellationRegistry
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _running = new(StringComparer.Ordinal);
public void Register(string taskId, CancellationTokenSource cts) => _running[taskId] = cts;
/// Removes the registration only if <paramref name="cts"/> is still the registered
/// one — a re-run may already have registered a newer CTS under the same task id.
public void Unregister(string taskId, CancellationTokenSource cts) =>
_running.TryRemove(new KeyValuePair<string, CancellationTokenSource>(taskId, cts));
public bool TryCancel(string taskId)
{
if (!_running.TryGetValue(taskId, out var cts)) return false;
try
{
cts.Cancel();
return true;
}
catch (ObjectDisposedException)
{
// Slot cleanup raced us; the run is already finished.
return false;
}
}
}
@@ -107,6 +107,40 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(worktree.Path, resolvedClaude, args, env);
}
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
{
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
// Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session
// planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to
// the UI process env at spawn time (see PtyTerminalSession) — process-global by design.
var env = new Dictionary<string, string>
{
["MAX_THINKING_TOKENS"] = "20000",
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env);
}
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
{
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var env = new Dictionary<string, string>
{
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env);
}
public Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
{
if (!Directory.Exists(directory))
@@ -1,7 +1,18 @@
using ClaudeDo.Worker.Planning;
namespace ClaudeDo.Worker.Runner;
public interface IInteractiveLaunchSpecService
{
/// <summary>Maps an already-prepared planning START context (worktree + prompt files + token,
/// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
/// planning session — same planning CLI args as the wt launcher, planning env carried in Env.</summary>
LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx);
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx);
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
+23 -5
View File
@@ -91,16 +91,34 @@ public sealed class StreamAnalyzer
private string? FallbackResult()
{
// A --json-schema run sometimes echoes the structured JSON as the result text; unwrap
// that to its summary. A plain markdown result is kept verbatim (even when a separate
// structured_output is also present). Only when there is no result text do we fall back
// to the structured summary, then the raw JSON.
if (SummaryFromJson(_resultMarkdown) is { } fromResult) return fromResult;
if (!string.IsNullOrEmpty(_resultMarkdown)) return _resultMarkdown;
if (_structuredOutputJson is null) return _resultMarkdown;
if (SummaryFromJson(_structuredOutputJson) is { } fromStructured) return fromStructured;
return _structuredOutputJson;
}
/// <summary>Extracts a non-empty string <c>summary</c> from a JSON object, or null when
/// the input isn't such an object (a plain markdown result parses to null and is kept).</summary>
private static string? SummaryFromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(_structuredOutputJson);
if (doc.RootElement.TryGetProperty("summary", out var s))
return s.GetString();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("summary", out var s) &&
s.ValueKind == JsonValueKind.String)
{
var text = s.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
catch { }
return _structuredOutputJson;
return null;
}
private void CollectFromAssistant(JsonElement root)
@@ -6,6 +6,7 @@ public interface ITaskStateService
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
@@ -24,5 +25,10 @@ public interface ITaskStateService
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
// Surfaces a WaitingForChildren parent for review once all its children are terminal.
// Best-effort (swallows and logs failures) — safe to call after any child mutation,
// e.g. deleting the last non-terminal child (no terminal transition fires for a delete).
Task TryAdvanceParentAsync(string parentId);
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
}
+63 -5
View File
@@ -15,6 +15,7 @@ public sealed class TaskStateService : ITaskStateService
private readonly HubBroadcaster _broadcaster;
private readonly IQueueWaker _waker;
private readonly PlanningChainCoordinator _chain;
private readonly RunCancellationRegistry _runCancels;
private readonly ILogger<TaskStateService> _logger;
public TaskStateService(
@@ -22,12 +23,14 @@ public sealed class TaskStateService : ITaskStateService
HubBroadcaster broadcaster,
IQueueWaker waker,
PlanningChainCoordinator chain,
RunCancellationRegistry runCancels,
ILogger<TaskStateService> logger)
{
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_waker = waker;
_chain = chain;
_runCancels = runCancels;
_logger = logger;
}
@@ -107,6 +110,26 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
// Submit an interactively-worked task (a ConPTY session left its worktree with commits/changes)
// for review. Unlike SubmitForReviewAsync — which only fires from the headless Running state —
// this transitions from Idle or Failed, the states an interactive task sits in after the user
// finishes the session by hand. The caller commits the worktree first so there is a diff to merge.
public async Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && (t.Status == TaskStatus.Idle || t.Status == TaskStatus.Failed))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
if (affected == 0)
return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review.");
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -220,6 +243,7 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct)
{
List<string> cancelledChildIds;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var affected = await ctx.Tasks
@@ -233,10 +257,38 @@ public sealed class TaskStateService : ITaskStateService
if (affected == 0)
return new TransitionResult(false, "Task not in cancellable state.");
// Cascade to this task's own non-terminal children (one level — children are
// leaves in the current model) so a Queued/Running child doesn't keep going
// against a parent that's already gone, orphaning its worktree commit.
cancelledChildIds = await ctx.Tasks
.Where(t => t.ParentTaskId == taskId
&& t.Status != TaskStatus.Done
&& t.Status != TaskStatus.Failed
&& t.Status != TaskStatus.Cancelled)
.Select(t => t.Id)
.ToListAsync(ct);
if (cancelledChildIds.Count > 0)
await ctx.Tasks
.Where(t => cancelledChildIds.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Cancelled)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
}
// Also stop the actual Claude processes: the DB flip above doesn't reach a run
// already executing in a queue/override slot — without this it would keep going
// and only fail its final (now-invalid) transition.
_runCancels.TryCancel(taskId);
foreach (var childId in cancelledChildIds)
_runCancels.TryCancel(childId);
await OnChildTerminalAsync(taskId, TaskStatus.Cancelled);
await _broadcaster.TaskUpdated(taskId);
foreach (var childId in cancelledChildIds)
await _broadcaster.TaskUpdated(childId);
return new TransitionResult(true, null);
}
@@ -395,9 +447,18 @@ public sealed class TaskStateService : ITaskStateService
_logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", taskId);
}
await TryAdvanceParentAsync(parentId);
}
// Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review
// once every child is terminal (Done/Failed/Cancelled). A failed or cancelled child does
// not wedge the parent — it is flagged on the result. Also called directly after a child
// is deleted, since no terminal transition fires in that case.
public async Task TryAdvanceParentAsync(string parentId)
{
try
{
await TryAdvanceParentAsync(parentId);
await AdvanceParentIfAllChildrenTerminalAsync(parentId);
}
catch (Exception ex)
{
@@ -405,10 +466,7 @@ public sealed class TaskStateService : ITaskStateService
}
}
// Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review
// once every child is terminal (Done/Failed/Cancelled). A failed or cancelled child does
// not wedge the parent — it is flagged on the result.
private async Task TryAdvanceParentAsync(string parentId)
private async Task AdvanceParentIfAllChildrenTerminalAsync(string parentId)
{
string? parentResult;
List<TaskStatus> childStatuses;
@@ -95,10 +95,15 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
@@ -98,4 +98,43 @@ public class DetailsIslandReviewActionsTests : IDisposable
vm.ReviewFeedback = " ";
Assert.False(vm.RejectReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsEnabled_WhenThereIsNothingToReview()
{
// A childless sandbox run has no worktree diff to inspect, so the gate must
// not block it — it approves straight through.
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-nodiff-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.False(vm.Merge.HasReviewableDiff);
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsGatedUntilDiffOpened_AndReLocksOnNewRun()
{
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-diff-1", Status = TaskStatus.WaitingForReview });
vm.Merge.SyncWorktree("/tmp/wt", null, null, "Active", null);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
// There is a diff to read, but it has not been opened → merge is blocked.
Assert.True(vm.Merge.HasReviewableDiff);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
// Opening the diff records the inspection and unlocks the merge.
vm.Merge.DiffViewed?.Invoke();
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
// A new run means a fresh diff — the gate re-engages.
vm.Monitor.ApplyState(TaskStatus.Running);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
}
}
@@ -106,4 +106,72 @@ public class UnifiedDiffParserTests
Assert.False(file.HasLines);
Assert.True(file.IsEmptyContent);
}
[Fact]
public void Path_with_spaces_is_parsed_in_full()
{
const string raw =
"diff --git a/pfad mit space.txt b/pfad mit space.txt\n" +
"index 111..222 100644\n" +
"--- a/pfad mit space.txt\t\n" +
"+++ b/pfad mit space.txt\t\n" +
"@@ -1,1 +1,1 @@\n" +
"-old\n" +
"+new\n";
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
Assert.Equal("pfad mit space.txt", file.Path);
Assert.Equal(DiffFileStatus.Modified, file.Status);
}
[Fact]
public void Rename_with_spaces_on_both_sides_captures_full_paths()
{
const string raw =
"diff --git a/old name with space.txt b/new name with space.txt\n" +
"similarity index 75%\n" +
"rename from old name with space.txt\n" +
"rename to new name with space.txt\n" +
"index 111..222 100644\n" +
"--- a/old name with space.txt\t\n" +
"+++ b/new name with space.txt\t\n";
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
Assert.Equal(DiffFileStatus.Renamed, file.Status);
Assert.Equal("old name with space.txt", file.OldPath);
Assert.Equal("new name with space.txt", file.Path);
}
[Fact]
public void Git_quoted_unicode_path_is_unquoted()
{
// git quotes paths containing non-ASCII bytes as C-style octal escapes,
// e.g. "pfad_ä.txt" -> "pfad_\303\244.txt" (UTF-8 bytes for 'ä').
const string raw =
"diff --git \"a/pfad_\\303\\244.txt\" \"b/pfad_\\303\\244.txt\"\n" +
"index 111..222 100644\n" +
"--- \"a/pfad_\\303\\244.txt\"\n" +
"+++ \"b/pfad_\\303\\244.txt\"\n" +
"@@ -1,1 +1,1 @@\n" +
"-old\n" +
"+new\n";
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
Assert.Equal("pfad_ä.txt", file.Path);
}
[Fact]
public void Binary_file_with_spaces_is_parsed_in_full()
{
const string raw =
"diff --git a/bin file.png b/bin file.png\n" +
"new file mode 100644\n" +
"index 000..abc\n" +
"Binary files /dev/null and b/bin file.png differ\n";
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
Assert.Equal("bin file.png", file.Path);
Assert.True(file.IsBinary);
Assert.False(file.HasLines);
}
}
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -72,14 +73,18 @@ public sealed class AddSubtaskToolTests : IDisposable
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, TaskStateServiceBuilder.Build(dbFactory).State, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, broadcaster,
state,
git, dbFactory, maintenance, merge);
git, dbFactory, maintenance, merge, planningMerge);
}
[Fact]
+11 -5
View File
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -64,11 +65,15 @@ public sealed class BatchMcpToolsTests : IDisposable
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
var svc = new ExternalMcpService(
_tasks, _lists, CreateQueue(), _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
state,
git, factory, maintenance, merge, planningMerge);
return new BatchMcpTools(svc);
}
@@ -88,9 +93,10 @@ public sealed class BatchMcpToolsTests : IDisposable
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state);
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels);
}
[Fact]
@@ -0,0 +1,48 @@
using ClaudeDo.Worker.External;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ExternalMcpExceptionFilterTests
{
[Fact]
public async Task Wrap_TranslatesInvalidOperationException_PreservingMessage()
{
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => throw new InvalidOperationException("Task abc123 not found.");
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var ex = await Assert.ThrowsAsync<McpException>(
() => wrapped(null!, CancellationToken.None).AsTask());
Assert.Equal("Task abc123 not found.", ex.Message);
}
[Fact]
public async Task Wrap_TranslatesArgumentException_PreservingMessage()
{
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => throw new ArgumentException("Unknown model alias 'gpt4'.");
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var ex = await Assert.ThrowsAsync<McpException>(
() => wrapped(null!, CancellationToken.None).AsTask());
Assert.Equal("Unknown model alias 'gpt4'.", ex.Message);
}
[Fact]
public async Task Wrap_PassesThroughSuccessfulResult()
{
var expected = new CallToolResult();
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => ValueTask.FromResult(expected);
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var result = await wrapped(null!, CancellationToken.None);
Assert.Same(expected, result);
}
}
@@ -6,6 +6,7 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -104,7 +105,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
return id;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle)
private async Task<TaskEntity> SeedTaskAsync(
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
{
var task = new TaskEntity
{
@@ -112,6 +114,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
ListId = listId,
Title = title,
Status = status,
ParentTaskId = parentId,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
@@ -124,11 +127,15 @@ public sealed class ExternalMcpServiceTests : IDisposable
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
var state = TaskStateServiceBuilder.Build(factory).State;
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var planningMerge = new PlanningMergeOrchestrator(
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
state,
git, factory, maintenance, merge, planningMerge);
}
private QueueService CreateQueue()
@@ -152,8 +159,9 @@ public sealed class ExternalMcpServiceTests : IDisposable
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
var runCancels = new RunCancellationRegistry();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
}
[Fact]
@@ -208,9 +216,10 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "approve", null, CancellationToken.None);
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None);
Assert.Equal("Done", dto.Status);
Assert.Equal("Done", result.Task.Status);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
}
[Fact]
@@ -221,7 +230,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "reject_rerun", null, CancellationToken.None));
sut.ReviewTask(task.Id, "reject_rerun", null, null, CancellationToken.None));
}
[Fact]
@@ -231,9 +240,9 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", CancellationToken.None);
var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, CancellationToken.None);
Assert.Equal("Queued", dto.Status);
Assert.Equal("Queued", result.Task.Status);
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal("fix it", loaded!.ReviewFeedback);
}
@@ -246,7 +255,79 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None));
sut.ReviewTask(task.Id, "bogus", null, null, CancellationToken.None));
}
[Fact]
public async Task ReviewTask_Approve_WithActiveWorktree_MergesIntoTargetBranch()
{
if (!GitAvailable) return;
var (task, list, wt) = await SeedWorktreeAsync(status: TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "feature.txt");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "add feature");
using (var ctx = _db.CreateContext())
{
var row = ctx.Worktrees.Single(w => w.TaskId == task.Id);
row.HeadCommit = GitRepoFixture.RunGit(wt.WorktreePath, "rev-parse", "HEAD").Trim();
ctx.SaveChanges();
}
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "feature.txt")));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == task.Id).State);
}
[Fact]
public async Task ReviewTask_Approve_ParentWithChildren_RunsUnitMerge()
{
if (!GitAvailable) return;
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{childId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "child.txt"), "c\n");
GitRepoFixture.RunGit(wtPath, "add", "child.txt");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "add child.txt");
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = childId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(parentId, "approve", null, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
}
[Fact]
@@ -284,6 +365,21 @@ public sealed class ExternalMcpServiceTests : IDisposable
sut.DeleteTask("does-not-exist", CancellationToken.None));
}
[Fact]
public async Task DeleteTask_LastNonTerminalChild_AdvancesParentToWaitingForReview()
{
var listId = await SeedListAsync();
var parent = await SeedTaskAsync(listId, status: TaskStatus.WaitingForChildren);
await SeedTaskAsync(listId, status: TaskStatus.Done, parentId: parent.Id);
var lastChild = await SeedTaskAsync(listId, status: TaskStatus.Idle, parentId: parent.Id);
var sut = BuildSut(CreateQueue());
await sut.DeleteTask(lastChild.Id, CancellationToken.None);
var reloadedParent = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
}
private ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t")
@@ -0,0 +1,74 @@
using System.Reflection;
using ClaudeDo.Worker.External;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.Tests.External;
/// <summary>
/// MCP clients routinely omit optional arguments. The generated tool schema only marks a
/// parameter optional when the C# method declares a default value — nullability alone is not
/// enough (verified against ModelContextProtocol/Microsoft.Extensions.AI 1.2.0 / 10.4.1's
/// AIJsonUtilities.CreateFunctionJsonSchema, which checks ParameterInfo.IsOptional). This sweeps
/// every [McpServerToolType] class in the External/ namespace so a future tool can't reintroduce
/// a nullable-but-required filter parameter.
/// </summary>
public sealed class ExternalMcpToolSchemaTests
{
private static IEnumerable<MethodInfo> ExternalToolMethods()
{
var toolTypes = typeof(ExternalMcpService).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ExternalMcpService).Namespace
&& t.GetCustomAttribute<McpServerToolTypeAttribute>() is not null);
foreach (var type in toolTypes)
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (method.GetCustomAttribute<McpServerToolAttribute>() is not null)
yield return method;
}
}
[Fact]
public void NoExternalTool_HasARequiredNullableParameter()
{
var nullabilityContext = new NullabilityInfoContext();
var violations = new List<string>();
foreach (var method in ExternalToolMethods())
{
var schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
var required = schema.TryGetProperty("required", out var requiredElement)
? requiredElement.EnumerateArray().Select(e => e.GetString()).ToHashSet()
: new HashSet<string?>();
foreach (var parameter in method.GetParameters())
{
if (parameter.ParameterType == typeof(CancellationToken)) continue;
if (!required.Contains(parameter.Name)) continue;
var isNullableValueType = Nullable.GetUnderlyingType(parameter.ParameterType) is not null;
var isNullableRefType = !parameter.ParameterType.IsValueType
&& nullabilityContext.Create(parameter).WriteState == NullabilityState.Nullable;
if (isNullableValueType || isNullableRefType)
{
violations.Add(
$"{method.DeclaringType!.Name}.{method.Name}({parameter.Name}) is a nullable " +
"type but has no default value, so MCP clients omitting it will fail. " +
"Give it a default value (e.g. '= null').");
}
}
}
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
}
[Fact]
public void ExternalToolMethods_AreDiscovered()
{
// Guards the sweep itself: if this drops to 0, ExternalToolMethods() broke silently
// (e.g. namespace/attribute mismatch) and the schema test above would pass vacuously.
Assert.True(ExternalToolMethods().Count() > 20);
}
}
@@ -17,13 +17,15 @@ public static class TaskStateServiceBuilder
PlanningChainCoordinator Chain,
CapturingHubContext Hub,
Func<int> WakeCount,
CountingQueueWaker Waker);
CountingQueueWaker Waker,
RunCancellationRegistry RunCancels);
public static Built Build(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
var hub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(hub);
var waker = new CountingQueueWaker();
var runCancels = new RunCancellationRegistry();
TaskStateService? state = null;
var chain = new PlanningChainCoordinator(dbFactory, () => state!);
@@ -32,9 +34,10 @@ public static class TaskStateServiceBuilder
broadcaster,
waker,
chain,
runCancels,
NullLogger<TaskStateService>.Instance);
return new Built(state, chain, hub, () => waker.Count, waker);
return new Built(state, chain, hub, () => waker.Count, waker, runCancels);
}
}
@@ -178,6 +178,37 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
}
[Fact]
public async Task OnChildFailed_MidChain_IdleSuccessor_DoesNotWedgeTail()
{
// Chain: c0 → c1 → c2 → c3. c1 is parked back to Idle (e.g. a manual reset)
// while still wired into the chain (BlockedByTaskId = c0). c0 then fails:
// CancelAsync(c1) is a no-op because Idle isn't a cancellable state, so the
// cascade must keep walking past c1 instead of leaving c2/c3 wedged forever.
await SeedPlanningFamilyAsync("P", 4);
await _sut.SetupChainAsync("P", enqueue: true, default);
await using (var ctx = _factory.CreateDbContext())
{
var c0 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
c0.Status = TaskStatus.Failed;
var c1 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c1");
c1.Status = TaskStatus.Idle;
await ctx.SaveChangesAsync();
}
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await GetChildrenAsync("P");
Assert.Equal(TaskStatus.Failed, kids[0].Status);
// c1 was already parked Idle; the coordinator must not resurrect it.
Assert.Equal(TaskStatus.Idle, kids[1].Status);
// c2/c3 must not stay wedged as Queued+blocked forever.
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
}
[Fact]
public async Task OnChildDone_LastChild_ReturnsNull()
{
@@ -348,7 +348,8 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
File.WriteAllText(Path.Combine(repo.RepoDir, "dirty.txt"), "unstaged\n");
// Modify a tracked file (real uncommitted change).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "unstaged\n");
var (orch, _) = BuildOrchestrator(db);
@@ -357,6 +358,27 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.Contains("uncommitted", ex.Message);
}
[Fact]
public async Task StartAsync_UntrackedFileInRepo_DoesNotThrow()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, subA, subB) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (orch, _) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None);
using var ctx = db.CreateContext();
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
}
[Fact]
public async Task StartAsync_IdempotentRestart_SkipsAlreadyMergedWorktrees()
{
@@ -70,6 +70,39 @@ public sealed class WindowsTerminalLauncherTests
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
}
[Fact]
public void BuildPlanningStartArgs_HasPlanningFlagsAndKickoffLast()
{
var ctx = MakeStartCtx();
var args = WindowsTerminalLauncher.BuildPlanningStartArgs(ctx);
Assert.Equal("--model", args[0]);
var permIdx = args.ToList().IndexOf("--permission-mode");
Assert.True(permIdx >= 0);
// Default mode, NOT plan mode: plan mode gates every MCP tool call regardless of the
// allowlist, which would prompt on the first CreateChildTask.
Assert.Equal("default", args[permIdx + 1]);
Assert.Contains("--allowedTools", args);
Assert.Contains(ctx.Files.SessionDirectory, args);
Assert.Contains(ctx.Files.SystemPromptPath, args);
// Kickoff positional is the last token and points at the brief file.
Assert.Contains(ctx.Files.InitialPromptPath, args[^1]);
}
[Fact]
public void BuildPlanningResumeArgs_DefaultModeAllowlistsMcpAndResumes()
{
var args = WindowsTerminalLauncher.BuildPlanningResumeArgs("sess-9");
Assert.Equal("--permission-mode", args[0]);
Assert.Equal("default", args[1]);
Assert.Equal("--allowedTools", args[2]);
// The MCP planning tools must be re-allowlisted on resume: the CLI does not restore
// --allowedTools across a --resume, so without it CreateChildTask would prompt again.
Assert.Contains("mcp__claudedo__", args[3]);
Assert.Equal(new[] { "--resume", "sess-9" }, args.Skip(4).ToArray());
}
[Fact]
public void BuildResumeCommand_ResumesSessionSingleQuoted()
{
@@ -0,0 +1,51 @@
using ClaudeDo.Worker.Queue;
namespace ClaudeDo.Worker.Tests.Queue;
public sealed class RunCancellationRegistryTests
{
[Fact]
public void TryCancel_RegisteredTask_CancelsAndReturnsTrue()
{
var sut = new RunCancellationRegistry();
using var cts = new CancellationTokenSource();
sut.Register("t1", cts);
Assert.True(sut.TryCancel("t1"));
Assert.True(cts.IsCancellationRequested);
}
[Fact]
public void TryCancel_UnknownTask_ReturnsFalse()
{
var sut = new RunCancellationRegistry();
Assert.False(sut.TryCancel("nope"));
}
[Fact]
public void Unregister_RemovesOnlyTheGivenRegistration()
{
var sut = new RunCancellationRegistry();
using var stale = new CancellationTokenSource();
using var current = new CancellationTokenSource();
sut.Register("t1", stale);
sut.Register("t1", current); // re-run replaced the registration
sut.Unregister("t1", stale); // late cleanup of the old slot must not evict the new one
Assert.True(sut.TryCancel("t1"));
Assert.True(current.IsCancellationRequested);
Assert.False(stale.IsCancellationRequested);
}
[Fact]
public void TryCancel_DisposedCts_ReturnsFalse()
{
var sut = new RunCancellationRegistry();
var cts = new CancellationTokenSource();
sut.Register("t1", cts);
cts.Dispose();
Assert.False(sut.TryCancel("t1"));
}
}
@@ -168,6 +168,39 @@ public class GitServiceMergeTests : IDisposable
Assert.Equal("feature/checkout-test", current);
}
[Fact]
public async Task HasChangesAsync_UntrackedFileOnly_DefaultReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "new\n");
var git = new GitService();
Assert.True(await git.HasChangesAsync(repo.RepoDir));
}
[Fact]
public async Task HasChangesAsync_UntrackedFileOnly_IncludeUntrackedFalse_ReturnsFalse()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "new\n");
var git = new GitService();
Assert.False(await git.HasChangesAsync(repo.RepoDir, includeUntracked: false));
}
[Fact]
public async Task HasChangesAsync_ModifiedTrackedFile_IncludeUntrackedFalse_StillReturnsTrue()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed\n");
var git = new GitService();
Assert.True(await git.HasChangesAsync(repo.RepoDir, includeUntracked: false));
}
[Fact]
public async Task ListConflictedFilesAsync_MidConflict_ReturnsConflictedFile()
{
@@ -2,6 +2,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -342,4 +343,45 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
}
[Fact]
public void BuildPlanningStart_MapsPlanningArgsAndEnv()
{
var sessionDir = Path.Combine(_tempDir, "sess");
Directory.CreateDirectory(sessionDir);
var ctx = new PlanningSessionStartContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir, Token: "tok-1",
WorktreePath: _worktreeDir, BranchName: "claudedo/planning/p1",
Files: new PlanningSessionFiles(sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt")));
var spec = BuildService().BuildPlanningStart(ctx);
Assert.Equal(_worktreeDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Contains("--permission-mode", spec.Args);
// Default mode, not plan mode -- plan mode would gate the MCP planning tools.
Assert.Contains("default", spec.Args);
Assert.Equal("tok-1", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal("20000", spec.Env["MAX_THINKING_TOKENS"]);
}
[Fact]
public void BuildPlanningResume_MapsResumeArgsAndToken()
{
var ctx = new PlanningSessionResumeContext(
ParentTaskId: "p1", WorkingDir: _worktreeDir,
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var spec = BuildService().BuildPlanningResume(ctx);
Assert.Equal("--permission-mode", spec.Args[0]);
Assert.Equal("default", spec.Args[1]);
Assert.Equal("--allowedTools", spec.Args[2]);
Assert.Contains("mcp__claudedo__", spec.Args[3]);
Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray());
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal(_worktreeDir, spec.Cwd);
}
}
@@ -52,13 +52,14 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var built = TaskStateServiceBuilder.Build(dbFactory);
var state = built.State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
_waker = new QueueWaker();
var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels);
return (service, fake);
}
@@ -53,13 +53,14 @@ public sealed class QueueServiceTests : IDisposable
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var built = TaskStateServiceBuilder.Build(dbFactory);
var state = built.State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder());
_waker = new QueueWaker();
var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state, built.RunCancels);
return (service, fake);
}
@@ -302,8 +302,8 @@ public class TaskMergeServiceTests : IDisposable
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
// Dirty the target working dir.
File.WriteAllText(Path.Combine(repo.RepoDir, "dirt.txt"), "dirty\n");
// Modify a tracked file in the target working dir (real uncommitted change).
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
@@ -312,6 +312,33 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
}
[Fact]
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
{
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, "feature.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
// An untracked file (e.g. left behind by a concurrent session) must not block the merge.
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
}
[Fact]
public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
{
@@ -410,6 +437,52 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(WorktreeState.Merged, wt.State);
}
[Fact]
public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t4", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
await SeedWorktree(db, task.Id, wtPath, "claudedo/t4", repo.BaseCommit);
var (svc, _) = BuildService(db);
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
// User never resolves the conflict — README.md still contains "<<<<<<<" markers.
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
// No commit must have happened, and the repo must still be mid-merge so Abort can clean it up.
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
var abort = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusAborted, abort.Status);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
}
[Fact]
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
{
@@ -87,6 +87,38 @@ public sealed class ReviewTransitionTests : IDisposable
Assert.Equal(TaskStatus.Queued, (await GetTaskAsync(id)).Status);
}
// ─── SubmitInteractiveForReviewAsync ──────────────────────────────────
[Theory]
[InlineData(TaskStatus.Idle)]
[InlineData(TaskStatus.Failed)]
public async Task SubmitInteractiveForReviewAsync_FromIdleOrFailed_TransitionsToWaitingForReview(TaskStatus from)
{
var id = await SeedTaskAsync(from);
var result = await _sut.SubmitInteractiveForReviewAsync(id, DateTime.UtcNow, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.WaitingForReview, t.Status);
Assert.NotNull(t.FinishedAt);
}
[Theory]
[InlineData(TaskStatus.Running)]
[InlineData(TaskStatus.Queued)]
[InlineData(TaskStatus.Done)]
[InlineData(TaskStatus.WaitingForReview)]
public async Task SubmitInteractiveForReviewAsync_FromOtherStates_Rejects(TaskStatus from)
{
var id = await SeedTaskAsync(from);
var result = await _sut.SubmitInteractiveForReviewAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Equal(from, (await GetTaskAsync(id)).Status);
}
// ─── ApproveReviewAsync ───────────────────────────────────────────────
[Fact]
@@ -72,10 +72,17 @@ sealed class FakeWorkerClient : IWorkerClient
public int PickUpInTerminalCalls { get; private set; }
public string? LastPickUpTaskId { get; private set; }
public Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) { PickUpInTerminalCalls++; LastPickUpTaskId = taskId; return Task.CompletedTask; }
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public int PlanningStartSpecCalls { get; private set; }
public int PlanningResumeSpecCalls { get; private set; }
public Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
public Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
@@ -224,25 +231,29 @@ public class TasksIslandViewModelPlanningTests
}
[Fact]
public async Task OpenPlanningSession_IgnoresNonIdleRow()
public void OpenPlanningSession_IgnoresNonIdleRow()
{
var row = MakeRow("t1", TaskStatus.Queued);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(0, worker.StartPlanningCalls);
Assert.Null(raised);
}
[Fact]
public async Task OpenPlanningSession_CallsWorkerForIdleRow()
public void OpenPlanningSession_RequestsConPtyPaneForIdleRow()
{
var row = MakeRow("t1", TaskStatus.Idle);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(1, worker.StartPlanningCalls);
Assert.Equal(("t1", false), raised);
}
[Fact]
@@ -183,6 +183,72 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable
}
}
[Fact]
public async Task Cancelling_WaitingForChildren_parent_cascades_to_nonterminal_children()
{
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "c_running", ListId = "l1", Title = "Running child",
Status = TaskStatus.Running, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "c_queued", ListId = "l1", Title = "Queued child",
Status = TaskStatus.Queued, ParentTaskId = "par", BlockedByTaskId = "c_running",
CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var result = await _built.State.CancelAsync("par", DateTime.UtcNow, default);
Assert.True(result.Ok);
using var ctx2 = _db.CreateContext();
var repo = new TaskRepository(ctx2);
var parent = await repo.GetByIdAsync("par");
var runningChild = await repo.GetByIdAsync("c_running");
var queuedChild = await repo.GetByIdAsync("c_queued");
Assert.Equal(TaskStatus.Cancelled, parent!.Status);
Assert.Equal(TaskStatus.Cancelled, runningChild!.Status);
Assert.Equal(TaskStatus.Cancelled, queuedChild!.Status);
Assert.Null(queuedChild.BlockedByTaskId);
}
[Fact]
public async Task Cancelling_WaitingForChildren_parent_stops_running_child_process()
{
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "c_running", ListId = "l1", Title = "Running child",
Status = TaskStatus.Running, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
using var childCts = new CancellationTokenSource();
_built.RunCancels.Register("c_running", childCts);
var result = await _built.State.CancelAsync("par", DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.True(childCts.IsCancellationRequested);
}
[Fact]
public async Task Cancelling_running_task_signals_its_registered_run()
{
var id = await SeedRunningStandaloneAsync();
using var cts = new CancellationTokenSource();
_built.RunCancels.Register(id, cts);
var result = await _built.State.CancelAsync(id, DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.True(cts.IsCancellationRequested);
}
// ─── FinalizePlanningAsync ────────────────────────────────────────────
private async Task<string> SeedActivePlanningParentAsync(string id = "par")