Merge branch 'claudedo/f359858ac98a439593e459df9c5d0a5d'
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# ConPTY interactive sessions & launch specs
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `f6cb825` (2026-08-05).
|
||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `InteractiveLaunchSpecService` and the four kinds of embedded ConPTY session the UI
|
||||
process hosts (real `claude` TUI in a Mission Control tile).
|
||||
|
||||
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
|
||||
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
|
||||
|
||||
## The four session kinds
|
||||
|
||||
| Kind | Hub spec method | Notes |
|
||||
|---|---|---|
|
||||
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
|
||||
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
|
||||
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
|
||||
| List handler | `GetMergeHelperLaunchSpec` | Effort from list config; `--permission-mode auto` (unattended) |
|
||||
|
||||
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
||||
|
||||
**No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.**
|
||||
Every one of them writes it to a file first and hands `claude` a single-line kickoff pointing at
|
||||
that file, exposed via `--add-dir`.
|
||||
|
||||
Two independent reasons:
|
||||
|
||||
1. The ConPTY host flattens `Args` into **one command line** to spawn the process, and `claude`
|
||||
re-splits that line on whitespace. Any token starting with `-` in real task text (e.g. `->`,
|
||||
`--abort`) is then misread as an unknown option.
|
||||
2. A raw multi-line positional prompt truncates at its **first newline** regardless.
|
||||
|
||||
A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md`
|
||||
(`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor
|
||||
description skips the file **and** the positional arg entirely.
|
||||
|
||||
## Argument ordering
|
||||
|
||||
Every spec passes `--effort <level>` from the relevant model's preset. It **leads** the args —
|
||||
except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so
|
||||
`--effort` (a single-value flag) can sit directly before the positional kickoff.
|
||||
|
||||
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
|
||||
models in the TUI.
|
||||
|
||||
## List handler ("Let Claude handle it")
|
||||
|
||||
`BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The
|
||||
`--allowedTools` allowlist is the security boundary:
|
||||
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
|
||||
|
||||
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
||||
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
||||
|
||||
### The host task and its commit range
|
||||
|
||||
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
|
||||
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`, called by the UI *before* it opens
|
||||
the tile:
|
||||
|
||||
- One new task per run in that list, `Idle` + `IsManual=true` (never queued).
|
||||
- Title/description localized via `missionControl.mergeHelperTaskTitle` /
|
||||
`mergeHelperTaskDescriptionHeader`.
|
||||
- `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD.
|
||||
|
||||
The host task **never gets a worktree of its own** — the handler commits straight into the
|
||||
list's working dir and merges the tasks it handles itself. Consequences:
|
||||
|
||||
- `SubmitTaskForReview` branches on whether the task has a `WorktreeEntity`: with one, it
|
||||
commits the worktree; without one, it stamps `HandlerHeadCommit` to the list repo's current
|
||||
HEAD. Both paths then flip the task `Idle`/`Failed` → `WaitingForReview`.
|
||||
- `GetTaskDiff` and the UI's `DetailsIslandViewModel` / `MergeSectionViewModel` fall back to the
|
||||
`HandlerBaseCommit`..`HandlerHeadCommit` range whenever `Worktree` is null.
|
||||
|
||||
### UI flow
|
||||
|
||||
`MergeHelperSelectionModalViewModel` — checkbox picker over one list's non-terminal, non-manual
|
||||
tasks, pre-ticking the actionable ones. **List-scoped only** (`Configure(listId, listName)`, no
|
||||
global scope). Opened from the list row's context menu, which is hidden when the list has no
|
||||
working dir.
|
||||
|
||||
On confirm: `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell →
|
||||
`MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which calls
|
||||
`CreateMergeHelperTaskAsync` and then opens a **task-based** tile (deduped by `TaskId` like
|
||||
`OpenConPtySessionAsync`, **not** `CreateAdHoc`) running the five-phase handler prompt.
|
||||
|
||||
## Tile lifecycle
|
||||
|
||||
`ConPtyPaneViewModel` resolves its own launch spec — the ctor takes a descriptor **factory**,
|
||||
the host wires handlers and then calls `Start()`. So the tile appears **immediately** with its
|
||||
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
|
||||
inline error banner instead of the tile never appearing.
|
||||
|
||||
`Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner — used for a starting pane
|
||||
(`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while
|
||||
`TaskRowViewModel.IsRefining`.
|
||||
|
||||
## Focus / key handling
|
||||
|
||||
`InteractiveTerminalView` lives in `MissionControlWindow`, so the `FocusClearing` Escape handler
|
||||
(scoped to `MainWindow` via `AddClassHandler<MainWindow>`) never runs there — **Escape always
|
||||
reaches the PTY**. See the note in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||||
|
||||
`TaskRowViewModel.HasInteractiveSession` shows an accent "Interactive" chip instead of "Parked";
|
||||
tapping it jumps to that Mission Control pane. `TasksIslandViewModel.SyncInteractiveSessions`
|
||||
mirrors Mission Control's open panes onto the rows.
|
||||
|
||||
## Related hub methods
|
||||
|
||||
`GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`,
|
||||
`CreateMergeHelperTask`, `SubmitTaskForReview`.
|
||||
|
||||
Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`,
|
||||
`FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`,
|
||||
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`.
|
||||
@@ -0,0 +1,141 @@
|
||||
# External MCP tool surface
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `f6cb825` (2026-08-05).
|
||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
|
||||
Claude sessions. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`.
|
||||
Server name `claudedo`, registered globally by the installer's `RegisterMcpStep`, so callers
|
||||
need no `--mcp-config`.
|
||||
|
||||
**Scope boundary:** these tools cover *starting* and *observing* sessions plus task/list CRUD
|
||||
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
|
||||
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
|
||||
|
||||
## Hard conventions (enforced by tests)
|
||||
|
||||
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
|
||||
The MCP schema only marks a parameter optional when it has one — nullability alone does
|
||||
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
|
||||
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
|
||||
an empty/omitted response apart from a dropped one.
|
||||
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
|
||||
true, <id>, ... }` (`DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`,
|
||||
`RemoveAttachmentResult`; `SetListConfigResult` / `SetTaskConfigResult` additionally echo
|
||||
the resulting config so the caller can see which fields were set vs. cleared to null).
|
||||
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
|
||||
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
|
||||
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
||||
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
||||
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
||||
*"An error occurred invoking 'X'."*
|
||||
|
||||
## Tool classes
|
||||
|
||||
### `ExternalMcpService` — task CRUD, execution, git
|
||||
|
||||
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
||||
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
|
||||
`CancelTask`, `DeleteTask`.
|
||||
|
||||
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
||||
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
||||
|
||||
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
|
||||
### Other classes
|
||||
|
||||
| Class | Tools |
|
||||
|---|---|
|
||||
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
|
||||
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
|
||||
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` |
|
||||
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
|
||||
| `AgentMcpTools` | `ListAgents` |
|
||||
| `LifecycleMcpTools` | `ResetFailedTask` |
|
||||
| `AppSettingsMcpTools` | `GetAppSettings` (read-only) |
|
||||
| `TaskWaitMcpTools` | `WaitForTaskChange` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
|
||||
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
|
||||
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
|
||||
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
|
||||
a manual opt-out signal.
|
||||
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
|
||||
UI's "set status freely" affordance uses) but is **refused** for a task with an active
|
||||
worktree, since that would skip `review_task`'s merge.
|
||||
|
||||
**`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
|
||||
are reported in `ReviewTaskResult`.
|
||||
|
||||
**`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one
|
||||
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
|
||||
status / conflictFiles / changedFileCount / `behind`. Unlike `TaskMergeService.PreviewAsync`'s
|
||||
silent *"unavailable"*, this **throws a clear error** when the task has no worktree, the
|
||||
worktree isn't `Active`, or the list's working dir is missing.
|
||||
|
||||
**`PreviewMergeSet`** — same preview for a batch, plus a file→tasks overlap report built from
|
||||
each task's own diff-stat. ⚠️ That overlap report is a **same-file-name hint only** — it is
|
||||
blind to cross-file collisions (e.g. the CS0103 case that motivated it). A task that fails to
|
||||
preview gets `error` set and is excluded from the overlap instead of aborting the batch.
|
||||
|
||||
**`RevertMerge`** — undoes a previously merged task's merge commit on `targetBranch` via
|
||||
`git revert -m 1`. Always a **new commit**, never a reset/rewrite, because the target working
|
||||
directory is shared with other concurrent sessions.
|
||||
- Requires the task to be `Done` with a `Merged` worktree carrying a recorded
|
||||
`WorktreeEntity.MergeCommit`. A task merged before that field existed has none and is
|
||||
**refused rather than guessed** via `git log`.
|
||||
- On success the task returns to `WaitingForReview` and the worktree moves to `Kept` — not
|
||||
`Active` (its directory/branch are typically already gone from the original merge's cleanup)
|
||||
and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those).
|
||||
- A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
|
||||
|
||||
**`BatchMcpTools`** — best-effort loops over the `ExternalMcpService` single-entity methods.
|
||||
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
|
||||
single-task. 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**.
|
||||
|
||||
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
|
||||
|
||||
**`WaitForTaskChange(taskIds, timeoutSeconds = 60)`** — blocks until any given task leaves
|
||||
`Queued`/`Running`, or times out. Returns immediately for a task already outside those two
|
||||
(unknown ids reported as status `"NotFound"`, also immediate).
|
||||
- Implemented as an **async DB poll** (short-lived `DbContext` per check, 500 ms delay, no
|
||||
held connection, no busy loop) rather than hooking `HubBroadcaster` — deliberately isolated
|
||||
so it can't regress the existing broadcast callers.
|
||||
- `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (170 s),
|
||||
comfortably under the list handler's `MCP_TOOL_TIMEOUT` (200 s, see
|
||||
`InteractiveLaunchSpecService`), so the tool reports `timedOut: true` instead of racing the
|
||||
client's own abort.
|
||||
- Replaced the list handler's old "sleep + poll `get_task` in a loop" Phase 3 instruction.
|
||||
|
||||
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
|
||||
`Running` task.
|
||||
|
||||
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
|
||||
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
|
||||
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
|
||||
helper in the same file.
|
||||
|
||||
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
|
||||
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
|
||||
|
||||
## Model / max-turns on task creation
|
||||
|
||||
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
|
||||
optional `model`, alias-validated via `ModelRegistry.NormalizeAlias` (`haiku`/`sonnet`/`opus`,
|
||||
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
|
||||
planning/system/improvement prompts instruct it to do so, using
|
||||
`ModelRegistry.ByCostAscending` as the cost order.
|
||||
|
||||
Planning's `CreateChildTask` **additionally** accepts an optional `maxTurns` (positive int;
|
||||
`0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the
|
||||
planner can raise the turn budget for a subtask it knows will run long.
|
||||
`SuggestImprovement` and `AddTask` do **not** expose it.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Review, merge & conflict resolution
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `f6cb825` (2026-08-05).
|
||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers the review→merge path: `TaskStateService` review transitions, `TaskMergeService`,
|
||||
`PlanningMergeOrchestrator`, the post-merge verify gate, and the UI conflict resolver.
|
||||
|
||||
## Approve = merge the whole unit
|
||||
|
||||
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
|
||||
There is no separate "Merge all" entry.
|
||||
|
||||
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
|
||||
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
|
||||
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
|
||||
`WaitingForReview`.
|
||||
- **No active worktree** (sandbox run) → straight to `Done`.
|
||||
|
||||
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
|
||||
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
|
||||
`ClearReviewFeedbackAsync`.
|
||||
|
||||
`ReviewFeedback` (nullable string on `TaskEntity`) is the reviewer's rejection comment: set by
|
||||
`RejectToQueueAsync`, consumed and cleared by `QueueService` on the next re-run, where it
|
||||
becomes the next-turn prompt of the resumed Claude session.
|
||||
|
||||
## Unified parent model
|
||||
|
||||
Every parent — planning **or** improvement — flows
|
||||
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
|
||||
`TaskStateService.TryAdvanceParentAsync`. It surfaces any `WaitingForChildren` parent for
|
||||
review once all children are terminal; failed/cancelled children are **annotated on the
|
||||
result, not wedged**.
|
||||
|
||||
- A planning parent enters `WaitingForChildren` at `FinalizePlanningAsync` (or
|
||||
`WaitingForReview` directly if it has no children).
|
||||
- An improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned children.
|
||||
- Planning/improvement **children** go straight to `Done` — no individual review. Only the
|
||||
parent is reviewed.
|
||||
|
||||
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not**
|
||||
advance the parent — the parent stays in `WaitingForChildren` until every child is terminal.
|
||||
The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children
|
||||
need attention" band) so the roadblock is visible without forcing a transition.
|
||||
|
||||
## Post-merge verify gate
|
||||
|
||||
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
||||
Null/blank (the default) = **no gate**, behavior bit-identical to before the feature existed.
|
||||
|
||||
When set, `TaskMergeService` runs it via `VerifyCommandRunner` (`cmd.exe /c <command>`,
|
||||
10-minute fixed timeout, output tail-captured) in `list.WorkingDir` right after a successful
|
||||
`MergeNoFfAsync` / `ContinueMergeAsync` **and** worktree cleanup, but **before** the task is
|
||||
allowed to reach `Done`.
|
||||
|
||||
| Outcome | Effect |
|
||||
|---|---|
|
||||
| Exit 0 | Unchanged flow — worktree `Merged`, task `Done` if it was `WaitingForReview`. |
|
||||
| Non-zero exit or timeout | The git merge is **deliberately left in place** (no auto-revert — that's a separate, unbuilt feature). The worktree is still marked `Merged` (it's already gone from disk when `removeWorktree` was requested), but the task stays out of `Done`. |
|
||||
|
||||
On failure `MergeResult.Status` comes back `TaskMergeService.StatusVerifyFailed`
|
||||
(`"verify_failed"`) with an output excerpt in `ErrorMessage`. This flows through
|
||||
`MergeResultDto` (hub) and `ReviewTaskResult` (`review_task`) unchanged, because both already
|
||||
treat any non-`blocked`/`conflict` status generically.
|
||||
|
||||
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
|
||||
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
|
||||
so a verify run can't be interrupted by a second merge landing in the same working dir
|
||||
mid-build.
|
||||
|
||||
## `MergeCommit` and revert
|
||||
|
||||
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
|
||||
produced on the target branch. Stamped by `TaskMergeService` the moment a merge/continue-merge
|
||||
succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomically sets
|
||||
`State=Merged` and stamps the SHA in one update).
|
||||
|
||||
It is the only thing that makes `revert_merge` possible without heuristically searching
|
||||
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
|
||||
merged before the field existed.
|
||||
|
||||
## Review gate in the UI
|
||||
|
||||
**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. The gate **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**.
|
||||
|
||||
Implementation: `MergeSectionViewModel` owns merge-target selection, the mergeability
|
||||
indicator (`MergePreviewPresenter` over `PreviewMergeAsync`), and `OpenDiffAsync` /
|
||||
`ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and
|
||||
fire the `DiffViewed` callback. `HasReviewableDiff` reports whether anything is inspectable
|
||||
and feeds the gate.
|
||||
|
||||
## Conflict resolver (in-app Rider-style 3-pane merge editor)
|
||||
|
||||
`ConflictResolverViewModel` + `Views/Conflicts/ConflictResolverView`. Handles **both**
|
||||
single-task and planning unit-merge conflicts.
|
||||
|
||||
### Model
|
||||
|
||||
Single-task mode starts the conflict merge, then parses each conflicted file into stable and
|
||||
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
|
||||
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
|
||||
|
||||
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
|
||||
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
|
||||
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
|
||||
`Current` / `Next` / `Previous` (focused-conflict nav), a per-file `PositionText` readout,
|
||||
per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on
|
||||
**every file resolved + no binary**. Each file is written via `WriteConflictResolution`.
|
||||
|
||||
**Planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's
|
||||
mid-merge conflicts **without re-starting the merge** and routes continue/abort to
|
||||
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
|
||||
per subtask via the `PlanningMergeConflict` broadcast.
|
||||
|
||||
### View
|
||||
|
||||
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
|
||||
INCOMING/theirs (read-only). TextMate highlighting by extension (theme `StyleInclude` in
|
||||
`App.axaml`).
|
||||
|
||||
- A code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across
|
||||
panes. Tints live in `Tokens.axaml` (`Merge*TintBrush`).
|
||||
- An `IReadOnlySectionProvider` + `TextAnchor` regions keep **only conflict spans** editable in
|
||||
Result; edits flow back to the block.
|
||||
- Each unresolved conflict starts **EMPTY** (a thin marker bar).
|
||||
- The between-pane gutter controls **toggle** each side in/out of the result: `›`/`‹` add
|
||||
MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a
|
||||
conflict can take main, incoming, both, or **neither**.
|
||||
- `FilesSummary` shows how many files still have conflicts. The three panes share a
|
||||
proportional synced vertical scroll.
|
||||
- A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the
|
||||
file proportionally; click a tick to jump. Useful for long files.
|
||||
|
||||
### Entry points
|
||||
|
||||
Review **Approve** on conflict, and the **Merge** button in the Diff window (a conflicting
|
||||
`MergeTask` hands off via `RequestConflictResolution`).
|
||||
|
||||
## Hub methods
|
||||
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto`,
|
||||
`ContinuePlanningMerge` / `AbortPlanningMerge`, `PreviewMerge(taskId, targetBranch) ->
|
||||
MergePreviewDto`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`,
|
||||
`GetMergeTargets`
|
||||
- Single-task conflict resolver: `StartConflictMerge`, `GetMergeConflictDocuments`,
|
||||
`WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` — note the
|
||||
service-level `TaskMergeService.ContinueMergeAsync` / `AbortMergeAsync` keep their own names.
|
||||
- Broadcast events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
|
||||
`PlanningMergeAborted`, `PlanningCompleted`
|
||||
|
||||
## Diff stack (UI)
|
||||
|
||||
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
|
||||
added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for
|
||||
a combined single-pane view. `DiffModels.cs` holds the shared types (`DiffLineViewModel`,
|
||||
`DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
|
||||
`DiffTreeNodeViewModel`, `DiffTree`).
|
||||
|
||||
`DiffViewerViewModel` is one unified read-only viewer with two modes:
|
||||
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
|
||||
file-tree left + per-file diff pane right, Merge button for a live branch source.
|
||||
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat
|
||||
diff right, combined integration-branch toggle.
|
||||
|
||||
`DiffLinesView` renders per-file content with binary/empty placeholders.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Usage monitoring, gate & throttle
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `f6cb825` (2026-08-05).
|
||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
|
||||
accounting, and the UI surfaces (usage pill + usage monitor modal).
|
||||
|
||||
## Data source
|
||||
|
||||
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
|
||||
authenticated with the Bearer access token Claude Code itself keeps fresh at
|
||||
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
|
||||
|
||||
Because the endpoint is undocumented and can change without notice, **every consumer
|
||||
fails open**. That is the single most important invariant here.
|
||||
|
||||
## Components (`Usage/`)
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
|
||||
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
|
||||
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
|
||||
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
|
||||
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
|
||||
| `TranscriptUsageReader` | Aggregates token usage from Claude Code transcripts. |
|
||||
|
||||
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`.
|
||||
|
||||
## The gate (hard pause)
|
||||
|
||||
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
|
||||
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
|
||||
(`>=`, not `>`). Threshold `0` = that bucket never gates.
|
||||
|
||||
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
|
||||
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
|
||||
planning sessions, daily prep (all bypass the queue).
|
||||
|
||||
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
|
||||
resolve to not-blocked.
|
||||
|
||||
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
|
||||
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
|
||||
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
|
||||
on block / Info on resume) exactly **once per change**, not every tick.
|
||||
|
||||
## The throttle (staged parallelism)
|
||||
|
||||
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
|
||||
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
|
||||
|
||||
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
|
||||
Whichever of 5h/7d is **more utilized** decides the stage:
|
||||
|
||||
| Utilization | Effective slots |
|
||||
|---|---|
|
||||
| below soft | full configured `max_parallel_executions` |
|
||||
| `>= softPct` | capped at 2 |
|
||||
| `>= hardPct` | capped at 1 |
|
||||
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
|
||||
|
||||
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
|
||||
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
|
||||
|
||||
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
|
||||
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
||||
|
||||
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
|
||||
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
|
||||
It does **not** change what the gate gates on.
|
||||
|
||||
## Queue integration (`Queue/QueueService`)
|
||||
|
||||
Per loop tick:
|
||||
|
||||
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
|
||||
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
|
||||
A missing/failed snapshot fails open to the configured value. A **stage change** (not
|
||||
every tick) logs once.
|
||||
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
|
||||
entirely for that tick.
|
||||
|
||||
## Per-run token accounting
|
||||
|
||||
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
|
||||
`cache_read_tokens` / `cache_write_tokens`.
|
||||
|
||||
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
|
||||
only the uncached remainder of a single API call and undercounts the real prompt size by
|
||||
orders of magnitude once caching kicks in.
|
||||
|
||||
Instead `TaskRunner.ApplyUsageAsync` calls
|
||||
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
|
||||
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
|
||||
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
|
||||
`--resume`'d run doesn't double-count turns already billed to an earlier run.
|
||||
|
||||
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
|
||||
|
||||
### `TranscriptUsageReader` details
|
||||
|
||||
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
||||
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
||||
|
||||
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
||||
|
||||
## UI surfaces
|
||||
|
||||
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
|
||||
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
|
||||
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
||||
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
||||
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
||||
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
||||
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
||||
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
||||
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
|
||||
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
|
||||
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range.
|
||||
|
||||
## Hub surface
|
||||
|
||||
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
|
||||
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
|
||||
or a snapshot older than 3× `usage_poll_interval_seconds`.
|
||||
- `GetModelUsage(from, to)` — thin wrapper over `ITranscriptUsageReader.ReadAsync`.
|
||||
- `GetTaskUsage(from, to)` — top consumers from `task_runs` joined to task/list, grouped per
|
||||
task. Null token columns count as **0**, never drop the row. `Model` comes from that task's
|
||||
most recent run. Sorted by total tokens descending, capped at 100.
|
||||
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
|
||||
|
||||
## Settings columns
|
||||
|
||||
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
||||
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by
|
||||
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
|
||||
Reference in New Issue
Block a user