Merge branch 'main' into worktree-diff-side-by-side
# Conflicts: # src/ClaudeDo.Ui/CLAUDE.md
This commit is contained in:
@@ -32,7 +32,14 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
|
|||||||
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
|
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
|
||||||
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
|
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
|
||||||
list of verbosely-described tasks from blowing past the response size limit by default.
|
list of verbosely-described tasks from blowing past the response size limit by default.
|
||||||
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
|
||||||
|
lives there as `const` strings. Rules: the first sentence says what the tool does *and* when
|
||||||
|
to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind
|
||||||
|
return-shape prose); parameters are documented with `[Description]` **on the parameter**, not
|
||||||
|
in the tool description; result fields appear only where the caller must branch on them
|
||||||
|
before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or
|
||||||
|
"since this feature was introduced" history. Not test-enforced — review it in PRs.
|
||||||
|
4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
||||||
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
||||||
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
||||||
*"An error occurred invoking 'X'."*
|
*"An error occurred invoking 'X'."*
|
||||||
@@ -42,8 +49,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
|
|||||||
### `ExternalMcpService` — task CRUD, execution, git
|
### `ExternalMcpService` — task CRUD, execution, git
|
||||||
|
|
||||||
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
||||||
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
|
`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`.
|
||||||
`CancelTask`, `DeleteTask`.
|
(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s
|
||||||
|
description is now the canonical place for what each status means.)
|
||||||
|
|
||||||
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
||||||
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Usage monitoring, gate & throttle
|
# Usage monitoring, gate & throttle
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `f6cb825` (2026-08-05).
|
> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
|
||||||
|
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
|
||||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
> 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.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
@@ -52,21 +53,26 @@ on block / Info on resume) exactly **once per change**, not every tick.
|
|||||||
|
|
||||||
## The throttle (staged parallelism)
|
## The throttle (staged parallelism)
|
||||||
|
|
||||||
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
|
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
|
||||||
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
|
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
|
||||||
|
per-bucket triple (same file).
|
||||||
|
|
||||||
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
|
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
|
||||||
Whichever of 5h/7d is **more utilized** decides the stage:
|
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
|
||||||
|
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
|
||||||
|
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
|
||||||
|
that throttles:
|
||||||
|
|
||||||
| Utilization | Effective slots |
|
| Utilization (per bucket) | That bucket's slots |
|
||||||
|---|---|
|
|---|---|
|
||||||
| below soft | full configured `max_parallel_executions` |
|
| below soft | full configured `max_parallel_executions` |
|
||||||
| `>= softPct` | capped at 2 |
|
| `>= softPct` | capped at 2 |
|
||||||
| `>= hardPct` | capped at 1 |
|
| `>= hardPct` | capped at 1 |
|
||||||
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
|
| `>= gatePct` | 0 — same hard block as `UsageGate` |
|
||||||
|
|
||||||
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
|
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
|
||||||
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
|
throttles. 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
|
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.
|
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
||||||
@@ -108,6 +114,12 @@ A missing/unreadable transcript leaves all four fields `null`; it never fails th
|
|||||||
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
||||||
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
||||||
|
|
||||||
|
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
|
||||||
|
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
|
||||||
|
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
|
||||||
|
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
|
||||||
|
unaffected — it looks up a single `{sessionId}.jsonl`.
|
||||||
|
|
||||||
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
||||||
|
|
||||||
## UI surfaces
|
## UI surfaces
|
||||||
@@ -117,6 +129,25 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
|
|||||||
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
||||||
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
||||||
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
||||||
|
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
|
||||||
|
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
|
||||||
|
the first `GetModelUsage` per worker process scans the whole transcript history.
|
||||||
|
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
|
||||||
|
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
|
||||||
|
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
|
||||||
|
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
|
||||||
|
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
|
||||||
|
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
|
||||||
|
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
|
||||||
|
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
|
||||||
|
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
|
||||||
|
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
|
||||||
|
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
|
||||||
|
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
|
||||||
|
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
|
||||||
|
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||||||
|
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
|
||||||
|
doesn't replace the bound instance.
|
||||||
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
||||||
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
||||||
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
||||||
@@ -138,5 +169,13 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
|
|||||||
## Settings columns
|
## Settings columns
|
||||||
|
|
||||||
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
`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
|
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
|
||||||
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
|
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
|
||||||
|
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
|
||||||
|
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
|
||||||
|
`usage_poll_interval_idle_seconds`.
|
||||||
|
|
||||||
|
The gate percentages are editable in **two** places that both write the same `app_settings` row:
|
||||||
|
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||||
|
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
|
||||||
|
can't reset a dragged value.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ read-only "## Reference files" section.
|
|||||||
- `TaskMergeService` — conflict resolution for worktree merges.
|
- `TaskMergeService` — conflict resolution for worktree merges.
|
||||||
|
|
||||||
**Hub/**
|
**Hub/**
|
||||||
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
|
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…).
|
||||||
- `WorkerHub` — SignalR hub + client methods.
|
- `WorkerHub` — SignalR hub + client methods.
|
||||||
|
|
||||||
**Agents/**
|
**Agents/**
|
||||||
|
|||||||
@@ -0,0 +1,939 @@
|
|||||||
|
# Handler-Run Links Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
|
||||||
|
|
||||||
|
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
|
||||||
|
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `SetHandlerTaskIdAsync`
|
||||||
|
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `HandlerBadgeBrush` + `Border.badge.handler`
|
||||||
|
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `tasks.badgeHandler`, `tasks.handlerTip`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `HandledTasks` collection, loader, clear, refresh
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
|
||||||
|
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data — `HandlerTaskId` column and migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
|
||||||
|
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the property**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
|
||||||
|
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
|
||||||
|
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
|
||||||
|
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
|
||||||
|
public string? HandlerTaskId { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Map the column and index it**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
|
||||||
|
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
At the end of `Configure`, after the line
|
||||||
|
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration**
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
|
||||||
|
|
||||||
|
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
|
||||||
|
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
|
||||||
|
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
|
||||||
|
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
||||||
|
Expected: `Build succeeded`, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
|
||||||
|
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
|
||||||
|
main checkout is shared with concurrent sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
|
||||||
|
/// run processed, so the handler task's detail pane can list them after the run.
|
||||||
|
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryHandlerLinkTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test List",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> AddTaskAsync(string listId)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _tasks.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ListId = listId,
|
||||||
|
Title = "T",
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var b = await AddTaskAsync(listId);
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(2, affected);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
|
||||||
|
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(
|
||||||
|
new[] { a, "does-not-exist" }, handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(1, affected);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var firstHandler = await AddTaskAsync(listId);
|
||||||
|
var secondHandler = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
|
||||||
|
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
|
||||||
|
|
||||||
|
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(0, affected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||||
|
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the method**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Links the tasks a "list handler" run processed back to the handler's own task, so the
|
||||||
|
// handler's detail pane can list them after the run. Stamped from the user's selection at
|
||||||
|
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
|
||||||
|
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
|
||||||
|
public async Task<int> SetHandlerTaskIdAsync(
|
||||||
|
IReadOnlyList<string> taskIds,
|
||||||
|
string handlerTaskId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (taskIds.Count == 0) return 0;
|
||||||
|
|
||||||
|
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return 0;
|
||||||
|
|
||||||
|
return await _context.Tasks
|
||||||
|
.Where(t => ids.Contains(t.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 4`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Worker — stamp the selection when the handler task is created
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
|
||||||
|
|
||||||
|
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
|
||||||
|
`IWorkerClient` → `WorkerHub` chain needs **no** change.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var handlerId = await svc.CreateMergeHelperTaskAsync(
|
||||||
|
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
|
||||||
|
|
||||||
|
using var readCtx = _db.CreateContext();
|
||||||
|
var tasks = new TaskRepository(readCtx);
|
||||||
|
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
|
||||||
|
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
|
||||||
|
// The handler never links to itself.
|
||||||
|
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
|
||||||
|
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Stamp the selection**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await taskRepo.AddAsync(handlerTask, ct);
|
||||||
|
|
||||||
|
return handlerTask.Id;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await taskRepo.AddAsync(handlerTask, ct);
|
||||||
|
|
||||||
|
// Link the selection back to this run BEFORE the session starts: the handler cancels
|
||||||
|
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
|
||||||
|
// list. Stamping later (e.g. at handoff) would lose them.
|
||||||
|
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
|
||||||
|
|
||||||
|
return handlerTask.Id;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
|
||||||
|
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Ui — HANDLER badge instead of MANUAL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
|
||||||
|
/// it -- the HANDLER badge must win and MANUAL must disappear.
|
||||||
|
public class TaskRowViewModelHandlerBadgeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t1" };
|
||||||
|
row.IsManual = true;
|
||||||
|
row.HandlerBaseCommit = "base123";
|
||||||
|
|
||||||
|
Assert.True(row.IsHandlerRun);
|
||||||
|
Assert.NotNull(row.HandlerBadge);
|
||||||
|
Assert.Null(row.ManualBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlainManualTask_StillShowsManualBadge()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t2" };
|
||||||
|
row.IsManual = true;
|
||||||
|
|
||||||
|
Assert.False(row.IsHandlerRun);
|
||||||
|
Assert.Null(row.HandlerBadge);
|
||||||
|
Assert.NotNull(row.ManualBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t3" };
|
||||||
|
row.UpdateFromEntity(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "t3",
|
||||||
|
ListId = "l1",
|
||||||
|
Title = "List handler: Alpha",
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
IsManual = true,
|
||||||
|
HandlerBaseCommit = "base123",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("base123", row.HandlerBaseCommit);
|
||||||
|
Assert.True(row.IsHandlerRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||||
|
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the properties**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
|
||||||
|
declaration (`[ObservableProperty] private bool _isManual;`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
|
||||||
|
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
|
||||||
|
[ObservableProperty] private string? _handlerBaseCommit;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||||
|
|
||||||
|
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||||
|
|
||||||
|
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
|
||||||
|
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
|
||||||
|
public bool ShowManualBadge => IsManual && !IsHandlerRun;
|
||||||
|
|
||||||
|
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a change hook next to the existing `OnIsManualChanged` partial method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnHandlerBaseCommitChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsHandlerRun));
|
||||||
|
OnPropertyChanged(nameof(HandlerBadge));
|
||||||
|
OnPropertyChanged(nameof(ShowManualBadge));
|
||||||
|
OnPropertyChanged(nameof(ManualBadge));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(ShowManualBadge));
|
||||||
|
```
|
||||||
|
|
||||||
|
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
HandlerBaseCommit = t.HandlerBaseCommit;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(HandlerBadge));
|
||||||
|
OnPropertyChanged(nameof(ManualBadge));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the locale keys**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"badgeHandler": "HANDLER",
|
||||||
|
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"badgeHandler": "HANDLER",
|
||||||
|
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the badge style and brush**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
|
||||||
|
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
After the existing `Border.badge.manual` style block add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
|
||||||
|
<Style Selector="Border.badge.handler">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Render the badge**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||||
|
<TextBlock Text="{Binding ManualBadge}"/>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||||
|
<TextBlock Text="{Binding ManualBadge}"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
|
||||||
|
<TextBlock Text="{Binding HandlerBadge}"/>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the `IsVisible` binding changed on the manual border (`IsManual` → `ShowManualBadge`); the
|
||||||
|
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 3`.
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: `Passed! - Failed: 0` (en/de key parity).
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: `Build succeeded` — this compiles the AXAML.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
|
||||||
|
/// its id, including ones the handler cancelled as duplicates.
|
||||||
|
public class DetailsIslandHandledTasksTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandHandledTasksTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||||
|
{
|
||||||
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||||
|
Task.FromResult(new List<DailyNoteDto>());
|
||||||
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||||
|
Task.FromResult<DailyNoteDto?>(null);
|
||||||
|
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetailsIslandViewModel BuildVm()
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(
|
||||||
|
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
|
||||||
|
{
|
||||||
|
const string listId = "list-1";
|
||||||
|
const string handlerId = "handler-task-1";
|
||||||
|
|
||||||
|
await using (var ctx = NewContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = handlerId, ListId = listId, Title = "List handler: L",
|
||||||
|
Status = TaskStatus.WaitingForReview, IsManual = true,
|
||||||
|
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "done-1", ListId = listId, Title = "Merged task",
|
||||||
|
Status = TaskStatus.Done, HandlerTaskId = handlerId,
|
||||||
|
SortOrder = 0, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
|
||||||
|
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
|
||||||
|
SortOrder = 1, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.HandledTasks.Count);
|
||||||
|
Assert.True(vm.HasHandledTasks);
|
||||||
|
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
|
||||||
|
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
|
||||||
|
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bind_PlainTask_HasNoHandledTasks()
|
||||||
|
{
|
||||||
|
const string listId = "list-1";
|
||||||
|
const string taskId = "plain-1";
|
||||||
|
|
||||||
|
await using (var ctx = NewContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "Plain",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
Assert.Empty(vm.HandledTasks);
|
||||||
|
Assert.False(vm.HasHandledTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||||
|
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the collection**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
|
||||||
|
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
|
||||||
|
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
|
||||||
|
// planning/improvement parent's children and feeds the merge card's combined diff, which a
|
||||||
|
// handler run must not touch (it commits straight to the list's working dir).
|
||||||
|
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool HasHandledTasks => HandledTasks.Count > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clear it on rebind**
|
||||||
|
|
||||||
|
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
HandledTasks.Clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(HasHandledTasks));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Load it on bind**
|
||||||
|
|
||||||
|
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await LoadHandledTasksAsync(row.Id, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
|
||||||
|
// reads in the same order the user picked them.
|
||||||
|
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var handled = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.Where(t => t.HandlerTaskId == handlerTaskId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (handled.Count == 0) return;
|
||||||
|
|
||||||
|
HandledTasks.Clear();
|
||||||
|
foreach (var h in handled)
|
||||||
|
HandledTasks.Add(new ChildOutcomeRowViewModel
|
||||||
|
{
|
||||||
|
Id = h.Id,
|
||||||
|
Title = h.Title,
|
||||||
|
Status = h.Status,
|
||||||
|
RoadblockCount = h.RoadblockCount,
|
||||||
|
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
|
||||||
|
});
|
||||||
|
OnPropertyChanged(nameof(HasHandledTasks));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Keep the rows live**
|
||||||
|
|
||||||
|
In the same file, in `RefreshChildOutcomeAsync`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
|
||||||
|
if (row is null) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// The same refresh serves both lists: a planning parent's children and a handler run's
|
||||||
|
// handled tasks. Only one of them can hold a given id.
|
||||||
|
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
|
||||||
|
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
|
||||||
|
if (row is null) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Render the panel**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
|
||||||
|
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Handled tasks (list handler run) -->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
|
||||||
|
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
|
||||||
|
<ItemsControl ItemsSource="{Binding HandledTasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||||
|
IsVisible="{Binding HasRoadblock}"
|
||||||
|
Foreground="#E0A030"
|
||||||
|
Margin="8,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||||
|
Opacity="0.75" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 2`.
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: `Build succeeded`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Full verification and docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
|
||||||
|
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run every affected test project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
|
||||||
|
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
|
||||||
|
|
||||||
|
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
|
||||||
|
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
|
||||||
|
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
|
||||||
|
|
||||||
|
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
|
||||||
|
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
|
||||||
|
|
||||||
|
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
|
||||||
|
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
|
||||||
|
|
||||||
|
In the "The host task and its commit range" section, add after the existing description:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
|
||||||
|
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
|
||||||
|
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
|
||||||
|
The handler never links to itself.
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump that note's "verified against" commit line to the current HEAD.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
|
||||||
|
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Report the visual-verification gap**
|
||||||
|
|
||||||
|
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
|
||||||
|
|
||||||
|
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
|
||||||
|
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
|
||||||
|
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
|
||||||
@@ -869,9 +869,18 @@ git commit -m "fix(worker): broadcast TaskUpdated for tasks imported from the on
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 7: `TaskUpdated` nach dem Anlegen der List-Handler-Task
|
### Task 7: ~~`TaskUpdated` nach dem Anlegen der List-Handler-Task~~ — ENTFÄLLT
|
||||||
|
|
||||||
`InteractiveLaunchSpecService` legt die Handler-Task an (`InteractiveLaunchSpecService.cs:447`) ohne Broadcast. Einzige weitere Aufrufstelle des Konstruktors ist `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs:64`.
|
**Bei der Umsetzung am 2026-08-07 verworfen. Prämisse war falsch, kein Code geändert.**
|
||||||
|
|
||||||
|
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` (`:447`) broadcastet selbst nichts — das stimmte. Aber ihr **einziger** Produktions-Aufrufer, `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`), sendet unmittelbar danach `Clients.All.SendAsync("TaskUpdated", taskId)`. Das kam mit Commit `c07c1f7` (2026-08-05) und ist durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts` abgesichert. Der UI-Pfad (`MissionControlViewModel` → `WorkerClient.CreateMergeHelperTaskAsync` → Hub) führt ausschließlich über diesen Aufrufer.
|
||||||
|
|
||||||
|
Den Broadcast zusätzlich in den Service zu legen hätte ihn **verdoppelt**. Ihn dorthin zu *verschieben* wäre ein reiner Konsistenz-Refactor ohne Verhaltensänderung — bewusst nicht gemacht.
|
||||||
|
|
||||||
|
Lehre für den Rest des Plans: Ein DB-Write ohne unmittelbar folgenden Broadcast ist erst dann ein Loch, wenn auch **alle Aufrufer** geprüft sind. Bei `WorktreeManager.cs:103` (Task 5) war das Loch echt, hier nicht.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Ursprünglicher Task-Text (nicht umgesetzt)</summary>
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`
|
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`
|
||||||
@@ -952,6 +961,8 @@ git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeD
|
|||||||
git commit -m "fix(worker): broadcast TaskUpdated when the list-handler task is created" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs
|
git commit -m "fix(worker): broadcast TaskUpdated when the list-handler task is created" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 8: Totes Event `RunCreated` entfernen
|
### Task 8: Totes Event `RunCreated` entfernen
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Handler-Run: Verknüpfung zu den behandelten Tasks
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Status:** Design approved (Mika), implementation pending
|
||||||
|
**Verified against:** commit `c792765`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
|
||||||
|
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
|
||||||
|
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
|
||||||
|
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
|
||||||
|
bekommt `survivingTaskIds` als flüchtige Liste.
|
||||||
|
|
||||||
|
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
|
||||||
|
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
|
||||||
|
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
|
||||||
|
|
||||||
|
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
|
||||||
|
irreführend, denn es ist kein manueller Reminder.
|
||||||
|
|
||||||
|
## Ist-Zustand
|
||||||
|
|
||||||
|
### Es gibt kein Task-Kind
|
||||||
|
|
||||||
|
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
|
||||||
|
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
|
||||||
|
| `PlanningPhase` | Planning-Parent |
|
||||||
|
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
|
||||||
|
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
|
||||||
|
|
||||||
|
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
|
||||||
|
|
||||||
|
### `ParentTaskId` ist belegt
|
||||||
|
|
||||||
|
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
|
||||||
|
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
|
||||||
|
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
|
||||||
|
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
|
||||||
|
schieben und mit echten Planning-Kindern kollidieren.
|
||||||
|
|
||||||
|
### Badge-Infrastruktur existiert
|
||||||
|
|
||||||
|
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
|
||||||
|
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
|
||||||
|
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
|
||||||
|
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
|
||||||
|
|
||||||
|
### Kinder-Panel existiert
|
||||||
|
|
||||||
|
`DetailsIslandViewModel.LoadChildOutcomesAsync`
|
||||||
|
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
|
||||||
|
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
|
||||||
|
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
|
||||||
|
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
| Frage | Entscheidung | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
|
||||||
|
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
|
||||||
|
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
|
||||||
|
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
|
||||||
|
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Daten
|
||||||
|
|
||||||
|
Neue nullable Spalte auf `TaskEntity`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
|
||||||
|
public string? HandlerTaskId { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
|
||||||
|
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
|
||||||
|
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
|
||||||
|
|
||||||
|
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
|
||||||
|
|
||||||
|
### 2. Schreiben
|
||||||
|
|
||||||
|
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync` →
|
||||||
|
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
|
||||||
|
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
|
||||||
|
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
|
||||||
|
still übersprungen.
|
||||||
|
|
||||||
|
### 3. Badge
|
||||||
|
|
||||||
|
`TaskRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||||
|
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||||
|
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
|
||||||
|
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
|
||||||
|
|
||||||
|
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
|
||||||
|
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
|
||||||
|
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
|
||||||
|
|
||||||
|
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
|
||||||
|
|
||||||
|
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
|
||||||
|
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
|
||||||
|
Tasks, die er bearbeitet hat"
|
||||||
|
|
||||||
|
### 4. Anzeige
|
||||||
|
|
||||||
|
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
|
||||||
|
Kinder-Panel:
|
||||||
|
|
||||||
|
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
|
||||||
|
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
|
||||||
|
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
|
||||||
|
WorktreeState) — keine neue Row-Klasse.
|
||||||
|
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
|
||||||
|
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
|
||||||
|
- Sichtbar nur wenn `HandledTasks.Count > 0`.
|
||||||
|
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
|
||||||
|
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
|
||||||
|
springen" wäre neues Verhalten und ist hier nicht enthalten.
|
||||||
|
|
||||||
|
### 5. Fehlerfälle
|
||||||
|
|
||||||
|
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
|
||||||
|
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
|
||||||
|
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
|
||||||
|
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Ebene | Test |
|
||||||
|
|---|---|
|
||||||
|
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
|
||||||
|
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
|
||||||
|
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
|
||||||
|
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
|
||||||
|
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
|
||||||
|
|
||||||
|
## Bewusst nicht enthalten
|
||||||
|
|
||||||
|
- Kein `TaskKind`-Enum.
|
||||||
|
- Keine n:m-Historie über mehrere Runs.
|
||||||
|
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
|
||||||
|
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
|
||||||
|
- Kein Badge auf den *behandelten* Tasks.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
|
||||||
|
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).
|
||||||
@@ -22,12 +22,14 @@ Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der ei
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
|
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
|
||||||
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
|
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
|
||||||
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131`, `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:447` |
|
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` |
|
||||||
|
|
||||||
|
**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen.
|
||||||
|
|
||||||
Dazu zwei kleinere Befunde:
|
Dazu zwei kleinere Befunde:
|
||||||
|
|
||||||
- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
|
- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
|
||||||
- **Kein Busy-Timeout konfiguriert.** Die Connection-Strings beider Prozesse sind blanke `Data Source=…` (`src/ClaudeDo.App/Program.cs:100-101`, `src/ClaudeDo.Worker/Program.cs:60-61`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1. Achtung: `PRAGMA busy_timeout` ist **per Connection** und wird — anders als `journal_mode=WAL` — *nicht* in der DB-Datei persistiert. Es in `ClaudeDoDbContext.MigrateAndConfigure` zu setzen würde nur die Startup-Connection betreffen und wäre wirkungslos; es gehört in den Connection-String (`Default Timeout=`), den Microsoft.Data.Sqlite auf den Busy-Handler abbildet.
|
- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout` → `30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout.
|
||||||
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.
|
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.
|
||||||
|
|
||||||
### Performance: der Engpass ist das Rendering, nicht die Datenbank
|
### Performance: der Engpass ist das Rendering, nicht die Datenbank
|
||||||
@@ -95,11 +97,9 @@ Unabhängig von Phase 2 und 3, kann sofort starten.
|
|||||||
| Fix | Ort |
|
| Fix | Ort |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
|
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
|
||||||
| `Default Timeout=30` in beide Connection-Strings (nicht als PRAGMA — siehe Analyse) | `App/Program.cs:100-101`, `Worker/Program.cs:60-61` |
|
|
||||||
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
|
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
|
||||||
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
|
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
|
||||||
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
|
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
|
||||||
| `TaskUpdated` nach dem Insert broadcasten | `Runner/InteractiveLaunchSpecService.cs:447` |
|
|
||||||
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
|
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
|
||||||
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
|
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Beyond the basics it carries:
|
|||||||
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
|
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
|
||||||
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
|
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
|
||||||
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
|
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
|
||||||
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
|
| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
|
||||||
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
|
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
|
||||||
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
||||||
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
|||||||
builder.Property(s => s.UsageGateSevenDayPct)
|
builder.Property(s => s.UsageGateSevenDayPct)
|
||||||
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
|
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
|
||||||
|
|
||||||
builder.Property(s => s.UsageThrottleSoftPct)
|
builder.Property(s => s.UsageThrottleFiveHourSoftPct)
|
||||||
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
|
.HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
|
||||||
builder.Property(s => s.UsageThrottleHardPct)
|
builder.Property(s => s.UsageThrottleFiveHourHardPct)
|
||||||
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
|
.HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
|
||||||
|
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
|
||||||
|
builder.Property(s => s.UsageThrottleSevenDayHardPct)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
|
||||||
|
|
||||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||||
}
|
}
|
||||||
|
|||||||
+883
@@ -0,0 +1,883 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClaudeDoDbContext))]
|
||||||
|
[Migration("20260806141710_SplitUsageThrottlePerBucket")]
|
||||||
|
partial class SplitUsageThrottlePerBucket
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("CentralWorktreeRoot")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("central_worktree_root");
|
||||||
|
|
||||||
|
b.Property<int>("DailyPrepMaxTasks")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(5)
|
||||||
|
.HasColumnName("daily_prep_max_tasks");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultClaudeInstructions")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("")
|
||||||
|
.HasColumnName("default_claude_instructions");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultMaxTurns")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(40)
|
||||||
|
.HasColumnName("default_max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultModel")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("sonnet")
|
||||||
|
.HasColumnName("default_model");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultPermissionMode")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("bypassPermissions")
|
||||||
|
.HasColumnName("default_permission_mode");
|
||||||
|
|
||||||
|
b.Property<int>("MaxParallelExecutions")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1)
|
||||||
|
.HasColumnName("max_parallel_executions");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
b.Property<string>("ModelPresets")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
|
b.Property<string>("RepoImportFolders")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
|
b.Property<string>("ReportExcludedPaths")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("report_excluded_paths");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<int>("StandupWeekday")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(3)
|
||||||
|
.HasColumnName("standup_weekday");
|
||||||
|
|
||||||
|
b.Property<int>("UsageGateFiveHourPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("usage_gate_five_hour_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageGateSevenDayPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(90)
|
||||||
|
.HasColumnName("usage_gate_seven_day_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("WorktreeAutoCleanupDays")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(7)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_days");
|
||||||
|
|
||||||
|
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||||
|
|
||||||
|
b.Property<string>("WorktreeStrategy")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("sibling")
|
||||||
|
.HasColumnName("worktree_strategy");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("app_settings", (string)null);
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
DailyPrepMaxTasks = 5,
|
||||||
|
DefaultClaudeInstructions = "",
|
||||||
|
DefaultMaxTurns = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
WorktreeAutoCleanupDays = 7,
|
||||||
|
WorktreeAutoCleanupEnabled = false,
|
||||||
|
WorktreeStrategy = "sibling"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("note_date");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.ToTable("daily_notes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("AgentPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("agent_path");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.Property<string>("VerifyCommand")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("verify_command");
|
||||||
|
|
||||||
|
b.HasKey("ListId");
|
||||||
|
|
||||||
|
b.ToTable("list_config", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultCommitType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("chore")
|
||||||
|
.HasColumnName("default_commit_type");
|
||||||
|
|
||||||
|
b.Property<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("WorkingDir")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("working_dir");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SortOrder")
|
||||||
|
.HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
|
b.ToTable("lists", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<int>("Days")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(31)
|
||||||
|
.HasColumnName("days_of_week");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("enabled");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastRunAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_run_at");
|
||||||
|
|
||||||
|
b.Property<string>("PromptOverride")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("prompt_override");
|
||||||
|
|
||||||
|
b.Property<TimeSpan>("TimeOfDay")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("time_of_day");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("prime_schedules", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AddedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("added_at");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("PinnedRef")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pinned_ref");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("source_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subpath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subpath");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("session_skills", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Completed")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("completed");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNum")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("order_num");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_subtasks_task_id");
|
||||||
|
|
||||||
|
b.ToTable("subtasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("ByteSize")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("byte_size");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("file_name");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_task_attachments_task_id");
|
||||||
|
|
||||||
|
b.ToTable("task_attachments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AgentPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("agent_path");
|
||||||
|
|
||||||
|
b.Property<string>("BlockedByTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("blocked_by_task_id");
|
||||||
|
|
||||||
|
b.Property<string>("CommitType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("chore")
|
||||||
|
.HasColumnName("commit_type");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinishedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
b.Property<string>("HandlerBaseCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("handler_base_commit");
|
||||||
|
|
||||||
|
b.Property<string>("HandlerHeadCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("handler_head_commit");
|
||||||
|
|
||||||
|
b.Property<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMyDay")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_my_day");
|
||||||
|
|
||||||
|
b.Property<bool>("IsStarred")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_starred");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("LogPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("log_path");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<string>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("parent_task_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningPhase")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("none")
|
||||||
|
.HasColumnName("planning_phase");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionToken")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_token");
|
||||||
|
|
||||||
|
b.Property<string>("Result")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result");
|
||||||
|
|
||||||
|
b.Property<string>("ReviewFeedback")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("review_feedback");
|
||||||
|
|
||||||
|
b.Property<int>("RoadblockCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("roadblock_count");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ScheduledFor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BlockedByTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
|
b.HasIndex("ListId")
|
||||||
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
|
b.HasIndex("ListId", "SortOrder")
|
||||||
|
.HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
|
||||||
|
b.ToTable("tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
b.Property<int?>("ExitCode")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("exit_code");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinishedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRetry")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_retry");
|
||||||
|
|
||||||
|
b.Property<string>("LogPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("log_path");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("Prompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("prompt");
|
||||||
|
|
||||||
|
b.Property<string>("ResultMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_markdown");
|
||||||
|
|
||||||
|
b.Property<int>("RunNumber")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("run_number");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<string>("StructuredOutputJson")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("structured_output");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<int?>("TokensIn")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("tokens_in");
|
||||||
|
|
||||||
|
b.Property<int?>("TokensOut")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("tokens_out");
|
||||||
|
|
||||||
|
b.Property<int?>("TurnCount")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("turn_count");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_task_runs_task_id");
|
||||||
|
|
||||||
|
b.ToTable("task_runs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("EndDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("end_date");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("generated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Markdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("markdown");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("StartDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("start_date");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StartDate", "EndDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("week_reports", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<string>("BaseCommit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("base_commit");
|
||||||
|
|
||||||
|
b.Property<string>("BranchName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("branch_name");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("DiffStat")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("diff_stat");
|
||||||
|
|
||||||
|
b.Property<string>("HeadCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("head_commit");
|
||||||
|
|
||||||
|
b.Property<string>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_commit");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("active")
|
||||||
|
.HasColumnName("state");
|
||||||
|
|
||||||
|
b.HasKey("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("worktrees", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
|
.WithOne("Config")
|
||||||
|
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("List");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany("Subtasks")
|
||||||
|
.HasForeignKey("TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BlockedByTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("ListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("List");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany("Runs")
|
||||||
|
.HasForeignKey("TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithOne("Worktree")
|
||||||
|
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Config");
|
||||||
|
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("Runs");
|
||||||
|
|
||||||
|
b.Navigation("Subtasks");
|
||||||
|
|
||||||
|
b.Navigation("Worktree");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SplitUsageThrottlePerBucket : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_seven_day_soft_pct");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "usage_throttle_five_hour_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 65);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "usage_throttle_five_hour_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 50);
|
||||||
|
|
||||||
|
// The old single soft/hard pair was compared against whichever bucket was more utilized,
|
||||||
|
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
|
||||||
|
// the split — the rename above already preserved it for the 7d side.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE app_settings
|
||||||
|
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
|
||||||
|
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "usage_throttle_five_hour_hard_pct",
|
||||||
|
table: "app_settings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "usage_throttle_five_hour_soft_pct",
|
||||||
|
table: "app_settings");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_seven_day_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_soft_pct");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_seven_day_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_hard_pct");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue(90)
|
.HasDefaultValue(90)
|
||||||
.HasColumnName("usage_gate_seven_day_pct");
|
.HasColumnName("usage_gate_seven_day_pct");
|
||||||
|
|
||||||
b.Property<int>("UsageThrottleHardPct")
|
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasDefaultValue(65)
|
.HasDefaultValue(65)
|
||||||
.HasColumnName("usage_throttle_hard_pct");
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
b.Property<int>("UsageThrottleSoftPct")
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasDefaultValue(50)
|
.HasDefaultValue(50)
|
||||||
.HasColumnName("usage_throttle_soft_pct");
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||||
|
|
||||||
b.Property<int>("WorktreeAutoCleanupDays")
|
b.Property<int>("WorktreeAutoCleanupDays")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
StandupWeekday = 3,
|
StandupWeekday = 3,
|
||||||
UsageGateFiveHourPct = 80,
|
UsageGateFiveHourPct = 80,
|
||||||
UsageGateSevenDayPct = 90,
|
UsageGateSevenDayPct = 90,
|
||||||
UsageThrottleHardPct = 65,
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
UsageThrottleSoftPct = 50,
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
WorktreeAutoCleanupDays = 7,
|
WorktreeAutoCleanupDays = 7,
|
||||||
WorktreeAutoCleanupEnabled = false,
|
WorktreeAutoCleanupEnabled = false,
|
||||||
WorktreeStrategy = "sibling"
|
WorktreeStrategy = "sibling"
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ public sealed class AppSettingsEntity
|
|||||||
public int UsageGateFiveHourPct { get; set; } = 80;
|
public int UsageGateFiveHourPct { get; set; } = 80;
|
||||||
public int UsageGateSevenDayPct { get; set; } = 90;
|
public int UsageGateSevenDayPct { get; set; } = 90;
|
||||||
|
|
||||||
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
|
// Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
|
||||||
// parallelism ahead of the hard gate above. 0 = that stage off.
|
// Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
|
||||||
public int UsageThrottleSoftPct { get; set; } = 50;
|
// parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
|
||||||
public int UsageThrottleHardPct { get; set; } = 65;
|
public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
|
||||||
|
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
|
||||||
|
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
|
||||||
|
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public static class ModelRegistry
|
|||||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
||||||
|
|
||||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus", "fable" };
|
||||||
|
|
||||||
public const string DefaultAlias = "sonnet";
|
public const string DefaultAlias = "sonnet";
|
||||||
public const string PlanningAlias = "opus";
|
public const string PlanningAlias = "opus";
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ public sealed class AppSettingsRepository
|
|||||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||||
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
|
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
|
||||||
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
|
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
|
||||||
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
|
row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
|
||||||
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
|
row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
|
||||||
|
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
|
||||||
|
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
|
||||||
|
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,10 @@
|
|||||||
"gateBlockedFormat": "Queue pausiert — {0}",
|
"gateBlockedFormat": "Queue pausiert — {0}",
|
||||||
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
|
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
|
||||||
"resetIn": "Reset in {0}",
|
"resetIn": "Reset in {0}",
|
||||||
|
"dragHint": "Marker ziehen oder Wert unten eintragen.",
|
||||||
|
"legendSoft": "Soft · 2 Slots",
|
||||||
|
"legendHard": "Hard · 1 Slot",
|
||||||
|
"legendGate": "Gate · Pause",
|
||||||
"gaugeSession": "Session (5 Std.)",
|
"gaugeSession": "Session (5 Std.)",
|
||||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||||
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
||||||
@@ -659,7 +663,8 @@
|
|||||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||||
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
|
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
|
||||||
|
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}"
|
||||||
},
|
},
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
|
||||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||||
|
|||||||
@@ -435,6 +435,10 @@
|
|||||||
"gateBlockedFormat": "Queue paused — {0}",
|
"gateBlockedFormat": "Queue paused — {0}",
|
||||||
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
|
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
|
||||||
"resetIn": "Reset in {0}",
|
"resetIn": "Reset in {0}",
|
||||||
|
"dragHint": "Drag a marker, or type the value below.",
|
||||||
|
"legendSoft": "Soft · 2 slots",
|
||||||
|
"legendHard": "Hard · 1 slot",
|
||||||
|
"legendGate": "Gate · pause",
|
||||||
"gaugeSession": "Session (5h)",
|
"gaugeSession": "Session (5h)",
|
||||||
"gaugeWeeklyAll": "Week (all models)",
|
"gaugeWeeklyAll": "Week (all models)",
|
||||||
"gaugeWeeklyScopedFormat": "Week ({0})",
|
"gaugeWeeklyScopedFormat": "Week ({0})",
|
||||||
@@ -659,7 +663,8 @@
|
|||||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Couldn't load usage data: {0}",
|
"loadFailed": "Couldn't load usage data: {0}",
|
||||||
"refreshFailed": "Couldn't refresh usage: {0}"
|
"refreshFailed": "Couldn't refresh usage: {0}",
|
||||||
|
"thresholdSaveFailed": "Couldn't save the threshold: {0}"
|
||||||
},
|
},
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
|
||||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ ViewModels/
|
|||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
||||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, InheritedBadge,
|
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, InheritedBadge,
|
||||||
AgentConfigEditor
|
AgentConfigEditor, UsagePill, UsageGaugeBar
|
||||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
||||||
```
|
```
|
||||||
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
|||||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||||
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
|
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
|
||||||
|
|
||||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||||
|
|||||||
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
|
|||||||
List<ModelPresetDto>? ModelPresets = null,
|
List<ModelPresetDto>? ModelPresets = null,
|
||||||
int UsageGateFiveHourPct = 80,
|
int UsageGateFiveHourPct = 80,
|
||||||
int UsageGateSevenDayPct = 90,
|
int UsageGateSevenDayPct = 90,
|
||||||
int MaxTurnsCeiling = 80);
|
int MaxTurnsCeiling = 80,
|
||||||
|
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||||
|
int UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
int UsageThrottleFiveHourHardPct = 65,
|
||||||
|
int UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
int UsageThrottleSevenDayHardPct = 65);
|
||||||
|
|
||||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||||
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||||
@@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto(
|
|||||||
string? LastError,
|
string? LastError,
|
||||||
int ConfiguredSlots,
|
int ConfiguredSlots,
|
||||||
int EffectiveSlots,
|
int EffectiveSlots,
|
||||||
string? ThrottleBucket);
|
string? ThrottleBucket,
|
||||||
|
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
|
||||||
|
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
|
||||||
|
int ThrottleFiveHourSoftPct = 50,
|
||||||
|
int ThrottleFiveHourHardPct = 65,
|
||||||
|
int ThrottleSevenDaySoftPct = 50,
|
||||||
|
int ThrottleSevenDayHardPct = 65);
|
||||||
|
|
||||||
public sealed record ModelUsageRowDto(
|
public sealed record ModelUsageRowDto(
|
||||||
DateOnly Date,
|
DateOnly Date,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// pick the flag up (see SyncInteractiveSessions).
|
// pick the flag up (see SyncInteractiveSessions).
|
||||||
private readonly HashSet<string> _interactiveSessionIds = new();
|
private readonly HashSet<string> _interactiveSessionIds = new();
|
||||||
private static readonly TaskListFilterRegistry _filters = new();
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
|
||||||
|
// one task can be in flight at once. Only the newest may write to the row.
|
||||||
|
private readonly Dictionary<string, long> _deltaSeq = new();
|
||||||
|
private long _deltaCounter;
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
public event EventHandler? FocusAddTaskRequested;
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
@@ -161,6 +165,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async void OnWorkerTaskUpdated(string taskId)
|
private async void OnWorkerTaskUpdated(string taskId)
|
||||||
|
=> await RefreshTaskFromWorkerAsync(taskId);
|
||||||
|
|
||||||
|
// Awaitable so tests can drive it deterministically. One retry, then a full reload:
|
||||||
|
// a swallowed exception here used to leave the row on a stale status permanently.
|
||||||
|
internal async Task RefreshTaskFromWorkerAsync(string taskId)
|
||||||
{
|
{
|
||||||
var list = _currentList;
|
var list = _currentList;
|
||||||
if (list is null) return;
|
if (list is null) return;
|
||||||
@@ -174,7 +183,31 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seq = ++_deltaCounter;
|
||||||
|
_deltaSeq[taskId] = seq;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
|
}
|
||||||
|
catch (Exception first)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine(
|
||||||
|
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
|
}
|
||||||
|
catch (Exception second)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine(
|
||||||
|
$"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
|
||||||
|
LoadForList(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await db.Tasks
|
var entity = await db.Tasks
|
||||||
@@ -182,6 +215,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
|
||||||
|
// A newer refresh for this task started while we were reading — its result is fresher.
|
||||||
|
if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;
|
||||||
|
|
||||||
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
|
// 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
|
// 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
|
// deletes them. The delta path below only touches the parent row and never recomputes
|
||||||
@@ -221,8 +257,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
|
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
|
||||||
// parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
|
// parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
|
||||||
|
|||||||
@@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
var vm = _usageMonitorVmFactory();
|
var vm = _usageMonitorVmFactory();
|
||||||
vm.ErrorReported += FlashFooterError;
|
vm.ErrorReported += FlashFooterError;
|
||||||
await vm.LoadAsync();
|
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
|
||||||
|
// here left the pill looking unresponsive until the window finally appeared.
|
||||||
|
vm.BeginLoad();
|
||||||
await Dialogs.ShowUsageMonitorAsync(vm);
|
await Dialogs.ShowUsageMonitorAsync(vm);
|
||||||
}
|
}
|
||||||
finally { _usageMonitorOpen = false; }
|
finally { _usageMonitorOpen = false; }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||||
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||||
|
|
||||||
|
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
|
||||||
|
public bool ShowOnlineInbox => false;
|
||||||
|
|
||||||
[ObservableProperty] private string _validationError = "";
|
[ObservableProperty] private string _validationError = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
|
||||||
|
// carried through load→save verbatim so saving Settings can never reset a dragged value.
|
||||||
|
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
|
||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
General.MaxParallelExecutions = dto.MaxParallelExecutions;
|
General.MaxParallelExecutions = dto.MaxParallelExecutions;
|
||||||
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
|
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
|
||||||
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
|
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
|
||||||
|
_throttleStages = (
|
||||||
|
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
|
||||||
|
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
|
||||||
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||||
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||||
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||||
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
General.ModelPresetDtos(),
|
General.ModelPresetDtos(),
|
||||||
General.UsageGateFiveHourPct,
|
General.UsageGateFiveHourPct,
|
||||||
General.UsageGateSevenDayPct,
|
General.UsageGateSevenDayPct,
|
||||||
General.MaxTurnsCeiling);
|
General.MaxTurnsCeiling,
|
||||||
|
_throttleStages.FiveSoft,
|
||||||
|
_throttleStages.FiveHard,
|
||||||
|
_throttleStages.SevenSoft,
|
||||||
|
_throttleStages.SevenHard);
|
||||||
await _worker.UpdateAppSettingsAsync(dto);
|
await _worker.UpdateAppSettingsAsync(dto);
|
||||||
await Prime.SaveAsync();
|
await Prime.SaveAsync();
|
||||||
await OnlineInbox.SaveAsync();
|
await OnlineInbox.SaveAsync();
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
public event Action<string>? ErrorReported;
|
public event Action<string>? ErrorReported;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(GaugeRows))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStale))]
|
[NotifyPropertyChangedFor(nameof(IsStale))]
|
||||||
[NotifyPropertyChangedFor(nameof(LastError))]
|
[NotifyPropertyChangedFor(nameof(LastError))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
|
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
|
||||||
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
|
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
|
||||||
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
|
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
|
||||||
|
|
||||||
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
|
[ObservableProperty]
|
||||||
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
|
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
||||||
|
|
||||||
public bool IsStale => Snapshot?.IsStale == true;
|
public bool IsStale => Snapshot?.IsStale == true;
|
||||||
public string? LastError => Snapshot?.LastError;
|
public string? LastError => Snapshot?.LastError;
|
||||||
@@ -83,7 +82,18 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the load without blocking the caller, so the host can show the window right away and
|
||||||
|
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
|
||||||
|
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
|
||||||
|
/// window made the usage pill look like it swallowed the click.
|
||||||
|
/// </summary>
|
||||||
|
public void BeginLoad() => _ = LoadAsync();
|
||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Snapshot = await _worker.GetUsageSnapshotAsync();
|
Snapshot = await _worker.GetUsageSnapshotAsync();
|
||||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||||
@@ -91,6 +101,12 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
ApplyPresetRange(SelectedPresetDays);
|
ApplyPresetRange(SelectedPresetDays);
|
||||||
await LoadUsageDataAsync();
|
await LoadUsageDataAsync();
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
|
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
|
||||||
|
|
||||||
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
|
||||||
|
/// while the user works the markers doesn't swap the instances out from under the drag.
|
||||||
|
/// </summary>
|
||||||
|
private void SyncGaugeRows()
|
||||||
|
{
|
||||||
|
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
|
||||||
|
var existing = GaugeRows.ToDictionary(r => r.Key);
|
||||||
|
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
|
||||||
|
|
||||||
|
foreach (var limit in limits)
|
||||||
|
{
|
||||||
|
var key = GaugeKey(limit);
|
||||||
|
var bucket = GaugeBucket(limit);
|
||||||
|
var (soft, hard, gate) = StagesFor(bucket);
|
||||||
|
var label = BuildGaugeLabel(limit);
|
||||||
|
|
||||||
|
if (existing.TryGetValue(key, out var row))
|
||||||
|
{
|
||||||
|
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rows.Add(new UsageGaugeRowViewModel(
|
||||||
|
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
|
||||||
|
soft, hard, gate, SaveStagesAsync));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
|
||||||
|
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
|
||||||
|
{
|
||||||
|
"session" => "five_hour",
|
||||||
|
"weekly_all" => "seven_day",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GaugeKey(UsageLimitDto limit) =>
|
||||||
|
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
|
||||||
|
|
||||||
|
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
|
||||||
|
{
|
||||||
|
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
|
||||||
|
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
|
||||||
|
_ => (null, null, null),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
|
||||||
|
/// this never clobbers a field the usage monitor doesn't own.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
|
||||||
|
{
|
||||||
|
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = await _worker.GetAppSettingsAsync();
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = row.Bucket == "five_hour"
|
||||||
|
? settings with
|
||||||
|
{
|
||||||
|
UsageThrottleFiveHourSoftPct = soft,
|
||||||
|
UsageThrottleFiveHourHardPct = hard,
|
||||||
|
UsageGateFiveHourPct = gate,
|
||||||
|
}
|
||||||
|
: settings with
|
||||||
|
{
|
||||||
|
UsageThrottleSevenDaySoftPct = soft,
|
||||||
|
UsageThrottleSevenDayHardPct = hard,
|
||||||
|
UsageGateSevenDayPct = gate,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _worker.UpdateAppSettingsAsync(updated);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
|
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
|
||||||
{
|
{
|
||||||
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
|
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
|
||||||
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
_ => limit.Kind,
|
_ => limit.Kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
|
|
||||||
{
|
|
||||||
int? threshold = limit.Kind switch
|
|
||||||
{
|
|
||||||
"session" => Snapshot?.FiveHourThresholdPct,
|
|
||||||
"weekly_all" => Snapshot?.SevenDayThresholdPct,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
|
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
|
||||||
{
|
{
|
||||||
var built = new List<ModelUsageDisplayRow>();
|
var built = new List<ModelUsageDisplayRow>();
|
||||||
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UsageGaugeRowViewModel(
|
/// <summary>
|
||||||
string Label,
|
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
|
||||||
double Percent,
|
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
|
||||||
string Severity,
|
/// </summary>
|
||||||
DateTimeOffset? ResetsAt,
|
public sealed partial class UsageGaugeRowViewModel : ObservableObject
|
||||||
int? ThresholdPercent)
|
|
||||||
{
|
{
|
||||||
|
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
|
||||||
|
|
||||||
|
public UsageGaugeRowViewModel(
|
||||||
|
string key,
|
||||||
|
string? bucket,
|
||||||
|
string label,
|
||||||
|
double percent,
|
||||||
|
string severity,
|
||||||
|
DateTimeOffset? resetsAt,
|
||||||
|
int? softPct,
|
||||||
|
int? hardPct,
|
||||||
|
int? gatePct,
|
||||||
|
Func<UsageGaugeRowViewModel, Task>? commit = null)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Bucket = bucket;
|
||||||
|
_label = label;
|
||||||
|
_percent = percent;
|
||||||
|
_severity = severity;
|
||||||
|
_resetsAt = resetsAt;
|
||||||
|
_softPct = softPct;
|
||||||
|
_hardPct = hardPct;
|
||||||
|
_gatePct = gatePct;
|
||||||
|
_commit = commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
|
||||||
|
public string Key { get; }
|
||||||
|
|
||||||
|
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
|
||||||
|
public string? Bucket { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string _label;
|
||||||
|
[ObservableProperty] private double _percent;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
|
||||||
|
private string _severity;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ResetText))]
|
||||||
|
private DateTimeOffset? _resetsAt;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||||
|
private int? _softPct;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||||
|
private int? _hardPct;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
|
||||||
|
private int? _gatePct;
|
||||||
|
|
||||||
|
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||||
|
|
||||||
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
|
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
|
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
|
||||||
|
|
||||||
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
|
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
|
||||||
private const double GaugeTrackWidthPx = 216;
|
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
|
||||||
|
int? softPct, int? hardPct, int? gatePct)
|
||||||
|
{
|
||||||
|
Label = label;
|
||||||
|
Percent = percent;
|
||||||
|
Severity = severity;
|
||||||
|
ResetsAt = resetsAt;
|
||||||
|
SoftPct = softPct;
|
||||||
|
HardPct = hardPct;
|
||||||
|
GatePct = gatePct;
|
||||||
|
}
|
||||||
|
|
||||||
public double ThresholdMarkerLeftPx =>
|
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
|
||||||
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
|
[RelayCommand]
|
||||||
|
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
|
||||||
|
// can't invert the order — and only the edited stage moves, never its neighbours.
|
||||||
|
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
|
||||||
|
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
|
||||||
|
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
|
||||||
|
|
||||||
|
private Task CommitStage(UsageThresholdDrag.Stage stage)
|
||||||
|
{
|
||||||
|
if (!IsAdjustable) return Task.CompletedTask;
|
||||||
|
|
||||||
|
var edited = stage switch
|
||||||
|
{
|
||||||
|
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
|
||||||
|
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
|
||||||
|
_ => GatePct!.Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
||||||
|
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
|
||||||
|
SoftPct = soft;
|
||||||
|
HardPct = hard;
|
||||||
|
GatePct = gate;
|
||||||
|
|
||||||
|
return Commit();
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatRemaining(DateTimeOffset resetsAt)
|
private static string FormatRemaining(DateTimeOffset resetsAt)
|
||||||
{
|
{
|
||||||
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
|
||||||
|
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
|
||||||
|
/// at 0 means "that stage is off" and therefore does not constrain anything.
|
||||||
|
/// </summary>
|
||||||
|
public static class UsageThresholdDrag
|
||||||
|
{
|
||||||
|
public enum Stage { Soft, Hard, Gate }
|
||||||
|
|
||||||
|
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
|
||||||
|
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
|
||||||
|
{
|
||||||
|
Stage? best = null;
|
||||||
|
var bestDistance = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
|
||||||
|
{
|
||||||
|
var distance = Math.Abs(percent - value);
|
||||||
|
if (distance > tolerancePercent || distance >= bestDistance) continue;
|
||||||
|
best = stage;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
|
||||||
|
{
|
||||||
|
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
|
||||||
|
|
||||||
|
return stage switch
|
||||||
|
{
|
||||||
|
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
|
||||||
|
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
|
||||||
|
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
|
||||||
|
_ => (soft, hard, gate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
|
||||||
|
private static int UpperBound(int nearest, int fallback) =>
|
||||||
|
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
|
||||||
|
|
||||||
|
// An already-inconsistent stored config (min above max) must not throw mid-drag.
|
||||||
|
private static int ClampRange(int value, int min, int max) =>
|
||||||
|
max < min ? max : Math.Clamp(value, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record ModelUsageDisplayRow(
|
public sealed record ModelUsageDisplayRow(
|
||||||
string Model,
|
string Model,
|
||||||
long ClaudeDoInputTokens,
|
long ClaudeDoInputTokens,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Data;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Rendering;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
|
||||||
|
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
|
||||||
|
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
|
||||||
|
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
|
||||||
|
/// fires once on release, which is when the host persists them.
|
||||||
|
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UsageGaugeBar : Control, ICustomHitTest
|
||||||
|
{
|
||||||
|
/// <summary>How close the pointer has to be to grab a marker.</summary>
|
||||||
|
private const double GrabRadiusPx = 12;
|
||||||
|
|
||||||
|
private const double TrackHeightPx = 10;
|
||||||
|
private const double MarkerWidthPx = 2;
|
||||||
|
|
||||||
|
public static readonly StyledProperty<double> PercentProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsWarnProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<int?> SoftPctProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||||
|
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<int?> HardPctProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||||
|
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<int?> GatePctProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
||||||
|
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> FillBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
|
||||||
|
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
|
||||||
|
|
||||||
|
static UsageGaugeBar()
|
||||||
|
{
|
||||||
|
AffectsRender<UsageGaugeBar>(
|
||||||
|
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
|
||||||
|
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
|
||||||
|
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UsageThresholdDrag.Stage? _dragging;
|
||||||
|
|
||||||
|
public double Percent
|
||||||
|
{
|
||||||
|
get => GetValue(PercentProperty);
|
||||||
|
set => SetValue(PercentProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsWarn
|
||||||
|
{
|
||||||
|
get => GetValue(IsWarnProperty);
|
||||||
|
set => SetValue(IsWarnProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? SoftPct
|
||||||
|
{
|
||||||
|
get => GetValue(SoftPctProperty);
|
||||||
|
set => SetValue(SoftPctProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? HardPct
|
||||||
|
{
|
||||||
|
get => GetValue(HardPctProperty);
|
||||||
|
set => SetValue(HardPctProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? GatePct
|
||||||
|
{
|
||||||
|
get => GetValue(GatePctProperty);
|
||||||
|
set => SetValue(GatePctProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? TrackBrush
|
||||||
|
{
|
||||||
|
get => GetValue(TrackBrushProperty);
|
||||||
|
set => SetValue(TrackBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? FillBrush
|
||||||
|
{
|
||||||
|
get => GetValue(FillBrushProperty);
|
||||||
|
set => SetValue(FillBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? WarnFillBrush
|
||||||
|
{
|
||||||
|
get => GetValue(WarnFillBrushProperty);
|
||||||
|
set => SetValue(WarnFillBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? SoftMarkerBrush
|
||||||
|
{
|
||||||
|
get => GetValue(SoftMarkerBrushProperty);
|
||||||
|
set => SetValue(SoftMarkerBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? HardMarkerBrush
|
||||||
|
{
|
||||||
|
get => GetValue(HardMarkerBrushProperty);
|
||||||
|
set => SetValue(HardMarkerBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IBrush? GateMarkerBrush
|
||||||
|
{
|
||||||
|
get => GetValue(GateMarkerBrushProperty);
|
||||||
|
set => SetValue(GateMarkerBrushProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand? CommitCommand
|
||||||
|
{
|
||||||
|
get => GetValue(CommitCommandProperty);
|
||||||
|
set => SetValue(CommitCommandProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
|
||||||
|
|
||||||
|
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
|
||||||
|
// rectangle takes the pointer — not just the pixels the track happens to cover.
|
||||||
|
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
|
||||||
|
|
||||||
|
public override void Render(DrawingContext context)
|
||||||
|
{
|
||||||
|
var width = Bounds.Width;
|
||||||
|
var height = Bounds.Height;
|
||||||
|
if (width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
|
var top = Math.Max(0, (height - TrackHeightPx) / 2);
|
||||||
|
var trackHeight = Math.Min(TrackHeightPx, height);
|
||||||
|
var radius = trackHeight / 2;
|
||||||
|
|
||||||
|
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
|
||||||
|
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
|
||||||
|
|
||||||
|
if (TrackBrush is { } track)
|
||||||
|
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
|
||||||
|
|
||||||
|
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
|
||||||
|
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
|
||||||
|
if (fillWidth > 0 && fill is not null)
|
||||||
|
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
|
||||||
|
|
||||||
|
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
|
||||||
|
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
|
||||||
|
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
|
||||||
|
{
|
||||||
|
if (percent is not { } value || brush is null) return;
|
||||||
|
|
||||||
|
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
|
||||||
|
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerPressed(e);
|
||||||
|
if (!IsAdjustable) return;
|
||||||
|
|
||||||
|
var percent = PercentAt(e.GetPosition(this).X);
|
||||||
|
_dragging = UsageThresholdDrag.Nearest(
|
||||||
|
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||||
|
if (_dragging is null) return;
|
||||||
|
|
||||||
|
e.Pointer.Capture(this);
|
||||||
|
ApplyDrag(_dragging.Value, percent);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerMoved(PointerEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerMoved(e);
|
||||||
|
if (!IsAdjustable) return;
|
||||||
|
|
||||||
|
var percent = PercentAt(e.GetPosition(this).X);
|
||||||
|
|
||||||
|
if (_dragging is { } stage)
|
||||||
|
{
|
||||||
|
ApplyDrag(stage, percent);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hover = UsageThresholdDrag.Nearest(
|
||||||
|
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
|
||||||
|
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPointerReleased(e);
|
||||||
|
if (_dragging is null) return;
|
||||||
|
|
||||||
|
_dragging = null;
|
||||||
|
e.Pointer.Capture(null);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
if (CommitCommand is { } command && command.CanExecute(null))
|
||||||
|
command.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
|
||||||
|
{
|
||||||
|
var (soft, hard, gate) = UsageThresholdDrag.Apply(
|
||||||
|
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
|
||||||
|
|
||||||
|
SoftPct = soft;
|
||||||
|
HardPct = hard;
|
||||||
|
GatePct = gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
|
||||||
|
|
||||||
|
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
|
||||||
|
}
|
||||||
@@ -390,7 +390,8 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
|
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
|
||||||
|
IsVisible="{Binding ShowOnlineInbox}">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
|
|||||||
public SettingsModalView()
|
public SettingsModalView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDataContextChanged(EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
|||||||
@@ -18,19 +18,6 @@
|
|||||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
|
|
||||||
<Window.Styles>
|
|
||||||
<Style Selector="ProgressBar.usage-gauge">
|
|
||||||
<Setter Property="Height" Value="10"/>
|
|
||||||
<Setter Property="CornerRadius" Value="4"/>
|
|
||||||
<Setter Property="Minimum" Value="0"/>
|
|
||||||
<Setter Property="Maximum" Value="100"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ProgressBar.usage-gauge.warn">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
|
||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
|
||||||
@@ -82,19 +69,60 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
|
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
|
||||||
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
|
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
|
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
|
||||||
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
|
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid Height="10">
|
<ctl:UsageGaugeBar Height="16"
|
||||||
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
|
Percent="{Binding Percent}"
|
||||||
<Canvas IsHitTestVisible="False">
|
IsWarn="{Binding IsWarnSeverity}"
|
||||||
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
|
SoftPct="{Binding SoftPct, Mode=TwoWay}"
|
||||||
Fill="{DynamicResource TextDimBrush}"
|
HardPct="{Binding HardPct, Mode=TwoWay}"
|
||||||
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
|
GatePct="{Binding GatePct, Mode=TwoWay}"
|
||||||
</Canvas>
|
CommitCommand="{Binding CommitCommand}"
|
||||||
|
TrackBrush="{DynamicResource LineBrush}"
|
||||||
|
FillBrush="{DynamicResource AccentBrush}"
|
||||||
|
WarnFillBrush="{DynamicResource StatusReviewBrush}"
|
||||||
|
SoftMarkerBrush="{DynamicResource TextDimBrush}"
|
||||||
|
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
|
||||||
|
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
|
||||||
|
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
|
||||||
|
|
||||||
|
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
|
||||||
|
and each box commits on Enter / focus loss (handlers in the code-behind). -->
|
||||||
|
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
|
||||||
|
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
|
||||||
|
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||||
|
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||||
|
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
|
||||||
|
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
|
||||||
|
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||||
|
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||||
|
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||||
|
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||||
|
|
||||||
|
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||||
|
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||||
|
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
|
||||||
|
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
|
||||||
|
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||||
|
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||||
|
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||||
|
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||||
|
|
||||||
|
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
|
||||||
|
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
|
||||||
|
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
|
||||||
|
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
|
||||||
|
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
|
||||||
|
Minimum="0" Maximum="100" Increment="5" FormatString="0"
|
||||||
|
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
|
||||||
|
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Modals;
|
namespace ClaudeDo.Ui.Views.Modals;
|
||||||
|
|
||||||
public partial class UsageMonitorModalView : Window
|
public partial class UsageMonitorModalView : Window
|
||||||
{
|
{
|
||||||
public UsageMonitorModalView() => InitializeComponent();
|
public UsageMonitorModalView() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
|
||||||
|
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
|
||||||
|
/// </summary>
|
||||||
|
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
|
||||||
|
|
||||||
|
var command = stage switch
|
||||||
|
{
|
||||||
|
"soft" => row.CommitSoftCommand,
|
||||||
|
"hard" => row.CommitHardCommand,
|
||||||
|
_ => row.CommitGateCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (command.CanExecute(null)) command.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key != Key.Enter) return;
|
||||||
|
OnStageBoxCommit(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService
|
|||||||
{
|
{
|
||||||
var dlg = new UsageMonitorModalView { DataContext = vm };
|
var dlg = new UsageMonitorModalView { DataContext = vm };
|
||||||
vm.CloseAction = () => dlg.Close();
|
vm.CloseAction = () => dlg.Close();
|
||||||
await dlg.ShowDialog(_owner);
|
// The pill sits in both the footer and the Mission Control header, so own the dialog to
|
||||||
|
// whichever window is active — otherwise it opens behind Mission Control.
|
||||||
|
await dlg.ShowDialog(ActiveOwner());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ subfolder within their area; the namespace stays the area namespace.
|
|||||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||||
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
- **StaleTaskRecovery** — startup-only; 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 multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
|
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. **Tool-description style** (not test-enforced) is documented in `External/McpToolDocs.cs`, which also holds the shared boilerplate clauses — read it before adding or editing a tool description. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
|
||||||
|
|
||||||
## Status Model
|
## Status Model
|
||||||
|
|
||||||
@@ -159,7 +159,6 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
|
|||||||
- `TaskMessage`
|
- `TaskMessage`
|
||||||
- `WorktreeUpdated`
|
- `WorktreeUpdated`
|
||||||
- `TaskUpdated`
|
- `TaskUpdated`
|
||||||
- `RunCreated`
|
|
||||||
- `ListUpdated`
|
- `ListUpdated`
|
||||||
- `WorkerLog`
|
- `WorkerLog`
|
||||||
- `PrimeFired`
|
- `PrimeFired`
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ public sealed class AgentMcpTools
|
|||||||
|
|
||||||
public AgentMcpTools(AgentFileService agents) => _agents = 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.")]
|
[McpServerTool, Description("List available agent definition files (name, description, path) to pick a value for a task's or list's agentPath override.")]
|
||||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||||
=> await _agents.ScanAsync(cancellationToken);
|
=> await _agents.ScanAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools
|
|||||||
|
|
||||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||||
|
|
||||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")]
|
[McpServerTool, Description("Read the worker's global defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy) that apply when a task/list doesn't override them. Read-only.")]
|
||||||
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|||||||
+11
-12
@@ -33,17 +33,15 @@ public sealed class AttachmentMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Attach a read-only reference file to a task. These files are handed to the agent at run time, " +
|
"Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " +
|
||||||
"making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " +
|
"context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" +
|
||||||
"Pass textContent for plain-text files (plans, markdown, scripts). " +
|
"base64Content is required. Re-attaching the same fileName overwrites the previous version." +
|
||||||
"Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " +
|
McpToolDocs.NotWhileRunning)]
|
||||||
"Re-attaching a file with the same fileName overwrites the previous version. " +
|
|
||||||
"Refuses if the task is currently Running — cancel it first.")]
|
|
||||||
public async Task<AttachmentDto> AddTaskAttachment(
|
public async Task<AttachmentDto> AddTaskAttachment(
|
||||||
string taskId,
|
string taskId,
|
||||||
string fileName,
|
[Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName,
|
||||||
string? textContent = null,
|
[Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null,
|
||||||
string? base64Content = null,
|
[Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, ct)
|
var task = await _tasks.GetByIdAsync(taskId, ct)
|
||||||
@@ -94,7 +92,8 @@ public sealed class AttachmentMcpTools
|
|||||||
return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow);
|
return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")]
|
[McpServerTool, Description(
|
||||||
|
"List all attachments on a task — use to check what reference files are already attached before adding more.")]
|
||||||
public async Task<IReadOnlyList<AttachmentDto>> ListTaskAttachments(
|
public async Task<IReadOnlyList<AttachmentDto>> ListTaskAttachments(
|
||||||
string taskId, CancellationToken ct = default)
|
string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -103,8 +102,8 @@ public sealed class AttachmentMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Remove a single attachment from a task. Deletes both the file on disk and the database record. " +
|
"Remove a single attachment from a task, deleting both the file on disk and its database record." +
|
||||||
"Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")]
|
McpToolDocs.NotWhileRunning)]
|
||||||
public async Task<RemoveAttachmentResult> RemoveTaskAttachment(
|
public async Task<RemoveAttachmentResult> RemoveTaskAttachment(
|
||||||
string taskId, string fileName, CancellationToken ct = default)
|
string taskId, string fileName, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
+39
-35
@@ -3,8 +3,14 @@ using ModelContextProtocol.Server;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.External;
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null);
|
public sealed record BatchAddTaskInput(
|
||||||
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
|
string Title,
|
||||||
|
[property: Description("Task description/instructions for the agent.")] string? Description = null,
|
||||||
|
[property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null);
|
||||||
|
public sealed record BatchSetMyDayInput(
|
||||||
|
string TaskId,
|
||||||
|
[property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay,
|
||||||
|
[property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null);
|
||||||
|
|
||||||
// task is populated when found and includeDescription=false (the default, lean reference);
|
// task is populated when found and includeDescription=false (the default, lean reference);
|
||||||
// taskFull is populated when found and includeDescription=true (full task incl.
|
// taskFull is populated when found and includeDescription=true (full task incl.
|
||||||
@@ -34,14 +40,14 @@ public sealed class BatchMcpTools
|
|||||||
public BatchMcpTools(ExternalMcpService svc) => _svc = svc;
|
public BatchMcpTools(ExternalMcpService svc) => _svc = svc;
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " +
|
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
|
||||||
"Returns one result per id: { id, found, task, taskFull, error }. " +
|
"calling get_task per id. A missing id comes back as found=false, not an error; error is only set " +
|
||||||
"includeDescription=false (default): found tasks come back in `task` (lean reference, no " +
|
"for an unexpected failure." + McpToolDocs.MaxBatch)]
|
||||||
"Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " +
|
|
||||||
"Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " +
|
|
||||||
"error is only set for an unexpected failure. Max 100 ids.")]
|
|
||||||
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
|
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
|
||||||
string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default)
|
string[] taskIds,
|
||||||
|
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " +
|
||||||
|
"(default), return a lean reference in `task`.")] bool includeDescription = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
EnsureWithinCap(taskIds, nameof(taskIds));
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
|
||||||
@@ -75,19 +81,15 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Create many tasks in one list at once. Each item: { title, description?, model? } " +
|
"Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " +
|
||||||
"(model: haiku|sonnet|opus, blank = inherit list/global default). " +
|
"Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " +
|
||||||
"queueImmediately enqueues every created task. " +
|
"heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." +
|
||||||
"Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " +
|
McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)]
|
||||||
"a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " +
|
|
||||||
"sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " +
|
|
||||||
"tasks in the same list with a strongly overlapping title, id/title/status only); check it and " +
|
|
||||||
"mention any hit to the caller, but do not treat it as an error. Max 100 items.")]
|
|
||||||
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
|
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
|
||||||
string listId,
|
string listId,
|
||||||
BatchAddTaskInput[] tasks,
|
BatchAddTaskInput[] tasks,
|
||||||
string? createdBy = null,
|
string? createdBy = null,
|
||||||
bool queueImmediately = false,
|
[Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
EnsureWithinCap(tasks, nameof(tasks));
|
EnsureWithinCap(tasks, nameof(tasks));
|
||||||
@@ -113,11 +115,13 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " +
|
"Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " +
|
||||||
"same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " +
|
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree." +
|
||||||
"Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
|
McpToolDocs.MaxBatch)]
|
||||||
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
|
||||||
string[] taskIds, string status, CancellationToken cancellationToken)
|
string[] taskIds,
|
||||||
|
[Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
EnsureWithinCap(taskIds, nameof(taskIds));
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
return await RunPerTaskAsync(taskIds,
|
return await RunPerTaskAsync(taskIds,
|
||||||
@@ -125,9 +129,8 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Cancel many running tasks at once. Returns one result per id: " +
|
"Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " +
|
||||||
"{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " +
|
"ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)]
|
||||||
"Max 100 ids.")]
|
|
||||||
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
|
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
|
||||||
string[] taskIds, CancellationToken cancellationToken)
|
string[] taskIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -151,8 +154,8 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Delete many tasks at once. A Running task is refused (cancel it first) and reported " +
|
"Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." +
|
||||||
"as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
|
McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
|
||||||
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
|
||||||
string[] taskIds, CancellationToken cancellationToken)
|
string[] taskIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -162,9 +165,9 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " +
|
"Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " +
|
||||||
"Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
|
"Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
|
||||||
"(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")]
|
"(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)]
|
||||||
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
|
||||||
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
|
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -188,12 +191,13 @@ public sealed class BatchMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Remove the worktrees of many tasks at once (directory + git branch). " +
|
"Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " +
|
||||||
"force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " +
|
"of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
|
||||||
"even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " +
|
|
||||||
"Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")]
|
|
||||||
public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
|
public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
|
||||||
string[] taskIds, bool force = false, CancellationToken cancellationToken = default)
|
string[] taskIds,
|
||||||
|
[Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " +
|
||||||
|
"is still refused either way.")] bool force = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
EnsureWithinCap(taskIds, nameof(taskIds));
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
|
||||||
|
|||||||
+11
-13
@@ -46,7 +46,7 @@ public sealed class ConfigMcpTools
|
|||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")]
|
[McpServerTool, Description("Read a list's default run config — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")]
|
||||||
public async Task<TaskConfigResult> GetListConfig(string listId, CancellationToken cancellationToken)
|
public async Task<TaskConfigResult> GetListConfig(string listId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
||||||
@@ -56,9 +56,8 @@ public sealed class ConfigMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " +
|
"Set a list's default model/system prompt/agent path/max turns — the fallback for tasks in this list " +
|
||||||
"config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " +
|
"that don't override them. Passing all four as null clears the list config instead of setting one.")]
|
||||||
"the fields that were set (a field is null there if it was individually left unset/cleared).")]
|
|
||||||
public async Task<SetListConfigResult> SetListConfig(
|
public async Task<SetListConfigResult> SetListConfig(
|
||||||
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||||
@@ -90,9 +89,8 @@ public sealed class ConfigMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " +
|
"Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " +
|
||||||
"clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " +
|
"list's default config for this one task. Pass null for any field to clear that override.")]
|
||||||
"null there if it was cleared or never set).")]
|
|
||||||
public async Task<SetTaskConfigResult> SetTaskConfig(
|
public async Task<SetTaskConfigResult> SetTaskConfig(
|
||||||
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||||
@@ -109,7 +107,7 @@ public sealed class ConfigMcpTools
|
|||||||
return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns));
|
return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns));
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")]
|
[McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")]
|
||||||
public async Task<TaskConfigResult> GetTaskConfig(string taskId, CancellationToken cancellationToken)
|
public async Task<TaskConfigResult> GetTaskConfig(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -120,11 +118,11 @@ public sealed class ConfigMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " +
|
"Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " +
|
||||||
"whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " +
|
"path, whether a system prompt is set, and skill names — each tagged with its source " +
|
||||||
"Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" +
|
"(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " +
|
||||||
"get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " +
|
"values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " +
|
||||||
"whether it was clamped to the global ceiling. Read-only, no side effects.")]
|
"to the global ceiling.")]
|
||||||
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
|
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
|||||||
+133
-159
@@ -26,7 +26,6 @@ public sealed record CancelTaskResult(bool Cancelled, string Id);
|
|||||||
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
|
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
|
||||||
// child) contributed nothing, so a reviewer sees them before approving instead of after.
|
// child) contributed nothing, so a reviewer sees them before approving instead of after.
|
||||||
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
|
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
|
||||||
public sealed record StatusValueDto(string Status, string Meaning);
|
|
||||||
public sealed record RunTaskNowResult(bool Started, string TaskId);
|
public sealed record RunTaskNowResult(bool Started, string TaskId);
|
||||||
|
|
||||||
public sealed record TaskDto(
|
public sealed record TaskDto(
|
||||||
@@ -161,7 +160,8 @@ public sealed class ExternalMcpService
|
|||||||
_planningMerge = planningMerge;
|
_planningMerge = planningMerge;
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
[McpServerTool, Description(
|
||||||
|
"List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")]
|
||||||
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var lists = await _lists.GetAllAsync(cancellationToken);
|
var lists = await _lists.GetAllAsync(cancellationToken);
|
||||||
@@ -169,17 +169,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")]
|
||||||
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " +
|
|
||||||
"includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " +
|
|
||||||
"use this unless you actually need the description text, since a list of verbosely-described tasks can " +
|
|
||||||
"otherwise blow past the response size limit. " +
|
|
||||||
"includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " +
|
|
||||||
"null in that case.")]
|
|
||||||
public async Task<ListTasksResult> ListTasks(
|
public async Task<ListTasksResult> ListTasks(
|
||||||
string listId,
|
string listId,
|
||||||
|
[Description("Only return tasks with this CreatedBy value.")]
|
||||||
string? createdBy = null,
|
string? createdBy = null,
|
||||||
|
[Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " +
|
||||||
|
"WaitingForChildren, Done, Failed or Cancelled.")]
|
||||||
string? status = null,
|
string? status = null,
|
||||||
|
[Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " +
|
||||||
|
"need the description text, since verbosely-described tasks can blow past the response size " +
|
||||||
|
"limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")]
|
||||||
bool includeDescription = false,
|
bool includeDescription = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -206,10 +206,12 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Get a single task by id, including its current status and result. " +
|
"Get a single task by id, including its current status and result — the canonical reference for what a " +
|
||||||
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
"status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
||||||
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
|
"A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " +
|
||||||
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
|
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
|
||||||
|
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
|
||||||
|
"is done but whose children are still running.")]
|
||||||
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -227,21 +229,19 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
|
"Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
|
||||||
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
|
"heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
|
||||||
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
|
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef)]
|
||||||
"Leave model null to inherit the list/global default. " +
|
|
||||||
"Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " +
|
|
||||||
"sortOrder, isMyDay) — not the description you just sent. The task is always created — " +
|
|
||||||
"possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " +
|
|
||||||
"strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " +
|
|
||||||
"but do not treat it as an error.")]
|
|
||||||
public async Task<AddTaskResult> AddTask(
|
public async Task<AddTaskResult> AddTask(
|
||||||
string listId,
|
string listId,
|
||||||
string title,
|
string title,
|
||||||
string? description = null,
|
string? description = null,
|
||||||
string? createdBy = null,
|
string? createdBy = null,
|
||||||
|
[Description("true: enqueue the task for agent execution right away.")]
|
||||||
bool queueImmediately = false,
|
bool queueImmediately = false,
|
||||||
|
[Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " +
|
||||||
|
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
||||||
|
"list/global default (normally sonnet).")]
|
||||||
string? model = null,
|
string? model = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -355,8 +355,7 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." +
|
"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. Returns a lean task reference (id, listId, title, status, " +
|
McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||||
"sortOrder, isMyDay) — not the description you just sent.")]
|
|
||||||
public async Task<TaskRefDto> UpdateTask(
|
public async Task<TaskRefDto> UpdateTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string? title = null,
|
string? title = null,
|
||||||
@@ -380,12 +379,12 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
"Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " +
|
||||||
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " +
|
"task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||||
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
||||||
public async Task<TaskRefDto> AddSubtask(
|
public async Task<TaskRefDto> AddSubtask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string title,
|
string title,
|
||||||
|
[Description("Position among the existing steps; defaults to the end.")]
|
||||||
int? orderNum = null,
|
int? orderNum = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -419,16 +418,14 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
|
"Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
|
||||||
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
|
"review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef)]
|
||||||
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
|
|
||||||
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
|
|
||||||
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
|
|
||||||
"and merge that worktree instead). " +
|
|
||||||
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
|
||||||
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
||||||
public async Task<TaskRefDto> UpdateTaskStatus(
|
public async Task<TaskRefDto> UpdateTaskStatus(
|
||||||
string taskId,
|
string taskId,
|
||||||
|
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
|
||||||
|
"deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
|
||||||
|
"active worktree — use review_task to approve and merge that worktree instead). No other " +
|
||||||
|
"value is settable externally.")]
|
||||||
string status,
|
string status,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -482,27 +479,29 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Review a task that is WaitingForReview. " +
|
"Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " +
|
||||||
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
|
"'approve' is 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 " +
|
"targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " +
|
||||||
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
|
"Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " +
|
||||||
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
|
"not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " +
|
||||||
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
|
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
|
||||||
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
|
||||||
"or abort_merge to cancel. " +
|
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
|
||||||
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
|
"delivered something." + McpToolDocs.LeanTaskRef)]
|
||||||
"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). " +
|
|
||||||
"The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description. " +
|
|
||||||
"emptyChildren (parent approve only) lists the Done children about to be unit-merged whose own review range " +
|
|
||||||
"contributed nothing (e.g. a child that reported CLAUDEDO_BLOCKED and committed no code) — check it before " +
|
|
||||||
"trusting that every child actually delivered something.")]
|
|
||||||
public async Task<ReviewTaskResult> ReviewTask(
|
public async Task<ReviewTaskResult> ReviewTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
|
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
|
||||||
string decision,
|
string decision,
|
||||||
|
[Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " +
|
||||||
|
"this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " +
|
||||||
|
"just returns the task to Idle for manual editing.")]
|
||||||
string? feedback = null,
|
string? feedback = null,
|
||||||
|
[Description("Branch an approve merges into; defaults to the repo's current branch.")]
|
||||||
string? targetBranch = null,
|
string? targetBranch = null,
|
||||||
|
[Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " +
|
||||||
|
"no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " +
|
||||||
|
"in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
||||||
|
"or abort_merge to cancel.")]
|
||||||
bool leaveConflictsInTree = false,
|
bool leaveConflictsInTree = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -634,7 +633,10 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")]
|
[McpServerTool, Description(
|
||||||
|
"Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" +
|
||||||
|
"occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " +
|
||||||
|
"enqueue via update_task_status instead of retrying in a loop.")]
|
||||||
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
|
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -653,7 +655,8 @@ public sealed class ExternalMcpService
|
|||||||
return new RunTaskNowResult(true, taskId);
|
return new RunTaskNowResult(true, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
|
[McpServerTool, Description(
|
||||||
|
"Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
|
||||||
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var cancelled = _queue.CancelTask(taskId);
|
var cancelled = _queue.CancelTask(taskId);
|
||||||
@@ -661,7 +664,9 @@ public sealed class ExternalMcpService
|
|||||||
return new CancelTaskResult(cancelled, taskId);
|
return new CancelTaskResult(cancelled, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
|
[McpServerTool, Description(
|
||||||
|
"Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." +
|
||||||
|
McpToolDocs.NotWhileRunning)]
|
||||||
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -676,31 +681,12 @@ public sealed class ExternalMcpService
|
|||||||
return new DeleteTaskResult(true, taskId);
|
return new DeleteTaskResult(true, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Status reference ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
|
||||||
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
|
|
||||||
Task.FromResult<IReadOnlyList<StatusValueDto>>([
|
|
||||||
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
|
||||||
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
|
||||||
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
|
||||||
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
|
||||||
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
|
|
||||||
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
|
||||||
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
|
||||||
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Worktree / git tools ──────────────────────────────────────────────────
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
|
"Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
|
||||||
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
|
"mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
|
||||||
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
|
"read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
|
||||||
"isDirty (has uncommitted changes in the worktree directory), " +
|
|
||||||
"mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " +
|
|
||||||
"if it has been merged and that succeeded after this field was introduced; null otherwise — " +
|
|
||||||
"required by revert_merge). " +
|
|
||||||
"Throws if the task or its worktree does not exist.")]
|
"Throws if the task or its worktree does not exist.")]
|
||||||
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -718,16 +704,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Get the diff for a task's worktree relative to its base commit. For a worktree-less " +
|
"Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
|
||||||
"list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " +
|
"list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
|
||||||
"HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " +
|
"instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
|
||||||
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
|
"totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
|
||||||
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
|
"directory is missing from disk.")]
|
||||||
"files always lists the changed file paths regardless of stat mode. " +
|
|
||||||
"totalBytes is the uncapped diff size (useful when truncated=true). " +
|
|
||||||
"Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")]
|
|
||||||
public async Task<TaskDiffDto> GetTaskDiff(
|
public async Task<TaskDiffDto> GetTaskDiff(
|
||||||
string taskId, bool stat = false, CancellationToken cancellationToken = default)
|
string taskId,
|
||||||
|
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
|
||||||
|
"per-file insertion/deletion counts — start here when the diff may be large.")]
|
||||||
|
bool stat = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
|
||||||
|
|
||||||
@@ -782,21 +769,21 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Merge a task's worktree branch into targetBranch (default: main). " +
|
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
|
||||||
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
|
||||||
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
"merged=false and conflicts lists the affected files.")]
|
||||||
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
|
|
||||||
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
|
||||||
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
|
|
||||||
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
|
|
||||||
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
|
|
||||||
"or abort_merge to cancel.")]
|
|
||||||
public async Task<MergeTaskResultDto> MergeTask(
|
public async Task<MergeTaskResultDto> MergeTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string targetBranch = "main",
|
string targetBranch = "main",
|
||||||
|
[Description("true (default): always create a merge commit (--no-ff).")]
|
||||||
bool noFf = true,
|
bool noFf = true,
|
||||||
|
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
|
||||||
bool dryRun = false,
|
bool dryRun = false,
|
||||||
|
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
|
||||||
bool allowWaitingForReview = false,
|
bool allowWaitingForReview = false,
|
||||||
|
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
|
||||||
|
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
|
||||||
|
"resolve them there and call continue_merge, or abort_merge to cancel.")]
|
||||||
bool leaveConflictsInTree = false,
|
bool leaveConflictsInTree = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -848,11 +835,9 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
|
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
|
||||||
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
|
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
|
||||||
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
|
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
|
||||||
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
|
|
||||||
"lists the affected files — resolve them and call continue_merge again. " +
|
|
||||||
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
|
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
|
||||||
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
|
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -920,10 +905,8 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
||||||
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
|
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
|
||||||
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
|
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
|
||||||
"Throws if there is no in-progress merge for the task. " +
|
|
||||||
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
||||||
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -945,21 +928,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " +
|
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
|
||||||
"current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " +
|
"working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " +
|
||||||
"status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " +
|
"would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " +
|
||||||
"lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " +
|
"when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " +
|
||||||
"you can spot a stale branch even when the preview itself is clean. " +
|
"passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " +
|
||||||
"IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " +
|
"still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " +
|
||||||
"can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " +
|
"rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " +
|
||||||
"break the build. " +
|
"nor a handler commit range, or the list's working directory is missing from disk.")]
|
||||||
"isEmpty=true means the task's review range contributed nothing (no commits ahead of base, or — for a " +
|
|
||||||
"worktree-less list-handler host task — HandlerBaseCommit == HandlerHeadCommit); do not mistake a small " +
|
|
||||||
"changedFileCount for an empty one, check isEmpty instead. " +
|
|
||||||
"Throws a clear error if the task has neither an active worktree nor a handler commit range, or the " +
|
|
||||||
"list's working directory is missing from disk.")]
|
|
||||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||||
string taskId,
|
string taskId,
|
||||||
|
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||||
string? targetBranch = null,
|
string? targetBranch = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -968,18 +947,16 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Merge preview plus file-overlap check across several tasks at once, all previewed against the same " +
|
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
||||||
"targetBranch (default: the repo's current branch). For each taskId returns the same fields as " +
|
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
||||||
"preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " +
|
"be previewed (it is then left out of the overlap computation). overlaps names, for each file touched by " +
|
||||||
"be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " +
|
"MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no overlaps. " +
|
||||||
"touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " +
|
"IMPORTANT: overlap is a HINT and its absence is not safety — two tasks touching entirely different files " +
|
||||||
"which tasks touch it — passing a single taskId always yields an empty overlaps list. " +
|
"(one deleting a symbol, another still referencing it) can still collide unflagged, and as with " +
|
||||||
"IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " +
|
"preview_merge a clean result does not mean the merge builds.")]
|
||||||
"guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " +
|
|
||||||
"still references it elsewhere) can still collide, and this tool will not flag that case. " +
|
|
||||||
"isEmpty=true (per entry) means that task's review range contributed nothing — see preview_merge.")]
|
|
||||||
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
||||||
IReadOnlyList<string> taskIds,
|
IReadOnlyList<string> taskIds,
|
||||||
|
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
||||||
string? targetBranch = null,
|
string? targetBranch = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -1076,18 +1053,17 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " +
|
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
|
||||||
"a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " +
|
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
|
||||||
"Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " +
|
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
|
||||||
"before this feature existed have no recorded commit and are refused rather than guessed via git log. " +
|
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
|
||||||
"On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " +
|
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
|
||||||
"WaitingForReview so it can be reconsidered. " +
|
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
|
||||||
"On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " +
|
|
||||||
"left in the tree) and conflicts lists the files that would have conflicted. " +
|
|
||||||
"Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " +
|
|
||||||
"tree has uncommitted changes from another session.")]
|
|
||||||
public async Task<RevertMergeResultDto> RevertMerge(
|
public async Task<RevertMergeResultDto> RevertMerge(
|
||||||
string taskId, string targetBranch = "main", CancellationToken cancellationToken = default)
|
string taskId,
|
||||||
|
[Description("Branch carrying the merge commit; defaults to main.")]
|
||||||
|
string targetBranch = "main",
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
|
||||||
|
|
||||||
@@ -1104,10 +1080,8 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"List all ClaudeDo-tracked worktrees. " +
|
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
|
||||||
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
|
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk.")]
|
||||||
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
|
|
||||||
"Only worktrees recorded in the ClaudeDo database are returned.")]
|
|
||||||
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
||||||
@@ -1125,12 +1099,14 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Remove a task's worktree directory and delete its git branch. " +
|
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
|
||||||
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
|
"went too." + McpToolDocs.NotWhileRunning)]
|
||||||
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
|
|
||||||
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
|
|
||||||
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
||||||
string taskId, bool force = false, CancellationToken cancellationToken = default)
|
string taskId,
|
||||||
|
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
|
||||||
|
"those changes.")]
|
||||||
|
bool force = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
|
||||||
@@ -1155,10 +1131,9 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
|
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
|
||||||
"The agent resumes using --resume with the session ID from the task's last run. " +
|
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
|
||||||
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
|
"same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")]
|
||||||
"Returns a status string from the execution slot.")]
|
|
||||||
public async Task<string> ContinueTask(
|
public async Task<string> ContinueTask(
|
||||||
string taskId,
|
string taskId,
|
||||||
string followUpPrompt,
|
string followUpPrompt,
|
||||||
@@ -1187,10 +1162,10 @@ public sealed class ExternalMcpService
|
|||||||
// ── Daily prep ───────────────────────────────────────────────────────────
|
// ── Daily prep ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
|
"Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " +
|
||||||
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
|
"non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " +
|
||||||
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
|
"flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " +
|
||||||
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
|
"never exceed maxTasks.")]
|
||||||
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||||
@@ -1225,14 +1200,13 @@ public sealed class ExternalMcpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
|
"Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " +
|
||||||
"(use consecutive sortOrder values to keep related tasks together). " +
|
"(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." +
|
||||||
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
|
McpToolDocs.LeanTaskRef)]
|
||||||
"clearing (isMyDay=false) is always allowed. " +
|
|
||||||
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
|
|
||||||
public async Task<TaskRefDto> SetMyDay(
|
public async Task<TaskRefDto> SetMyDay(
|
||||||
string taskId,
|
string taskId,
|
||||||
bool isMyDay,
|
bool isMyDay,
|
||||||
|
[Description("Position in the MyDay list; use consecutive values to keep related tasks together.")]
|
||||||
int? sortOrder = null,
|
int? sortOrder = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-6
@@ -20,13 +20,15 @@ public sealed class HandoffMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " +
|
"Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " +
|
||||||
"ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " +
|
"to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " +
|
||||||
"context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " +
|
"dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " +
|
||||||
"it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " +
|
"HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " +
|
||||||
"and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")]
|
"immediately after calling this.")]
|
||||||
public async Task<HandoffListHandlerResult> HandoffListHandler(
|
public async Task<HandoffListHandlerResult> HandoffListHandler(
|
||||||
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken cancellationToken)
|
[Description("This session's own handler task id.")] string taskId,
|
||||||
|
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (survivingTaskIds.Count == 0)
|
if (survivingTaskIds.Count == 0)
|
||||||
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
|
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ public sealed class LifecycleMcpTools
|
|||||||
_reset = reset;
|
_reset = reset;
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")]
|
[McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")]
|
||||||
public async Task<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
public async Task<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
|||||||
+15
-5
@@ -21,9 +21,14 @@ public sealed class ListMcpTools
|
|||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
|
[McpServerTool, Description("Create a new task list — the top-level grouping tasks belong to, with its own working dir, commit type, and default run config.")]
|
||||||
public async Task<ListSummaryDto> CreateList(
|
public async Task<ListSummaryDto> CreateList(
|
||||||
string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default)
|
string name,
|
||||||
|
[Description("Absolute local path to an existing git repository this list's tasks will run against. Not validated here — the first task run fails if the path isn't an actual git repo. Omit to run this list's tasks in a throwaway sandbox with no worktree.")]
|
||||||
|
string? workingDir = null,
|
||||||
|
[Description("Conventional-commit-style type prefix for this list's task commits (e.g. 'feat', 'fix'). Defaults to 'chore'.")]
|
||||||
|
string? commitType = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
throw new InvalidOperationException("name is required.");
|
throw new InvalidOperationException("name is required.");
|
||||||
@@ -41,9 +46,14 @@ public sealed class ListMcpTools
|
|||||||
return ToDto(entity);
|
return ToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
|
[McpServerTool, Description("Rename a list, or change its working dir / default commit type without recreating it. Pass null for any field to leave it unchanged.")]
|
||||||
public async Task<ListSummaryDto> UpdateList(
|
public async Task<ListSummaryDto> UpdateList(
|
||||||
string listId, string? name = null, string? workingDir = null, string? commitType = null,
|
string listId,
|
||||||
|
string? name = null,
|
||||||
|
[Description("Absolute local path to an existing git repository this list's tasks will run against; not validated until the next task runs. Null leaves it unchanged; pass an empty string to clear it and switch this list to sandbox-only task runs.")]
|
||||||
|
string? workingDir = null,
|
||||||
|
[Description("New default commit type prefix for this list's task commits. Null leaves it unchanged.")]
|
||||||
|
string? commitType = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||||
@@ -62,7 +72,7 @@ public sealed class ListMcpTools
|
|||||||
return ToDto(entity);
|
return ToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")]
|
[McpServerTool, Description("Permanently delete a list and all its tasks — no undo. Only for removing the whole list, not a single task within it.")]
|
||||||
public async Task<DeleteListResult> DeleteList(string listId, CancellationToken cancellationToken)
|
public async Task<DeleteListResult> DeleteList(string listId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boilerplate clauses shared by several external MCP tool descriptions. Every tool description is
|
||||||
|
/// still emitted in full to the client — these constants only stop the wording from drifting apart
|
||||||
|
/// across ~50 attributes.
|
||||||
|
///
|
||||||
|
/// Description style (keep new tools in line with it):
|
||||||
|
/// 1. First sentence says what the tool does AND when to reach for it — MCP clients rank tools by
|
||||||
|
/// this text, so the trigger must not be buried behind return-shape prose.
|
||||||
|
/// 2. Then only non-obvious preconditions and refusals.
|
||||||
|
/// 3. Document parameters with [Description] on the parameter, not in the tool description.
|
||||||
|
/// 4. Describe result fields only where the caller must branch on them (isEmpty, truncated,
|
||||||
|
/// conflicts, …). Everything else is visible in the first actual response.
|
||||||
|
/// 5. No design rationale or "since this feature was introduced" history.
|
||||||
|
/// Budget: ~400 chars for a simple tool, ~800 for the merge/review family.
|
||||||
|
/// </summary>
|
||||||
|
internal static class McpToolDocs
|
||||||
|
{
|
||||||
|
/// <summary>Warns that the payload is the lean reference, not the task's description/result.</summary>
|
||||||
|
public const string LeanTaskRef = " Returns a lean task reference, not the task's description.";
|
||||||
|
|
||||||
|
/// <summary>Batch-size cap shared by every BatchMcpTools entry point.</summary>
|
||||||
|
public const string MaxBatch = " Max 100 per call.";
|
||||||
|
|
||||||
|
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
|
||||||
|
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
|
||||||
|
}
|
||||||
+6
-9
@@ -28,15 +28,12 @@ public sealed class QueueStateMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Read-only snapshot of the execution queue -- observe slot occupancy instead of inferring " +
|
"Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " +
|
||||||
"it from maxParallelExecutions. Result: { configuredSlots, effectiveSlots, activeSlots: " +
|
"of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " +
|
||||||
"[{ slot, taskId, startedAt }], waitingTaskIds }. configuredSlots is Settings -> " +
|
"by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " +
|
||||||
"MaxParallelExecutions; effectiveSlots is that value stepped down by the usage throttle " +
|
"shows whether throttling is currently active. Each active slot is \"queue\" (a normal " +
|
||||||
"(lower when the 5h/7d usage window is filling up) -- compare the two to see whether " +
|
"queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " +
|
||||||
"throttling is currently active. activeSlots lists every task presently holding an " +
|
"lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")]
|
||||||
"execution slot, with slot \"queue\" for a normal queue slot or \"override\" for the single " +
|
|
||||||
"run_task_now/continue_task slot. waitingTaskIds lists queued, unblocked, non-manual, due " +
|
|
||||||
"tasks in the order the queue would pick them next.")]
|
|
||||||
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
|
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
|
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
|
||||||
|
|||||||
+11
-10
@@ -24,14 +24,17 @@ public sealed class RunHistoryMcpTools
|
|||||||
|
|
||||||
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
|
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
|
||||||
|
|
||||||
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
|
[McpServerTool, Description(
|
||||||
|
"List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " +
|
||||||
|
"oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " +
|
||||||
|
"get_run to fetch it individually.")]
|
||||||
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
|
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
|
||||||
return runs.Select(ToDto).ToList();
|
return runs.Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Get a single execution run by its run id.")]
|
[McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")]
|
||||||
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
|
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var run = await _runs.GetByIdAsync(runId, cancellationToken)
|
var run = await _runs.GetByIdAsync(runId, cancellationToken)
|
||||||
@@ -40,18 +43,16 @@ public sealed class RunHistoryMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Fetch log entries from a task's latest run. " +
|
"Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " +
|
||||||
"Returns { available, entries, totalLines, truncated }. " +
|
"opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " +
|
||||||
"available=false means no log exists yet (task is queued or just started — not an error). " +
|
"or just started — not an error); truncated=true when fewer entries are returned than totalLines.")]
|
||||||
"entries are the individual lines (NDJSON messages) from Claude's streaming output. " +
|
|
||||||
"Default: returns the last 50 entries (tail=50). " +
|
|
||||||
"tail: override the number of trailing entries to return. " +
|
|
||||||
"offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " +
|
|
||||||
"truncated=true when fewer entries are returned than totalLines.")]
|
|
||||||
public async Task<TaskLogResult> GetTaskLog(
|
public async Task<TaskLogResult> GetTaskLog(
|
||||||
string taskId,
|
string taskId,
|
||||||
|
[Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")]
|
||||||
int? tail = null,
|
int? tail = null,
|
||||||
|
[Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")]
|
||||||
int? offset = null,
|
int? offset = null,
|
||||||
|
[Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")]
|
||||||
int? limit = null,
|
int? limit = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
+17
-12
@@ -29,19 +29,24 @@ public sealed class TaskWaitMcpTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " +
|
"Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " +
|
||||||
"(clamped server-side to 900s). Returns immediately if any task is already outside Queued/Running " +
|
"polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " +
|
||||||
"when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " +
|
"(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " +
|
||||||
"of polling get_task in a loop. Pitfall: a planning parent with children goes Running -> " +
|
"goes Running -> WaitingForChildren while its children are still working, so by default " +
|
||||||
"WaitingForChildren while its children are still working, and by default that counts as \"changed\" -- " +
|
"waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " +
|
||||||
"so waiting on a parent returns immediately even though the work isn't done. Set " +
|
"claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " +
|
||||||
"treatWaitingForChildrenAsBusy=true to keep waiting through WaitingForChildren; the call then only " +
|
"held open -- ClaudeDo's own launchers already set this.")]
|
||||||
"returns once the parent reaches WaitingForReview or a terminal status (default: false, unchanged " +
|
|
||||||
"legacy behavior). Requires the calling claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for " +
|
|
||||||
"a long wait to actually be held open -- ClaudeDo's own launchers already set this. " +
|
|
||||||
"Result: { changed: [{ taskId, status }], timedOut }.")]
|
|
||||||
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
|
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
|
||||||
string[] taskIds, int timeoutSeconds = 60, bool treatWaitingForChildrenAsBusy = false,
|
string[] taskIds,
|
||||||
|
[Description(
|
||||||
|
"How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " +
|
||||||
|
"regardless of what's passed.")]
|
||||||
|
int timeoutSeconds = 60,
|
||||||
|
[Description(
|
||||||
|
"When true, WaitingForChildren still counts as busy, so waiting on a planning parent " +
|
||||||
|
"continues until it reaches WaitingForReview or a terminal status instead of returning " +
|
||||||
|
"as soon as it leaves Running.")]
|
||||||
|
bool treatWaitingForChildrenAsBusy = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (taskIds.Length == 0)
|
if (taskIds.Length == 0)
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
public Task ListUpdated(string listId) =>
|
public Task ListUpdated(string listId) =>
|
||||||
_hub.Clients.All.SendAsync("ListUpdated", listId);
|
_hub.Clients.All.SendAsync("ListUpdated", listId);
|
||||||
|
|
||||||
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
|
|
||||||
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
|
|
||||||
|
|
||||||
public Task UsageUpdated(UsageSnapshotDto snapshot) =>
|
public Task UsageUpdated(UsageSnapshotDto snapshot) =>
|
||||||
_hub.Clients.All.SendAsync("UsageUpdated", snapshot);
|
_hub.Clients.All.SendAsync("UsageUpdated", snapshot);
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ public record AppSettingsDto(
|
|||||||
List<ModelPresetDto>? ModelPresets = null,
|
List<ModelPresetDto>? ModelPresets = null,
|
||||||
int UsageGateFiveHourPct = 80,
|
int UsageGateFiveHourPct = 80,
|
||||||
int UsageGateSevenDayPct = 90,
|
int UsageGateSevenDayPct = 90,
|
||||||
int MaxTurnsCeiling = 80);
|
int MaxTurnsCeiling = 80,
|
||||||
|
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||||
|
int UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
int UsageThrottleFiveHourHardPct = 65,
|
||||||
|
int UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
int UsageThrottleSevenDayHardPct = 65);
|
||||||
|
|
||||||
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
||||||
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||||
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
|
|||||||
string? LastError,
|
string? LastError,
|
||||||
int ConfiguredSlots,
|
int ConfiguredSlots,
|
||||||
int EffectiveSlots,
|
int EffectiveSlots,
|
||||||
string? ThrottleBucket);
|
string? ThrottleBucket,
|
||||||
|
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
|
||||||
|
int ThrottleFiveHourSoftPct,
|
||||||
|
int ThrottleFiveHourHardPct,
|
||||||
|
int ThrottleSevenDaySoftPct,
|
||||||
|
int ThrottleSevenDayHardPct);
|
||||||
|
|
||||||
public record ModelUsageRowDto(
|
public record ModelUsageRowDto(
|
||||||
DateOnly Date,
|
DateOnly Date,
|
||||||
@@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
|
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
|
||||||
row.UsageGateFiveHourPct,
|
row.UsageGateFiveHourPct,
|
||||||
row.UsageGateSevenDayPct,
|
row.UsageGateSevenDayPct,
|
||||||
row.MaxTurnsCeiling);
|
row.MaxTurnsCeiling,
|
||||||
|
row.UsageThrottleFiveHourSoftPct,
|
||||||
|
row.UsageThrottleFiveHourHardPct,
|
||||||
|
row.UsageThrottleSevenDaySoftPct,
|
||||||
|
row.UsageThrottleSevenDayHardPct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||||
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
|
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
|
||||||
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
|
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
|
||||||
MaxTurnsCeiling = dto.MaxTurnsCeiling,
|
MaxTurnsCeiling = dto.MaxTurnsCeiling,
|
||||||
|
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
|
||||||
|
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
|
||||||
|
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
|
||||||
|
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Online.Interfaces;
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -15,19 +16,22 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
private readonly IOnlineAuthProvider _auth;
|
private readonly IOnlineAuthProvider _auth;
|
||||||
private readonly OnlineInboxConfig _config;
|
private readonly OnlineInboxConfig _config;
|
||||||
private readonly ILogger<OnlineSyncService> _logger;
|
private readonly ILogger<OnlineSyncService> _logger;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
public OnlineSyncService(
|
public OnlineSyncService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
IOnlineInboxApi api,
|
IOnlineInboxApi api,
|
||||||
IOnlineAuthProvider auth,
|
IOnlineAuthProvider auth,
|
||||||
OnlineInboxConfig config,
|
OnlineInboxConfig config,
|
||||||
ILogger<OnlineSyncService> logger)
|
ILogger<OnlineSyncService> logger,
|
||||||
|
HubBroadcaster broadcaster)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_api = api;
|
_api = api;
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -129,6 +133,8 @@ public sealed class OnlineSyncService : BackgroundService
|
|||||||
CommitType = CommitTypeRegistry.DefaultType,
|
CommitType = CommitTypeRegistry.DefaultType,
|
||||||
};
|
};
|
||||||
await tasks.AddAsync(entity, ct);
|
await tasks.AddAsync(entity, ct);
|
||||||
|
// Without this the imported task only shows up after a manual reload.
|
||||||
|
await _broadcaster.TaskUpdated(entity.Id);
|
||||||
await _api.MarkImportedAsync(remote.Id, ct);
|
await _api.MarkImportedAsync(remote.Id, ct);
|
||||||
|
|
||||||
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
||||||
|
|||||||
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
|
|||||||
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
|
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
int configured;
|
int configured;
|
||||||
int softPct, hardPct, gateFivePct, gateSevenPct;
|
UsageThresholds fiveHour, sevenDay;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var settings = await new AppSettingsRepository(context).GetAsync(ct);
|
var settings = await new AppSettingsRepository(context).GetAsync(ct);
|
||||||
configured = Math.Max(1, settings.MaxParallelExecutions);
|
configured = Math.Max(1, settings.MaxParallelExecutions);
|
||||||
softPct = settings.UsageThrottleSoftPct;
|
fiveHour = new UsageThresholds(
|
||||||
hardPct = settings.UsageThrottleHardPct;
|
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
|
||||||
gateFivePct = settings.UsageGateFiveHourPct;
|
settings.UsageGateFiveHourPct);
|
||||||
gateSevenPct = settings.UsageGateSevenDayPct;
|
sevenDay = new UsageThresholds(
|
||||||
|
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
|
||||||
|
settings.UsageGateSevenDayPct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var effective = UsageThrottle.EffectiveSlots(
|
var effective = UsageThrottle.EffectiveSlots(
|
||||||
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
configured,
|
||||||
softPct, hardPct, gateFivePct, gateSevenPct);
|
snapshot.FiveHour?.Utilization, fiveHour,
|
||||||
|
snapshot.SevenDay?.Utilization, sevenDay);
|
||||||
|
|
||||||
ReportThrottleTransition(configured, effective, snapshot);
|
ReportThrottleTransition(configured, effective, snapshot);
|
||||||
return (configured, effective);
|
return (configured, effective);
|
||||||
@@ -343,9 +346,28 @@ public sealed class QueueService : BackgroundService
|
|||||||
|
|
||||||
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
|
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Cancellation is driven by the cancel path, which already wrote the terminal status.
|
||||||
|
// Marking the task Failed here would be a regression (it would stomp Cancelled).
|
||||||
|
_logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
|
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
|
||||||
|
|
||||||
|
// The picker already committed status='running' before this ran. Without this the
|
||||||
|
// task stays Running forever and the UI never hears about it — it keeps showing the
|
||||||
|
// pre-claim status because the raw-SQL claim itself never broadcasts.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _state.FailAsync(taskId, DateTime.UtcNow,
|
||||||
|
$"Slot runner error: {ex.Message}", CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception failEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ public sealed class TaskRunner
|
|||||||
{
|
{
|
||||||
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
|
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
|
||||||
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
|
||||||
|
// The worktrees row was just inserted; without this the UI keeps showing the task
|
||||||
|
// as having no worktree until some unrelated event happens to refresh it.
|
||||||
|
await _broadcaster.WorktreeUpdated(task.Id);
|
||||||
return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
|
return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -355,8 +358,6 @@ public sealed class TaskRunner
|
|||||||
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
|
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
|
|
||||||
|
|
||||||
var arguments = _argsBuilder.Build(config);
|
var arguments = _argsBuilder.Build(config);
|
||||||
|
|
||||||
await using var logWriter = new LogWriter(logPath);
|
await using var logWriter = new LogWriter(logPath);
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
|||||||
|
|
||||||
if (Directory.Exists(_projectsRoot))
|
if (Directory.Exists(_projectsRoot))
|
||||||
{
|
{
|
||||||
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
|
// A transcript last written before the window began cannot hold a record inside it, so
|
||||||
|
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
|
||||||
|
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
|
||||||
|
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
|
||||||
|
|
||||||
|
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (file.LastWriteTime < mtimeCutoff) continue;
|
||||||
|
|
||||||
foreach (var record in GetOrReadFile(file))
|
foreach (var record in GetOrReadFile(file))
|
||||||
{
|
{
|
||||||
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
|||||||
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
|
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
|
||||||
return Task.FromResult<SessionUsageTotals?>(null);
|
return Task.FromResult<SessionUsageTotals?>(null);
|
||||||
|
|
||||||
var file = Directory
|
var file = new DirectoryInfo(_projectsRoot)
|
||||||
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories)
|
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
|
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
|
||||||
|
|
||||||
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
|
|||||||
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
|
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<UsageMessageRecord> GetOrReadFile(string file)
|
private List<UsageMessageRecord> GetOrReadFile(FileInfo info)
|
||||||
{
|
{
|
||||||
var info = new FileInfo(file);
|
if (_cache.TryGetValue(info.FullName, out var cached) &&
|
||||||
if (_cache.TryGetValue(file, out var cached) &&
|
|
||||||
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
|
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
|
||||||
{
|
{
|
||||||
return cached.Records;
|
return cached.Records;
|
||||||
}
|
}
|
||||||
|
|
||||||
var records = ReadFile(file);
|
var records = ReadFile(info.FullName);
|
||||||
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
_cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder
|
|||||||
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
|
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
var fiveHourThresholds = new UsageThresholds(
|
||||||
|
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct);
|
||||||
|
var sevenDayThresholds = new UsageThresholds(
|
||||||
|
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct);
|
||||||
|
|
||||||
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
|
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
|
||||||
var effectiveSlots = snapshot is null || lastError is not null
|
var effectiveSlots = snapshot is null || lastError is not null
|
||||||
? configuredSlots
|
? configuredSlots
|
||||||
: UsageThrottle.EffectiveSlots(
|
: UsageThrottle.EffectiveSlots(
|
||||||
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
|
configuredSlots,
|
||||||
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct,
|
snapshot.FiveHour?.Utilization, fiveHourThresholds,
|
||||||
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct);
|
snapshot.SevenDay?.Utilization, sevenDayThresholds);
|
||||||
var throttleBucket = effectiveSlots < configuredSlots
|
var throttleBucket = effectiveSlots < configuredSlots
|
||||||
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
|
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
|
||||||
: null;
|
: null;
|
||||||
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
|
|||||||
lastError,
|
lastError,
|
||||||
configuredSlots,
|
configuredSlots,
|
||||||
effectiveSlots,
|
effectiveSlots,
|
||||||
throttleBucket);
|
throttleBucket,
|
||||||
|
fiveHourThresholds.SoftPct,
|
||||||
|
fiveHourThresholds.HardPct,
|
||||||
|
sevenDayThresholds.SoftPct,
|
||||||
|
sevenDayThresholds.HardPct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
|
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
namespace ClaudeDo.Worker.Usage;
|
namespace ClaudeDo.Worker.Usage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage
|
/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
|
||||||
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of
|
/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
|
||||||
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more
|
/// </summary>
|
||||||
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only —
|
public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
|
||||||
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
|
|
||||||
/// directly (fail-open), same policy as <see cref="UsageGate"/>.
|
/// <summary>
|
||||||
|
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as a usage window
|
||||||
|
/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running
|
||||||
|
/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and
|
||||||
|
/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket
|
||||||
|
/// (null) never throttles — callers with no snapshot at all should skip this and use
|
||||||
|
/// <paramref name="configuredSlots"/> directly (fail-open), same policy as <see cref="UsageGate"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class UsageThrottle
|
public static class UsageThrottle
|
||||||
{
|
{
|
||||||
public static int EffectiveSlots(
|
public static int EffectiveSlots(
|
||||||
int configuredSlots,
|
int configuredSlots,
|
||||||
double? fiveHourPct,
|
double? fiveHourPct,
|
||||||
|
UsageThresholds fiveHour,
|
||||||
double? sevenDayPct,
|
double? sevenDayPct,
|
||||||
int softPct,
|
UsageThresholds sevenDay)
|
||||||
int hardPct,
|
|
||||||
int gateFiveHourPct,
|
|
||||||
int gateSevenDayPct)
|
|
||||||
{
|
{
|
||||||
var slots = Math.Max(1, configuredSlots);
|
var slots = Math.Max(1, configuredSlots);
|
||||||
|
|
||||||
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct)
|
return Math.Min(
|
||||||
return 0;
|
BucketSlots(slots, fiveHourPct, fiveHour),
|
||||||
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct)
|
BucketSlots(slots, sevenDayPct, sevenDay));
|
||||||
return 0;
|
}
|
||||||
|
|
||||||
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
|
private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds)
|
||||||
|
{
|
||||||
|
if (pct is not { } utilization) return slots;
|
||||||
|
|
||||||
if (hardPct > 0 && worst >= hardPct)
|
if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
|
||||||
return Math.Min(slots, 1);
|
if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
|
||||||
if (softPct > 0 && worst >= softPct)
|
if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
|
||||||
return Math.Min(slots, 2);
|
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ public class ModelRegistryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ByCostAscending_is_haiku_sonnet_opus()
|
public void ByCostAscending_is_haiku_sonnet_opus_fable()
|
||||||
{
|
{
|
||||||
Assert.Equal(new[] { "haiku", "sonnet", "opus" }, ModelRegistry.ByCostAscending);
|
Assert.Equal(new[] { "haiku", "sonnet", "opus", "fable" }, ModelRegistry.ByCostAscending);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ public class SettingsModalViewModelTests
|
|||||||
SessionSkills: null, ModelPresets: null,
|
SessionSkills: null, ModelPresets: null,
|
||||||
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
|
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_carries_dragged_throttle_stages_through_untouched()
|
||||||
|
{
|
||||||
|
// The throttle stages are only editable by dragging the usage-monitor gauges. Saving the
|
||||||
|
// Settings modal rebuilds the whole DTO, so it must not reset them to the defaults.
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
AppToReturn = DtoWith(65, 95) with
|
||||||
|
{
|
||||||
|
UsageThrottleFiveHourSoftPct = 42,
|
||||||
|
UsageThrottleFiveHourHardPct = 58,
|
||||||
|
UsageThrottleSevenDaySoftPct = 71,
|
||||||
|
UsageThrottleSevenDayHardPct = 88,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var vm = MakeVm(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
await vm.SaveCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.NotNull(worker.Saved);
|
||||||
|
Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct);
|
||||||
|
Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct);
|
||||||
|
Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct);
|
||||||
|
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
|
||||||
|
}
|
||||||
|
|
||||||
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
|
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
|
||||||
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
|
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
|
||||||
MakeLocalizer(), new AppSettings());
|
MakeLocalizer(), new AppSettings());
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
// The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single
|
||||||
|
// transient DB error therefore left the row on its old status forever — the "task stuck on
|
||||||
|
// Queued although it is running" bug. It must retry, and fall back to a full reload.
|
||||||
|
public class TasksIslandDeltaResilienceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public TasksIslandDeltaResilienceTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throws on the first N CreateDbContext calls, then behaves normally.
|
||||||
|
private sealed class FlakyDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
private int _failuresLeft;
|
||||||
|
public int CreateCalls { get; private set; }
|
||||||
|
|
||||||
|
public FlakyDbFactory(Func<ClaudeDoDbContext> create, int failuresLeft)
|
||||||
|
{
|
||||||
|
_create = create;
|
||||||
|
_failuresLeft = failuresLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClaudeDoDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
CreateCalls++;
|
||||||
|
if (_failuresLeft > 0)
|
||||||
|
{
|
||||||
|
_failuresLeft--;
|
||||||
|
throw new InvalidOperationException("simulated transient DB failure");
|
||||||
|
}
|
||||||
|
return _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FailNext() => _failuresLeft++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList.
|
||||||
|
private static ListNavItemViewModel UserList(string listEntityId, string name) =>
|
||||||
|
new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name };
|
||||||
|
|
||||||
|
// LoadForList is void and fires a background task; this is the wait idiom the other
|
||||||
|
// TasksIsland test files use.
|
||||||
|
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
|
||||||
|
{
|
||||||
|
vm.LoadForList(list);
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
await Task.Delay(25);
|
||||||
|
if (vm.Items.Count > 0) break;
|
||||||
|
}
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedAsync()
|
||||||
|
{
|
||||||
|
await using var db = NewContext();
|
||||||
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||||
|
db.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "T1", ListId = "L1", Title = "Task one",
|
||||||
|
Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status()
|
||||||
|
{
|
||||||
|
await SeedAsync();
|
||||||
|
|
||||||
|
var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0);
|
||||||
|
var vm = new TasksIslandViewModel(flaky, new FakeWorker());
|
||||||
|
var list = UserList("L1", "Work");
|
||||||
|
|
||||||
|
await LoadAndWaitAsync(vm, list);
|
||||||
|
Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status);
|
||||||
|
|
||||||
|
// Worker flips the task to Running.
|
||||||
|
await using (var db = NewContext())
|
||||||
|
{
|
||||||
|
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
|
||||||
|
t.Status = TaskStatus.Running;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next delta read fails once; the retry must still land the new status.
|
||||||
|
flaky.FailNext();
|
||||||
|
await vm.RefreshTaskFromWorkerAsync("T1");
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_stale_delta_result_does_not_overwrite_a_newer_one()
|
||||||
|
{
|
||||||
|
await SeedAsync();
|
||||||
|
|
||||||
|
var factory = new FlakyDbFactory(NewContext, failuresLeft: 0);
|
||||||
|
var vm = new TasksIslandViewModel(factory, new FakeWorker());
|
||||||
|
var list = UserList("L1", "Work");
|
||||||
|
await LoadAndWaitAsync(vm, list);
|
||||||
|
|
||||||
|
// Start refresh #1 while the DB still says Queued, but do not await it yet.
|
||||||
|
var first = vm.RefreshTaskFromWorkerAsync("T1");
|
||||||
|
|
||||||
|
await using (var db = NewContext())
|
||||||
|
{
|
||||||
|
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
|
||||||
|
t.Status = TaskStatus.Running;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh #2 sees Running and must win, regardless of completion order.
|
||||||
|
var second = vm.RefreshTaskFromWorkerAsync("T1");
|
||||||
|
|
||||||
|
await Task.WhenAll(first, second);
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
|
|||||||
public int RefreshCalls;
|
public int RefreshCalls;
|
||||||
public Exception? RefreshThrows;
|
public Exception? RefreshThrows;
|
||||||
|
|
||||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
/// <summary>When set, the snapshot fetch never completes — stands in for the slow first
|
||||||
|
/// transcript scan on the worker side.</summary>
|
||||||
|
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
|
||||||
|
public Exception? SnapshotThrows;
|
||||||
|
|
||||||
|
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
||||||
|
{
|
||||||
|
if (SnapshotThrows is not null) throw SnapshotThrows;
|
||||||
|
return SnapshotGate?.Task ?? Task.FromResult(Snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
|
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
|
||||||
{
|
{
|
||||||
@@ -53,7 +62,24 @@ public class UsageMonitorModalViewModelTests
|
|||||||
TaskUsageCalls++;
|
TaskUsageCalls++;
|
||||||
return Task.FromResult(TaskRows);
|
return Task.FromResult(TaskRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppSettingsDto? AppSettings;
|
||||||
|
public AppSettingsDto? SavedSettings;
|
||||||
|
|
||||||
|
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppSettings);
|
||||||
|
|
||||||
|
public override Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||||
|
{
|
||||||
|
SavedSettings = dto;
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppSettingsDto AppSettings() =>
|
||||||
|
new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30,
|
||||||
|
DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling",
|
||||||
|
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7,
|
||||||
|
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
|
||||||
|
|
||||||
private static UsageLimitDto Limit(
|
private static UsageLimitDto Limit(
|
||||||
string kind, double percent = 10, string severity = "normal",
|
string kind, double percent = 10, string severity = "normal",
|
||||||
@@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests
|
|||||||
DateTime? fetchedAtUtc = null,
|
DateTime? fetchedAtUtc = null,
|
||||||
int configuredSlots = 1,
|
int configuredSlots = 1,
|
||||||
int effectiveSlots = 1,
|
int effectiveSlots = 1,
|
||||||
string? throttleBucket = null)
|
string? throttleBucket = null,
|
||||||
|
int throttleFiveHourSoftPct = 50,
|
||||||
|
int throttleFiveHourHardPct = 65,
|
||||||
|
int throttleSevenDaySoftPct = 50,
|
||||||
|
int throttleSevenDayHardPct = 65)
|
||||||
=> new(
|
=> new(
|
||||||
null, null, null, null,
|
null, null, null, null,
|
||||||
limits ?? Array.Empty<UsageLimitDto>(),
|
limits ?? Array.Empty<UsageLimitDto>(),
|
||||||
fiveHourThresholdPct, sevenDayThresholdPct,
|
fiveHourThresholdPct, sevenDayThresholdPct,
|
||||||
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
|
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
|
||||||
configuredSlots, effectiveSlots, throttleBucket);
|
configuredSlots, effectiveSlots, throttleBucket,
|
||||||
|
throttleFiveHourSoftPct, throttleFiveHourHardPct,
|
||||||
|
throttleSevenDaySoftPct, throttleSevenDayHardPct);
|
||||||
|
|
||||||
|
// ── BeginLoad: the modal must open before the data lands ────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource<UsageSnapshotDto?>() };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
|
||||||
|
vm.BeginLoad();
|
||||||
|
|
||||||
|
Assert.True(vm.IsBusy);
|
||||||
|
Assert.False(vm.ModelsEmpty);
|
||||||
|
Assert.False(vm.TasksEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
string? reported = null;
|
||||||
|
vm.ErrorReported += m => reported = m;
|
||||||
|
|
||||||
|
vm.BeginLoad();
|
||||||
|
|
||||||
|
Assert.NotNull(reported);
|
||||||
|
Assert.Contains("worker offline", reported);
|
||||||
|
Assert.False(vm.IsBusy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
string? reported = null;
|
||||||
|
vm.ErrorReported += m => reported = m;
|
||||||
|
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
Assert.NotNull(reported);
|
||||||
|
Assert.False(vm.IsBusy);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manual refresh ──────────────────────────────────────────────────────
|
// ── Manual refresh ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests
|
|||||||
var vm = new UsageMonitorModalViewModel(worker);
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
|
Assert.Equal(80, vm.GaugeRows[0].GatePct);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests
|
|||||||
var vm = new UsageMonitorModalViewModel(worker);
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
await vm.LoadAsync();
|
await vm.LoadAsync();
|
||||||
|
|
||||||
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
|
Assert.Null(vm.GaugeRows[0].GatePct);
|
||||||
|
Assert.False(vm.GaugeRows[0].IsAdjustable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draggable stage markers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GaugeRow_Session_CarriesPerBucketThrottleStages()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||||
|
throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
Assert.Equal("five_hour", row.Bucket);
|
||||||
|
Assert.Equal(45, row.SoftPct);
|
||||||
|
Assert.Equal(60, row.HardPct);
|
||||||
|
Assert.Equal(80, row.GatePct);
|
||||||
|
Assert.True(row.IsAdjustable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
|
||||||
|
throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
Assert.Equal("seven_day", row.Bucket);
|
||||||
|
Assert.Equal(70, row.SoftPct);
|
||||||
|
Assert.Equal(85, row.HardPct);
|
||||||
|
Assert.Equal(90, row.GatePct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||||
|
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
row.SoftPct = 40;
|
||||||
|
row.HardPct = 55;
|
||||||
|
row.GatePct = 75;
|
||||||
|
await row.CommitCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.NotNull(worker.SavedSettings);
|
||||||
|
Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
|
||||||
|
Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct);
|
||||||
|
Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct);
|
||||||
|
// The 7d bucket and unrelated settings ride along untouched.
|
||||||
|
Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct);
|
||||||
|
Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct);
|
||||||
|
Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct);
|
||||||
|
Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("session") }),
|
||||||
|
AppSettings = null,
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
string? reported = null;
|
||||||
|
vm.ErrorReported += m => reported = m;
|
||||||
|
|
||||||
|
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.NotNull(reported);
|
||||||
|
Assert.Null(worker.SavedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Commit_NonAdjustableRow_SavesNothing()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Null(worker.SavedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances()
|
||||||
|
{
|
||||||
|
// A poll landing mid-interaction must not swap the row the gauge is bound to.
|
||||||
|
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
var before = vm.GaugeRows[0];
|
||||||
|
|
||||||
|
vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44);
|
||||||
|
|
||||||
|
Assert.Same(before, vm.GaugeRows[0]);
|
||||||
|
Assert.Equal(55, vm.GaugeRows[0].Percent);
|
||||||
|
Assert.Equal(44, vm.GaugeRows[0].SoftPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LiveSnapshot_NewLimitKind_AddsARow()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") });
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.GaugeRows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legend input boxes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TypedStage_SavesTheEditedBucket()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
row.HardPct = 58;
|
||||||
|
await row.CommitHardCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct);
|
||||||
|
Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct);
|
||||||
|
Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||||
|
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
// Typing 95 into the soft box may not push past hard — and must not drag hard along.
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
row.SoftPct = 95;
|
||||||
|
await row.CommitSoftCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(65, row.SoftPct);
|
||||||
|
Assert.Equal(65, row.HardPct);
|
||||||
|
Assert.Equal(80, row.GatePct);
|
||||||
|
Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TypedGate_BelowHard_IsPinnedToHard()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
|
||||||
|
throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
var row = vm.GaugeRows[0];
|
||||||
|
row.GatePct = 20;
|
||||||
|
await row.CommitGateCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(65, row.GatePct);
|
||||||
|
Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TypedStage_OnNonAdjustableRow_SavesNothing()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker
|
||||||
|
{
|
||||||
|
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
|
||||||
|
AppSettings = AppSettings(),
|
||||||
|
};
|
||||||
|
var vm = new UsageMonitorModalViewModel(worker);
|
||||||
|
await vm.LoadAsync();
|
||||||
|
|
||||||
|
await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Null(worker.SavedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag math ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard
|
||||||
|
[InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard
|
||||||
|
[InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0
|
||||||
|
[InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate
|
||||||
|
[InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft
|
||||||
|
[InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate
|
||||||
|
[InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100
|
||||||
|
[InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard
|
||||||
|
public void Drag_KeepsStagesOrderedAndInRange(
|
||||||
|
double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate)
|
||||||
|
{
|
||||||
|
var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo);
|
||||||
|
|
||||||
|
Assert.Equal((expectedSoft, expectedHard, expectedGate), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drag_RoundsToWholePercent()
|
||||||
|
{
|
||||||
|
Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker()
|
||||||
|
{
|
||||||
|
// hard = 0 means "hard stage off" — soft must still be draggable up to the gate.
|
||||||
|
Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drag_InconsistentStoredConfig_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// soft above gate (only reachable by hand-editing the DB) must degrade, not crash.
|
||||||
|
var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Hard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(50, UsageThresholdDrag.Stage.Soft)]
|
||||||
|
[InlineData(63, UsageThresholdDrag.Stage.Hard)]
|
||||||
|
[InlineData(82, UsageThresholdDrag.Stage.Gate)]
|
||||||
|
public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nearest_OutOfReach_GrabsNothing()
|
||||||
|
{
|
||||||
|
Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stale / gate bands ───────────────────────────────────────────────────
|
// ── Stale / gate bands ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.Equal(10, result.Config.MaxTurns);
|
Assert.Equal(10, result.Config.MaxTurns);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GetTaskStatusValues ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetTaskStatusValues_ContainsAllStatuses()
|
|
||||||
{
|
|
||||||
var sut = NewService();
|
|
||||||
var values = await sut.GetTaskStatusValues();
|
|
||||||
var names = values.Select(v => v.Status).ToHashSet();
|
|
||||||
|
|
||||||
foreach (var status in Enum.GetValues<TaskStatus>())
|
|
||||||
Assert.Contains(status.ToString(), names);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ListTasks status filter ───────────────────────────────────────────────
|
// ── ListTasks status filter ───────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable
|
|||||||
var repo = new AppSettingsRepository(ctx);
|
var repo = new AppSettingsRepository(ctx);
|
||||||
var settings = await repo.GetAsync();
|
var settings = await repo.GetAsync();
|
||||||
settings.MaxParallelExecutions = maxParallel;
|
settings.MaxParallelExecutions = maxParallel;
|
||||||
settings.UsageThrottleSoftPct = softPct;
|
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||||
settings.UsageThrottleHardPct = hardPct;
|
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||||
|
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||||
|
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||||
await repo.UpdateAsync(settings);
|
await repo.UpdateAsync(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Online;
|
using ClaudeDo.Worker.Online;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -55,7 +56,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private OnlineSyncService BuildService(FakeApi api, string? token = "test-token", bool enabled = true)
|
private OnlineSyncService BuildService(
|
||||||
|
FakeApi api, string? token = "test-token", bool enabled = true, HubBroadcaster? broadcaster = null)
|
||||||
{
|
{
|
||||||
var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 };
|
var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 };
|
||||||
var auth = new StaticTokenAuthProvider(token);
|
var auth = new StaticTokenAuthProvider(token);
|
||||||
@@ -64,7 +66,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
|||||||
api,
|
api,
|
||||||
auth,
|
auth,
|
||||||
config,
|
config,
|
||||||
NullLogger<OnlineSyncService>.Instance);
|
NullLogger<OnlineSyncService>.Instance,
|
||||||
|
broadcaster ?? new HubBroadcaster(new CapturingHubContext()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
|
private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
|
||||||
@@ -103,6 +106,26 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
|||||||
Assert.Contains(remoteId, api.MarkedImported);
|
Assert.Contains(remoteId, api.MarkedImported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tick_Imports_RemoteTask_BroadcastsTaskUpdated()
|
||||||
|
{
|
||||||
|
var (listId, ctx, _, _) = await SeedAsync();
|
||||||
|
using var _ = ctx;
|
||||||
|
|
||||||
|
var remoteId = Guid.NewGuid().ToString();
|
||||||
|
var api = new FakeApi
|
||||||
|
{
|
||||||
|
UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)],
|
||||||
|
};
|
||||||
|
var hubContext = new CapturingHubContext();
|
||||||
|
var svc = BuildService(api, broadcaster: new HubBroadcaster(hubContext));
|
||||||
|
|
||||||
|
await svc.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains(hubContext.Proxy.Calls,
|
||||||
|
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == remoteId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_UnknownList_Skips_And_DoesNotMark()
|
public async Task Tick_UnknownList_Skips_And_DoesNotMark()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -121,4 +121,52 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable
|
|||||||
releaseProcess.TrySetResult();
|
releaseProcess.TrySetResult();
|
||||||
await runTask;
|
await runTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creating_a_worktree_broadcasts_WorktreeUpdated()
|
||||||
|
{
|
||||||
|
string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString();
|
||||||
|
var repoDir = Path.Combine(_tempDir, "repo");
|
||||||
|
Directory.CreateDirectory(repoDir);
|
||||||
|
|
||||||
|
// A real git repo — Worker.Tests run real git by design.
|
||||||
|
await RunGitAsync(repoDir, "init");
|
||||||
|
await RunGitAsync(repoDir, "config user.email t@t.t");
|
||||||
|
await RunGitAsync(repoDir, "config user.name t");
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi");
|
||||||
|
await RunGitAsync(repoDir, "add a.txt");
|
||||||
|
await RunGitAsync(repoDir, "commit -m init");
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running,
|
||||||
|
StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
|
||||||
|
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
|
||||||
|
var runner = BuildRunner(fake);
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue",
|
||||||
|
CancellationToken.None, alreadyClaimed: true);
|
||||||
|
|
||||||
|
Assert.Contains(_hubContext.Proxy.Calls,
|
||||||
|
c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunGitAsync(string dir, string args)
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo("git", args)
|
||||||
|
{
|
||||||
|
WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
using var p = System.Diagnostics.Process.Start(psi)!;
|
||||||
|
await p.WaitForExitAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Queue;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Usage;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
// The queue picker's raw-SQL claim commits status='running' before the runner starts. If
|
||||||
|
// anything then throws before the runner's own terminal-status write, the task used to stay
|
||||||
|
// Running forever with the UI never notified (RunInSlotAsync's catch only logged the error).
|
||||||
|
// It must now mark the task Failed for a real exception (which broadcasts TaskUpdated), but
|
||||||
|
// must NOT do so for a cancellation — the cancel path already wrote the terminal status.
|
||||||
|
//
|
||||||
|
// These drive the real QueueService end to end (StartAsync + the waker), not just the
|
||||||
|
// FailAsync contract, so they actually exercise the fixed catch block.
|
||||||
|
public sealed class QueueServiceSlotFailureTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly string _tempDir;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
|
||||||
|
public QueueServiceSlotFailureTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotfail_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
_cfg = new WorkerConfig
|
||||||
|
{
|
||||||
|
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
|
||||||
|
LogRoot = Path.Combine(_tempDir, "logs"),
|
||||||
|
QueueBackstopIntervalMs = 50, // fast for tests
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors QueueServiceTests.CreateService but takes the picker as a parameter so each test
|
||||||
|
// can engineer the exact failure path it needs to exercise.
|
||||||
|
// Build() wires its own CapturingHubContext internally and hands it back as .Hub — the
|
||||||
|
// broadcaster inside TaskStateService (and therefore FailAsync's TaskUpdated) uses that
|
||||||
|
// exact instance, so everything else here must share it too rather than constructing a
|
||||||
|
// second CapturingHubContext that would silently miss FailAsync's broadcast.
|
||||||
|
private (QueueService service, CapturingHubContext hub, QueueWaker waker) CreateService(IQueuePicker picker)
|
||||||
|
{
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
var built = TaskStateServiceBuilder.Build(dbFactory);
|
||||||
|
var broadcaster = new HubBroadcaster(built.Hub);
|
||||||
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
|
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
|
NullLogger<TaskRunner>.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(),
|
||||||
|
new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
||||||
|
var waker = new QueueWaker();
|
||||||
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
|
||||||
|
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, waker, picker,
|
||||||
|
overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster);
|
||||||
|
return (service, built.Hub, waker);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SeedListAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directly rewrites the task's list_id via a raw connection with FK enforcement off,
|
||||||
|
// reproducing "the list vanished between the queue claim and the run" without going
|
||||||
|
// through EF's foreign-key-checked connections (which would reject the write).
|
||||||
|
private void OrphanTaskListId(string taskId)
|
||||||
|
{
|
||||||
|
using var conn = new SqliteConnection($"Data Source={_db.DbPath}");
|
||||||
|
conn.Open();
|
||||||
|
using (var pragmaCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
pragmaCmd.CommandText = "PRAGMA foreign_keys=OFF;";
|
||||||
|
pragmaCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "UPDATE tasks SET list_id = 'orphaned-missing-list' WHERE id = $id;";
|
||||||
|
cmd.Parameters.AddWithValue("$id", taskId);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_throwing_slot_run_marks_the_task_Failed_and_broadcasts_TaskUpdated()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
|
||||||
|
ReviewFeedback = "please fix", CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// A prior run with a session id routes RunInSlotAsync into TaskRunner.ContinueAsync
|
||||||
|
// instead of RunAsync.
|
||||||
|
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
|
||||||
|
Prompt = "original", SessionId = "sess-1", StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContinueAsync's setup block reads the list *before* its own try/catch starts
|
||||||
|
// (TaskRunner.cs, ContinueAsync ~line 232-234) and throws InvalidOperationException
|
||||||
|
// ("List not found.") straight past TaskRunner's own protection. That's the exact gap
|
||||||
|
// QueueService.RunInSlotAsync's own catch now has to cover.
|
||||||
|
OrphanTaskListId(taskId);
|
||||||
|
|
||||||
|
var (service, hub, waker) = CreateService(new QueuePicker(_db.CreateFactory()));
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
waker.Wake();
|
||||||
|
|
||||||
|
// FailAsync (TaskStateService.cs:236-249) commits the DB status flip via
|
||||||
|
// ExecuteUpdateAsync *before* it calls the broadcaster's TaskUpdated — so a poll that
|
||||||
|
// breaks the instant it observes Status==Failed can race ahead of the broadcast still
|
||||||
|
// landing in hub.Proxy.Calls. Wait for both signals together so the assertions below
|
||||||
|
// never sample a genuinely-not-yet-broadcast window as a failure.
|
||||||
|
TaskEntity? reloaded = null;
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
using var verify = _db.CreateContext();
|
||||||
|
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||||
|
var broadcastSeen = hub.Proxy.Calls.Any(
|
||||||
|
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||||
|
if (reloaded!.Status == TaskStatus.Failed && broadcastSeen) break;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
Assert.Equal(TaskStatus.Failed, reloaded!.Status);
|
||||||
|
Assert.Contains(hub.Proxy.Calls,
|
||||||
|
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake IQueuePicker that performs the real atomic claim (so the DB row transitions
|
||||||
|
// Queued->Running exactly like production) and then, synchronously before returning,
|
||||||
|
// cancels the token QueueService's per-slot CTS is linked from. By the time
|
||||||
|
// QueueService.ExecuteAsync creates that linked CTS and dispatches RunInSlotAsync, the
|
||||||
|
// token is already cancelled — deterministic, no timing race required.
|
||||||
|
private sealed class ClaimThenCancelPicker : IQueuePicker
|
||||||
|
{
|
||||||
|
private readonly IQueuePicker _inner;
|
||||||
|
private readonly CancellationTokenSource _cancelAfterClaim;
|
||||||
|
|
||||||
|
public ClaimThenCancelPicker(IQueuePicker inner, CancellationTokenSource cancelAfterClaim)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_cancelAfterClaim = cancelAfterClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var claimed = await _inner.ClaimNextAsync(now, ct);
|
||||||
|
if (claimed is not null) _cancelAfterClaim.Cancel();
|
||||||
|
return claimed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cancelled_slot_run_does_not_mark_the_task_Failed()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var outerCts = new CancellationTokenSource();
|
||||||
|
var realPicker = new QueuePicker(_db.CreateFactory());
|
||||||
|
var picker = new ClaimThenCancelPicker(realPicker, outerCts);
|
||||||
|
var (service, hub, waker) = CreateService(picker);
|
||||||
|
|
||||||
|
await service.StartAsync(outerCts.Token);
|
||||||
|
waker.Wake();
|
||||||
|
|
||||||
|
// Wait for the slot to be claimed and then released again (RunInSlotAsync's
|
||||||
|
// ContinueWith removes it once the catch block — ours or a stray one — finishes).
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
while (service.GetActive().Any(a => a.taskId == taskId) && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(25);
|
||||||
|
await Task.Delay(100); // let the fire-and-forget continuation fully settle
|
||||||
|
|
||||||
|
TaskEntity? reloaded;
|
||||||
|
using (var verify = _db.CreateContext())
|
||||||
|
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||||
|
|
||||||
|
// The picker's atomic claim already flipped it to Running; the cancelled slot run must
|
||||||
|
// leave it there rather than flipping it to Failed.
|
||||||
|
Assert.Equal(TaskStatus.Running, reloaded!.Status);
|
||||||
|
Assert.DoesNotContain(hub.Proxy.Calls,
|
||||||
|
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
var repo = new AppSettingsRepository(ctx);
|
var repo = new AppSettingsRepository(ctx);
|
||||||
var settings = await repo.GetAsync();
|
var settings = await repo.GetAsync();
|
||||||
settings.MaxParallelExecutions = maxParallel;
|
settings.MaxParallelExecutions = maxParallel;
|
||||||
settings.UsageThrottleSoftPct = softPct;
|
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||||
settings.UsageThrottleHardPct = hardPct;
|
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||||
|
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||||
|
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||||
settings.UsageGateFiveHourPct = gateFive;
|
settings.UsageGateFiveHourPct = gateFive;
|
||||||
settings.UsageGateSevenDayPct = gateSeven;
|
settings.UsageGateSevenDayPct = gateSeven;
|
||||||
await repo.UpdateAsync(settings);
|
await repo.UpdateAsync(settings);
|
||||||
|
|||||||
@@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable
|
|||||||
Assert.Equal(1, row.Messages);
|
Assert.Equal(1, row.Messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
|
||||||
|
{
|
||||||
|
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
|
||||||
|
// record inside it, so it is skipped unread. Here the content would match the window —
|
||||||
|
// proving the file was never opened, which is what keeps a 7-day range off the full history.
|
||||||
|
var path = WriteSession("proj", "old.jsonl",
|
||||||
|
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
|
||||||
|
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
|
||||||
|
|
||||||
|
var reader = MakeReader();
|
||||||
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
|
||||||
|
{
|
||||||
|
var path = WriteSession("proj", "edge.jsonl",
|
||||||
|
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
|
||||||
|
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
|
||||||
|
|
||||||
|
var reader = MakeReader();
|
||||||
|
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Malformed_Line_Does_Not_Abort_The_Run()
|
public async Task Malformed_Line_Does_Not_Abort_The_Run()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
|
|||||||
var repo = new AppSettingsRepository(ctx);
|
var repo = new AppSettingsRepository(ctx);
|
||||||
var settings = await repo.GetAsync();
|
var settings = await repo.GetAsync();
|
||||||
settings.MaxParallelExecutions = maxParallel;
|
settings.MaxParallelExecutions = maxParallel;
|
||||||
settings.UsageThrottleSoftPct = softPct;
|
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||||
settings.UsageThrottleHardPct = hardPct;
|
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||||
|
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||||
|
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||||
await repo.UpdateAsync(settings);
|
await repo.UpdateAsync(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SetPerBucketThrottleAsync(
|
||||||
|
int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard)
|
||||||
|
{
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
var repo = new AppSettingsRepository(ctx);
|
||||||
|
var settings = await repo.GetAsync();
|
||||||
|
settings.MaxParallelExecutions = maxParallel;
|
||||||
|
settings.UsageThrottleFiveHourSoftPct = fiveSoft;
|
||||||
|
settings.UsageThrottleFiveHourHardPct = fiveHard;
|
||||||
|
settings.UsageThrottleSevenDaySoftPct = sevenSoft;
|
||||||
|
settings.UsageThrottleSevenDayHardPct = sevenHard;
|
||||||
|
await repo.UpdateAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges()
|
||||||
|
{
|
||||||
|
await SetThresholdsAsync(80, 90);
|
||||||
|
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85);
|
||||||
|
|
||||||
|
var state = new UsageState();
|
||||||
|
state.ReportSuccess(new UsageSnapshot(
|
||||||
|
new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||||
|
|
||||||
|
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||||
|
|
||||||
|
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
|
||||||
|
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
|
||||||
|
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
|
||||||
|
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
|
||||||
|
{
|
||||||
|
await SetThresholdsAsync(80, 90);
|
||||||
|
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
|
||||||
|
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
|
||||||
|
|
||||||
|
var state = new UsageState();
|
||||||
|
state.ReportSuccess(new UsageSnapshot(
|
||||||
|
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||||
|
|
||||||
|
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, dto.EffectiveSlots);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Throttled_slots_and_decisive_bucket_reported()
|
public async Task Throttled_slots_and_decisive_bucket_reported()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage;
|
|||||||
|
|
||||||
public sealed class UsageThrottleTests
|
public sealed class UsageThrottleTests
|
||||||
{
|
{
|
||||||
private const int Soft = 50;
|
private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80);
|
||||||
private const int Hard = 65;
|
private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90);
|
||||||
private const int GateFive = 80;
|
|
||||||
private const int GateSeven = 90;
|
|
||||||
|
|
||||||
private static int Effective(double? five, double? seven, int configured = 3) =>
|
private static int Effective(double? five, double? seven, int configured = 3) =>
|
||||||
UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven);
|
UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
|
public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
|
||||||
@@ -94,13 +92,19 @@ public sealed class UsageThrottleTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
|
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
|
||||||
{
|
{
|
||||||
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven));
|
var five = new UsageThresholds(0, 0, 80);
|
||||||
|
var seven = new UsageThresholds(0, 0, 90);
|
||||||
|
|
||||||
|
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ZeroGateThresholds_NeverHardBlock()
|
public void ZeroGateThresholds_NeverHardBlock()
|
||||||
{
|
{
|
||||||
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0));
|
var five = new UsageThresholds(50, 65, 0);
|
||||||
|
var seven = new UsageThresholds(50, 65, 0);
|
||||||
|
|
||||||
|
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -108,4 +112,46 @@ public sealed class UsageThrottleTests
|
|||||||
{
|
{
|
||||||
Assert.Equal(1, Effective(10, 10, configured: 0));
|
Assert.Equal(1, Effective(10, 10, configured: 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-bucket thresholds are independent ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerBucket_SameUtilization_DifferentStagesPerBucket()
|
||||||
|
{
|
||||||
|
// 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides.
|
||||||
|
var five = new UsageThresholds(50, 65, 80);
|
||||||
|
var seven = new UsageThresholds(70, 85, 90);
|
||||||
|
|
||||||
|
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne()
|
||||||
|
{
|
||||||
|
// 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles.
|
||||||
|
var five = new UsageThresholds(90, 95, 99);
|
||||||
|
var seven = new UsageThresholds(20, 35, 90);
|
||||||
|
|
||||||
|
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerBucket_StrictestStageWins()
|
||||||
|
{
|
||||||
|
// 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins.
|
||||||
|
var five = new UsageThresholds(50, 65, 80);
|
||||||
|
var seven = new UsageThresholds(30, 40, 90);
|
||||||
|
|
||||||
|
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerBucket_MissingBucketNeverThrottles()
|
||||||
|
{
|
||||||
|
// No 7d reading at all: only the 5h bucket may step parallelism down.
|
||||||
|
var five = new UsageThresholds(50, 65, 80);
|
||||||
|
var seven = new UsageThresholds(1, 2, 3);
|
||||||
|
|
||||||
|
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user