feat(claude-do): PowerUse fuer List-Handler-Runs + konfigurierbares Handoff-Budget

This commit is contained in:
Mika Kuns
2026-09-02 15:56:10 +02:00
31 changed files with 1781 additions and 45 deletions
+28 -4
View File
@@ -66,14 +66,38 @@ Commit freely (per task + the spec/plan docs). Never push without asking.
## Building & Testing ## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects with `-c Release` (a running Worker locks the `Debug` output). **Build the whole solution, test only what you touched.** Always `-c Release` — a running Worker locks the `Debug` output.
```bash ```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release # pulls in Ui + Data dotnet build ClaudeDo.slnx -c Release # ~40s, catches cross-project breaks
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build \
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release # also: Data.Tests, Ui.Tests, Localization.Tests, Installer.Tests, Releases.Tests --filter "FullyQualifiedName~Handoff" # seconds, not minutes
``` ```
`.slnx` needs SDK 9+ (this machine is on 10). On an older SDK build the projects individually:
`src/ClaudeDo.App` (pulls in Ui + Data) and `src/ClaudeDo.Worker`.
### Do NOT run the full test suite to finish a task
Measured 2026-09-02, sequentially after one build: **Worker.Tests 507s**, Ui.Tests 33s, Data.Tests 8s,
Localization/Installer/Releases 3s each, build 42s — ~10 min for all six. Run in parallel across
projects it is *worse*, not better (Worker.Tests 801s, Ui.Tests 82s): these tests create real git
worktrees and spawn processes, so they thrash rather than share.
With up to 5 implementer tasks running at once, a full suite per task costs hours — and **cannot find
what it costs that for.** Each task works in its own worktree containing only its own change, so a
collision between two parallel tasks is invisible to both. Collisions surface after the merge, which
is exactly where `verify_merges` runs the list's verify command **once per batch**. The full suite is
the merge gate's job, not the task's.
So, per task:
- **Build:** the whole solution, always. It's cheap and it catches the compile break your change
caused three projects over.
- **Tests:** `--no-build --filter` scoped to what you touched. Add a filter for the classes covering
your change; if you genuinely can't scope it, run the one relevant test project, never all six.
- **Report** what you ran and what you did not, so the merge session knows what is still unverified.
- Never report "all tests pass" when you ran a filtered subset. Say which filter you used.
### Gotchas ### Gotchas
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree). - **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
- **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state. - **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
+16
View File
@@ -115,6 +115,22 @@ share the same prompt/model as their non-final counterpart and differ only in on
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
and forbidding further reruns. and forbidding further reruns.
The chain length is bounded server-side by `HandoffRoundTracker`, whose cap comes from
`AppSettingsEntity.ListHandlerMaxHandoffs` (default 4 = two merge rounds; `HandoffMcpTools` reads it
per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past the cap a
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
`coerced` flag — and any handoff after a `merge_final` round throws.
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (toggled on the usage monitor) **and** the
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
(never ask, decide and record instead; resolve or abort conflicts rather than leaving them in the
tree; harvest new tasks when a round would starve). Both keys are required on purpose: the global
switch can stay on overnight without turning an unrelated list's handler run unattended. A run that
is not in PowerUse gets a byte-identical prompt to the pre-PowerUse behaviour — there is deliberately
no second set of handler prompts to keep in sync.
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel. Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`, OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
+2
View File
@@ -39,6 +39,8 @@ Beyond the basics it carries:
| `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`. |
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. | | `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`. All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
@@ -70,6 +70,11 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.AutoContinueOnUsageLimit) builder.Property(s => s.AutoContinueOnUsageLimit)
.HasColumnName("auto_continue_on_usage_limit").IsRequired().HasDefaultValue(false); .HasColumnName("auto_continue_on_usage_limit").IsRequired().HasDefaultValue(false);
builder.Property(s => s.PowerUseEnabled)
.HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false);
builder.Property(s => s.ListHandlerMaxHandoffs)
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
} }
@@ -20,5 +20,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command"); builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
builder.Property(c => c.PermissionMode).HasColumnName("permission_mode"); builder.Property(c => c.PermissionMode).HasColumnName("permission_mode");
builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id"); builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id");
builder.Property(c => c.PowerUseAllowed).HasColumnName("power_use_allowed");
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerUse : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "power_use_allowed",
table: "list_config",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "list_handler_max_handoffs",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 4);
migrationBuilder.AddColumn<bool>(
name: "power_use_enabled",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "list_handler_max_handoffs",
value: 4);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "power_use_allowed",
table: "list_config");
migrationBuilder.DropColumn(
name: "list_handler_max_handoffs",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_enabled",
table: "app_settings");
}
}
}
@@ -66,6 +66,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions") .HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode"); .HasColumnName("default_permission_mode");
b.Property<int>("ListHandlerMaxHandoffs")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(4)
.HasColumnName("list_handler_max_handoffs");
b.Property<int>("MaxParallelExecutions") b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -88,6 +94,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1) .HasDefaultValue(1)
.HasColumnName("next_task_number"); .HasColumnName("next_task_number");
b.Property<bool>("PowerUseEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("power_use_enabled");
b.Property<string>("RepoImportFolders") b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("repo_import_folders"); .HasColumnName("repo_import_folders");
@@ -175,9 +187,11 @@ namespace ClaudeDo.Data.Migrations
DefaultMaxTurns = 40, DefaultMaxTurns = 40,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "auto", DefaultPermissionMode = "auto",
ListHandlerMaxHandoffs = 4,
MaxParallelExecutions = 1, MaxParallelExecutions = 1,
MaxTurnsCeiling = 80, MaxTurnsCeiling = 80,
NextTaskNumber = 1, NextTaskNumber = 1,
PowerUseEnabled = false,
StandupWeekday = 3, StandupWeekday = 3,
UsageGateFiveHourPct = 80, UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90, UsageGateSevenDayPct = 90,
@@ -247,6 +261,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("permission_mode"); .HasColumnName("permission_mode");
b.Property<bool>("PowerUseAllowed")
.HasColumnType("INTEGER")
.HasColumnName("power_use_allowed");
b.Property<string>("SessionSkills") b.Property<string>("SessionSkills")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("session_skills"); .HasColumnName("session_skills");
@@ -60,4 +60,17 @@ public sealed class AppSettingsEntity
// automatic ContinueTask once the 5h window resets, and the gate-blocked queue wakes exactly // automatic ContinueTask once the 5h window resets, and the gate-blocked queue wakes exactly
// at that reset time instead of only on the 30s backstop. // at that reset time instead of only on the 30s backstop.
public bool AutoContinueOnUsageLimit { get; set; } public bool AutoContinueOnUsageLimit { get; set; }
// Master switch for PowerUse: list-handler runs go unattended (never ask the user) and harvest
// new work when a round would otherwise starve. Toggled from the usage monitor, because it is a
// deliberate "spend the remaining window" decision rather than a normal default. Off by default,
// and on its own it changes nothing — a run is only in PowerUse when the LIST also opted in via
// ListConfigEntity.PowerUseAllowed, so leaving this on cannot make an unrelated list's handler
// run unattended. Composition lives in InteractiveLaunchSpecService.
public bool PowerUseEnabled { get; set; }
// Server-side cap on a list-handler run's wait<->merge handoff chain (HandoffRoundTracker).
// 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep
// going for hours. It exists to bound runaway handoff loops, so it must stay >= 1.
public int ListHandlerMaxHandoffs { get; set; } = 4;
} }
@@ -17,6 +17,12 @@ public sealed class ListConfigEntity
// Feld mitführen, sonst setzt es sich still zurück. // Feld mitführen, sonst setzt es sich still zurück.
public int? TicketProjectId { get; set; } public int? TicketProjectId { get; set; }
// Per-Liste-Opt-in für PowerUse. Ein Handler-Run läuft nur unattended, wenn BEIDES gesetzt ist:
// dieses Flag und AppSettingsEntity.PowerUseEnabled. Der globale Toggle bleibt damit gefahrlos
// an — eine Liste, die hier nicht zugestimmt hat, behält das interaktive Verhalten. Default
// false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim.
public bool PowerUseAllowed { get; set; }
// Navigation property // Navigation property
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
} }
+76 -2
View File
@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff } public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff, PowerUseAddendum }
/// <summary> /// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default. /// How a prompt kind's on-disk override (if any) relates to the bundled default.
@@ -44,6 +44,7 @@ public static class PromptFiles
PromptKind.MergeHelperMerge => "merge-helper-merge.md", PromptKind.MergeHelperMerge => "merge-helper-merge.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md", PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md", PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
PromptKind.PowerUseAddendum => "power-use.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind)) _ => throw new ArgumentOutOfRangeException(nameof(kind))
}; };
@@ -221,6 +222,7 @@ public static class PromptFiles
PromptKind.MergeHelperMerge => MergeHelperMergeDefault, PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault, PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault, PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
PromptKind.PowerUseAddendum => PowerUseAddendumDefault,
_ => "" _ => ""
}; };
@@ -455,7 +457,7 @@ public static class PromptFiles
Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived. Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived.
Then check the wave count against the handoff cap: this list-handler chain allows at most 4 handoffs total (wait, merge, wait_final, merge_final), which is exactly two merge rounds. If your wave plan needs more than two rounds to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain. Then check the wave count against the handoff cap. This list-handler chain allows a limited number of handoffs (wait, merge, wait_final, merge_final) — by default 4, i.e. exactly two merge rounds, but it is configurable and may be higher for this run. Assume two rounds unless the kickoff tells you otherwise. If your wave plan needs more rounds than that to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
## Phase 2 — Enhance for execution ## Phase 2 — Enhance for execution
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below. Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below.
@@ -620,6 +622,78 @@ public static class PromptFiles
{roundInfo} {roundInfo}
"""; """;
// Appended to the triage/wait/merge system prompt when a run is in PowerUse (global switch AND
// the list's own opt-in). Deliberately an addendum rather than a second set of prompts: the
// shared 95% stays in one place, and a normal run's prompt is byte-identical to what it was.
// It therefore has to read as an OVERRIDE of the rules above it, not as standalone instructions.
private const string PowerUseAddendumDefault = """
# PowerUse mode — these rules supersede the ones above
PowerUse is ON for this run. Everything above was written for a session with a user watching it. That assumption no longer holds, and where the two conflict, this section wins.
## No user exists
Nobody is watching and nobody can answer a question. Every instruction above that says to ask the user, confirm with the user, or stop and let the user decide is replaced by: decide it yourself, act, and record the decision in your summary. A question asked here is a hung session and a wasted run.
This does not lower the bar for what you may do — it removes escalation as an option, so the decisions you make must be ones you can defend in writing afterwards. Where a rule above offered "ask the user" as the safe path, the safe path is now the conservative action plus a written record, never the bold one.
Specifically:
- **Duplicate pairs (Triage Phase 1):** decide them. Clear duplicate — same goal, or one fully contained in the other — fold the loser's unique content into the survivor and cancel the loser. Genuinely different intent that merely touches the same area — keep both and chain them with dependsOnTaskId so they cannot run against the same files at once.
- **A task too vague to sharpen (Triage Phase 2):** cancel it with a recorded reason rather than guessing a specification for someone else's half-sentence. Guessing burns a whole slot on work nobody asked for.
- **Verify command not set (Triage Phase 2):** determine it, run it once via Bash to prove it works, then set it. Never write a command you have not actually run.
- **A risky or wrong-looking diff (Merge Phase 4):** if it contradicts the task's own acceptance criteria, reject_rerun with concrete feedback naming what was missing. If it merely looks unusual but meets the criteria, merge it and say why. If it looks actively destructive — deletes code no task asked to delete, drops a migration, rewrites unrelated files — leave it unmerged and record it as HELD FOR REVIEW. Never merge something you would describe as destructive.
- **A conflict you cannot resolve with confidence (Merge Phase 4):** call abort_merge(taskId) and record it as HELD FOR REVIEW. Aborting is always correct over guessing: the task keeps its status, and a rerun against the new main usually removes the conflict entirely.
## Conflicts are the normal case
Never end your turn with a conflicted merge left in the working tree. A half-merged tree makes the repo unbuildable for every session and every task after you, and any verify that runs against it reports a false red.
Prefer get_merge_conflicts + resolve_conflict_hunk over reading marker text by hand — for a large conflict that is the only tractable route; a 200k-character marker dump is not something to read linearly.
The common collision shape in a parallel run is one task MOVING code out of a file while another task ADDS to it. The resolution is almost always to keep the move and re-apply the addition in the file the code moved to — not to pick a side. Picking "ours" silently deletes the other task's work.
When ordering merges that overlap on a file, merge the task that moves or deletes code BEFORE the task that only adds to it. A move landing second turns every addition into a conflict.
## Rerun budget
At most TWO reruns per task across the whole run, replacing the single-rerun limit above. A task that has already been rerun twice is recorded as HELD FOR REVIEW instead of getting a third attempt — three failures on the same task is a specification problem, not bad luck, and further attempts starve the rest of the run.
## Harvest — so the run does not starve
This applies to the Merge phase only, after its merges and reruns are done.
If you have fewer than five tasks to hand to the next round, harvest new work for this list's repo before handing off. Only for the list named in the brief — never another list or repo.
Sources, in this order; prefer one you have not exhausted yet:
1. **Verify/build output** — real compiler warnings. One task per warning group.
2. **`ponytail:` comments** — deliberate shortcuts with a named ceiling and upgrade path. Take the ones whose upgrade is small.
3. **TODO / HACK / FIXME** — only the concretely formulated ones.
4. **Unfinished plan slices** — `docs/superpowers/plans/*.md` whose last slices never landed. Check against `git log` before believing a slice is open. One task per slice, naming the plan path and slice number.
5. **Test gaps at bug hotspots** — `git log --grep="^fix"` over recent commits; files with repeated fixes and no test.
6. **Over-engineering** — the ponytail-audit skill against individual subfolders, never the whole repo at once. Deletion candidates.
7. **Feature work** — improvements that follow clearly from what the code already does and from the plans and specs under `docs/`. Ground each one in something you actually read; a feature you cannot point at a file for is a guess, not a proposal.
Rules for every harvested task, without exception:
- Write it the way the Triage phase would: concrete acceptance criteria, real file paths found with Read/Grep/Glob, and what is out of scope. A vague harvested task wastes a whole slot.
- Keep it narrow — one concern, ideally under five changed files. Narrow tasks merge in parallel; broad ones collide with everything.
- Do not hand two new tasks to the same round if they touch the same file. Stagger them across rounds, or chain them with dependsOnTaskId.
- Tag every harvested task `createdBy: "power-use"` so a later review can tell your proposals from the ones a human asked for.
- A task that only verifies, audits or researches and is not meant to change code must be created with `expectsNoDiff: true`, or the next Merge session will read its empty diff as a failure.
## Verify red is the one thing you must not paper over
If verify_merges returns verify_failed, the target branch is now RED. Do not try to fix it inside this session. Create ONE task with add_task(queueImmediately: true, model: "sonnet") titled `fix: verify red after this round`, put the failing output tail in its description, and hand that task to the next round as its ONLY entry. Merge nothing further until it has landed.
## The summary is the only record
Nobody saw you work, so anything not written down did not happen. In addition to the per-task lines the Merge phase already asks for, your summary must carry these sections. An empty section is a valid answer; an omitted one is not:
- **HELD FOR REVIEW** — every task you did not merge, with the reason.
- **Conflicts resolved** — per conflict: the file, which two tasks collided, and which way you resolved it. Someone must be able to find your resolution without re-deriving it.
- **Harvested** — every task you created, grouped by source.
- **Round state** — which round this was and what you handed to the next one.
""";
private const string WeeklyReportDefault = """ private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer, You are generating a concise weekly standup report for a software developer,
covering {start} to {end}. covering {start} to {end}.
@@ -90,6 +90,7 @@ public sealed class ListRepository
existing.VerifyCommand = config.VerifyCommand; existing.VerifyCommand = config.VerifyCommand;
existing.PermissionMode = config.PermissionMode; existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId; existing.TicketProjectId = config.TicketProjectId;
existing.PowerUseAllowed = config.PowerUseAllowed;
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
+9 -3
View File
@@ -36,7 +36,11 @@ public record AppSettingsDto(
int UsageThrottleFiveHourHardPct = 65, int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50, int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65, int UsageThrottleSevenDayHardPct = 65,
bool AutoContinueOnUsageLimit = false); bool AutoContinueOnUsageLimit = false,
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
// effect for a list that also set PowerUseAllowed.
bool PowerUseEnabled = false,
int ListHandlerMaxHandoffs = 4);
public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -95,11 +99,13 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the // TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity // list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
// verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it. // verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); // PowerUseAllowed is nullable for the same reason: null = keep stored, false = revoke the opt-in.
// A non-nullable bool here would let any caller that omits it silently revoke PowerUse for the list.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-Tage-Fenster (%)", "usageGateSevenDayPct": "7-Tage-Fenster (%)",
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen", "autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.", "autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
"throttleStagesHeading": "Throttle-Stufen", "throttleStagesHeading": "Throttle-Stufen",
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.", "throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Wiederholung", "retryPrompt": "Wiederholung",
"dailyPrepPrompt": "Tagesplanung", "dailyPrepPrompt": "Tagesplanung",
"weeklyReportPrompt": "Wochenbericht", "weeklyReportPrompt": "Wochenbericht",
"powerUsePrompt": "PowerUse-Addendum",
"viewPrompt": "Ansehen", "viewPrompt": "Ansehen",
"customizedSection": "ANGEPASST", "customizedSection": "ANGEPASST",
"customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.", "customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
@@ -398,6 +401,8 @@
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"powerUseAllowed": "PowerUse für diese Liste erlauben",
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "Listen-Icon", "icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.", "iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
@@ -504,6 +509,8 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "Noch keine Nutzungslimits gemeldet.", "noGauges": "Noch keine Nutzungslimits gemeldet.",
"powerUse": "PowerUse",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"refresh": "Jetzt aktualisieren", "refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.", "refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})", "staleFormat": "Werte veraltet (Stand {0})",
@@ -749,6 +756,7 @@
"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}", "thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.", "installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}", "installFailed": "TokenTracker konnte nicht installiert werden: {0}",
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}", "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-day window (%)", "usageGateSevenDayPct": "7-day window (%)",
"autoContinueOnUsageLimit": "Continue on session limit reset", "autoContinueOnUsageLimit": "Continue on session limit reset",
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.", "autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
"listHandlerMaxHandoffs": "List-handler handoff budget",
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
"throttleStagesHeading": "Throttle stages", "throttleStagesHeading": "Throttle stages",
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.", "throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Retry", "retryPrompt": "Retry",
"dailyPrepPrompt": "Daily prep", "dailyPrepPrompt": "Daily prep",
"weeklyReportPrompt": "Weekly report", "weeklyReportPrompt": "Weekly report",
"powerUsePrompt": "PowerUse addendum",
"viewPrompt": "View", "viewPrompt": "View",
"customizedSection": "CUSTOMIZED", "customizedSection": "CUSTOMIZED",
"customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.", "customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
@@ -398,6 +401,8 @@
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"findingsTracked": "Commit the .claudedo findings folder", "findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"powerUseAllowed": "Allow PowerUse for this list",
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
"sectionIcon": "ICON", "sectionIcon": "ICON",
"icon": "List icon", "icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.", "iconHint": "Shown next to the list name in the sidebar.",
@@ -504,6 +509,8 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "No usage limits reported yet.", "noGauges": "No usage limits reported yet.",
"powerUse": "PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"refresh": "Refresh now", "refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.", "refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})", "staleFormat": "Values stale (as of {0})",
@@ -749,6 +756,7 @@
"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}", "thresholdSaveFailed": "Couldn't save the threshold: {0}",
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.", "installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}", "installFailed": "Couldn't install TokenTracker: {0}",
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}", "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
@@ -208,11 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { } catch { }
} }
// verifyCommand and ticketProjectId are List-only fields owned by ListSettingsModalViewModel // verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
// (not this editor, which is also reused for Task scope); the caller passes them through so the // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// single UpdateListConfig call carries the full desired row instead of clobbering it. // passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
public async System.Threading.Tasks.Task SaveAsync( public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, int? ticketProjectId = null) string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
{ {
if (TargetId is null) return; if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -225,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId)); await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
} }
private List<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -45,6 +45,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Optional post-merge verification command (build/test), run in WorkingDir after a merge // Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = ""; [ObservableProperty] private string _verifyCommand = "";
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
// are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry). // Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon; [ObservableProperty] private string _icon = DefaultIcon;
@@ -110,6 +114,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
await Agent.LoadForListAsync(listId, ct); await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync() ? await _worker.GetTicketSettingsAsync()
@@ -149,9 +154,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Never null here, or an explicit unlink would silently fail to reach the worker. // Never null here, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null; var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
await Agent.SaveAsync( await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId); ticketProjectId,
PowerUseAllowed);
Saved = true; Saved = true;
CloseAction?.Invoke(); CloseAction?.Invoke();
@@ -30,6 +30,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry); public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep); public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport); public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public string PowerUsePromptPath { get; } = PromptFiles.PathFor(PromptKind.PowerUseAddendum);
public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new(); public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new();
@@ -27,6 +27,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
// Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and // Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and
// wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop. // wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop.
[ObservableProperty] private bool _autoContinueOnUsageLimit; [ObservableProperty] private bool _autoContinueOnUsageLimit;
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
// Newline-separated path prefixes excluded from the weekly report. // Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private"; [ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday. // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
@@ -130,6 +130,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// carried through load→save verbatim so saving Settings can never reset a dragged value. // 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); private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
public async Task LoadAsync() public async Task LoadAsync()
{ {
IsBusy = true; IsBusy = true;
@@ -147,6 +151,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit; General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_throttleStages = ( _throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -213,7 +222,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.FiveHard, _throttleStages.FiveHard,
_throttleStages.SevenSoft, _throttleStages.SevenSoft,
_throttleStages.SevenHard, _throttleStages.SevenHard,
General.AutoContinueOnUsageLimit); General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
@@ -100,6 +100,45 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>(); private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
/// <summary>
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
/// "spend the rest of the window" decision, made while looking at how much window is left.
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUseAsync(value);
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
{
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
}
}
public bool IsStale => Snapshot?.IsStale == true; public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError; public string? LastError => Snapshot?.LastError;
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true; public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
@@ -148,6 +187,15 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
Snapshot = snapshot; Snapshot = snapshot;
_worker.UsageUpdatedEvent -= OnUsageUpdated; _worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated; _worker.UsageUpdatedEvent += OnUsageUpdated;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
finally { _loadingPowerUse = false; }
}
ApplyPresetRange(SelectedPresetDays); ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync(); await LoadUsageDataAsync();
} }
@@ -103,6 +103,13 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}"> <StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}" <ComboBox ItemsSource="{Binding TicketProjects}"
@@ -251,6 +251,17 @@
Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/> Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/>
<TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}" <TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<!-- PowerUse itself is toggled on the usage monitor; only its handoff budget is
typed here, because it also applies to a normal (interactive) handler run. -->
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="2" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.listHandlerMaxHandoffsHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel> </StackPanel>
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage); <!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
@@ -433,7 +444,7 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/> <TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
@@ -458,6 +469,10 @@
<TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/> Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/>
<TextBlock Grid.Row="6" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.powerUsePrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="6" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PowerUsePromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="6" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="PowerUseAddendum"/>
</Grid> </Grid>
</StackPanel> </StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> <StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -134,6 +134,19 @@
Text="{loc:Tr modals.usageMonitor.noGauges}" Text="{loc:Tr modals.usageMonitor.noGauges}"
IsVisible="{Binding !GaugeRows.Count}"/> IsVisible="{Binding !GaugeRows.Count}"/>
<!-- PowerUse: sits under the gauges on purpose — "spend the rest of the window" is a
decision made while looking at how much window is left. Saves immediately on toggle. -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0" Padding="12,10"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseEnabled, Mode=TwoWay}"
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
</StackPanel>
</Border>
<!-- Period picker --> <!-- Period picker -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/> <TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
+21 -4
View File
@@ -1,7 +1,9 @@
using System.ComponentModel; using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
@@ -19,12 +21,18 @@ public sealed class HandoffMcpTools
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly HandoffRoundTracker _rounds; private readonly HandoffRoundTracker _rounds;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster, HandoffRoundTracker rounds) public HandoffMcpTools(
TaskRepository tasks,
HubBroadcaster broadcaster,
HandoffRoundTracker rounds,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{ {
_tasks = tasks; _tasks = tasks;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_rounds = rounds; _rounds = rounds;
_dbFactory = dbFactory;
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -35,7 +43,8 @@ public sealed class HandoffMcpTools
"after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" + "after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" +
"\"merge_final\" are only for a merge session that started reruns and must eventually stop the " + "\"merge_final\" are only for a merge session that started reruns and must eventually stop the " +
"chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " + "chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " +
"capped server-side at four handoffs (i.e. two " + "capped server-side at a configurable number of handoffs (the ListHandlerMaxHandoffs setting, " +
"4 by default, i.e. two " +
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " + "full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " + "silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
"any handoff at all after a merge_final round for this task throws. The current tile stays " + "any handoff at all after a merge_final round for this task throws. The current tile stays " +
@@ -61,7 +70,10 @@ public sealed class HandoffMcpTools
_ = await _tasks.GetByIdAsync(id, cancellationToken) _ = await _tasks.GetByIdAsync(id, cancellationToken)
?? throw new InvalidOperationException($"Task {id} not found."); ?? throw new InvalidOperationException($"Task {id} not found.");
var round = _rounds.Register(taskId, nextPhase); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var round = _rounds.Register(taskId, nextPhase, maxHandoffs);
if (round.Rejected) if (round.Rejected)
throw new InvalidOperationException(round.Error); throw new InvalidOperationException(round.Error);
@@ -85,7 +97,12 @@ public sealed class HandoffMcpTools
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
var status = _rounds.GetStatus(taskId); // Same setting HandoffListHandler enforces against — read here too, so a raised cap shows up
// in the reported budget instead of the tool confidently understating it.
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var status = _rounds.GetStatus(taskId, maxHandoffs);
return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final); return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final);
} }
} }
+19 -7
View File
@@ -13,8 +13,10 @@ namespace ClaudeDo.Worker.External;
// resets the count, which is acceptable since the chain restarts fresh anyway. // resets the count, which is acceptable since the chain restarts fresh anyway.
public sealed class HandoffRoundTracker public sealed class HandoffRoundTracker
{ {
// Two full wait->merge cycles: wait, merge, wait, merge. // Two full wait->merge cycles: wait, merge, wait, merge. Only the fallback now -- the live cap
public const int MaxHandoffs = 4; // comes from AppSettingsEntity.ListHandlerMaxHandoffs, passed per call by HandoffMcpTools, so a
// run meant to keep going for hours can raise it without the bound disappearing entirely.
public const int DefaultMaxHandoffs = 4;
private sealed class TaskState private sealed class TaskState
{ {
@@ -42,17 +44,27 @@ public sealed class HandoffRoundTracker
// handoff yet" is a normal state, not an error. // handoff yet" is a normal state, not an error.
public sealed record Status(int Round, int Remaining, bool Final); public sealed record Status(int Round, int Remaining, bool Final);
public Status GetStatus(string taskId) /// <param name="maxHandoffs">The live cap (AppSettingsEntity.ListHandlerMaxHandoffs), passed in
/// rather than read off a constant so the REPORTED budget can never drift from the cap Register
/// actually enforces. A brief telling the model "2 rounds left" while the chain really allows 40
/// is worse than no budget line at all. Clamped like Register's.</param>
public Status GetStatus(string taskId, int maxHandoffs = DefaultMaxHandoffs)
{ {
var cap = Math.Max(1, maxHandoffs);
if (!_states.TryGetValue(taskId, out var state)) if (!_states.TryGetValue(taskId, out var state))
return new Status(0, MaxHandoffs, false); return new Status(0, cap, false);
lock (state) lock (state)
return new Status(state.Count, Math.Max(0, MaxHandoffs - state.Count), state.Sealed); return new Status(state.Count, Math.Max(0, cap - state.Count), state.Sealed);
} }
public Result Register(string taskId, string nextPhase) /// <param name="maxHandoffs">Cap for this chain, normally AppSettingsEntity.ListHandlerMaxHandoffs.
/// Values below 1 are treated as 1: the cap exists to bound the loop, so "no cap" is not an
/// option a caller gets to pick by passing 0.</param>
public Result Register(string taskId, string nextPhase, int maxHandoffs = DefaultMaxHandoffs)
{ {
var cap = Math.Max(1, maxHandoffs);
var state = _states.GetOrAdd(taskId, _ => new TaskState()); var state = _states.GetOrAdd(taskId, _ => new TaskState());
lock (state) lock (state)
{ {
@@ -65,7 +77,7 @@ public sealed class HandoffRoundTracker
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal; var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
var effective = nextPhase; var effective = nextPhase;
if (state.Count > MaxHandoffs && !isFinal) if (state.Count > cap && !isFinal)
{ {
effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal; effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal;
isFinal = true; isFinal = true;
+15 -3
View File
@@ -321,7 +321,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.UsageThrottleFiveHourHardPct, row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct, row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct, row.UsageThrottleSevenDayHardPct,
row.AutoContinueOnUsageLimit); row.AutoContinueOnUsageLimit,
row.PowerUseEnabled,
row.ListHandlerMaxHandoffs);
} }
public async Task UpdateAppSettings(AppSettingsDto dto) public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -357,6 +359,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct, UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct, UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit, AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit,
PowerUseEnabled = dto.PowerUseEnabled,
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
// client would remove that bound entirely, so clamp instead of trusting the wire value.
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
}); });
} }
@@ -553,7 +559,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var id => id, var id => id,
}; };
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null) // Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen.
var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false;
// powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das
// EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit.
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed)
{ {
await repo.DeleteConfigAsync(dto.ListId); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -570,6 +581,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
VerifyCommand = verifyCommand, VerifyCommand = verifyCommand,
PermissionMode = permissionMode, PermissionMode = permissionMode,
TicketProjectId = ticketProjectId, TicketProjectId = ticketProjectId,
PowerUseAllowed = powerUseAllowed,
}); });
} }
@@ -593,7 +605,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx); var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId); var config = await repo.GetConfigAsync(listId);
if (config is null) return null; if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId); return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed);
} }
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status) public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -300,7 +300,8 @@ public sealed class InteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir); Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md"); var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), ct); await File.WriteAllTextAsync(systemPromptPath,
await ComposeHandlerPromptAsync(ctx, listId, PromptKind.MergeHelperTriage, ct), ct);
var briefPath = Path.Combine(sessionDir, "brief.md"); var briefPath = Path.Combine(sessionDir, "brief.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial, await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
@@ -395,18 +396,22 @@ public sealed class InteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir); Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md"); var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct); await File.WriteAllTextAsync(systemPromptPath,
await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), ct);
// The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools // The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools
// registered this handoff against -- never recomputed here, so the brief can't drift from // registered this handoff against -- never recomputed here, so the brief can't drift from
// the server-side cap that actually enforces the chain. A "_final" round adds one more line // the server-side cap that actually enforces the chain. The cap is the CONFIGURED one
// (ListHandlerMaxHandoffs), read through GetStatus so the clamp and the arithmetic live in
// one place; hardcoding the default here would understate a raised budget and tell the model
// it has two rounds left when it really has dozens. A "_final" round adds one more line
// forbidding a further rerun, so the chain is bounded structurally rather than by prompt // forbidding a further rerun, so the chain is bounded structurally rather than by prompt
// appeal alone. // appeal alone.
var round = _handoffRounds.GetState(taskId); var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(ct)).ListHandlerMaxHandoffs;
var remaining = Math.Max(HandoffRoundTracker.MaxHandoffs - round.Count, 0); var round = _handoffRounds.GetStatus(taskId, maxHandoffs);
var roundInfo = $"Handoff budget: this is round {round.Count} of " + var roundInfo = $"Handoff budget: this is round {round.Round} of " +
$"{HandoffRoundTracker.MaxHandoffs} possible handoffs for this list-handler run " + $"{Math.Max(1, maxHandoffs)} possible handoffs for this list-handler run " +
$"({remaining} more possible after this one)."; $"({round.Remaining} more possible after this one).";
if (phase.IsFinal) if (phase.IsFinal)
roundInfo += "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " + roundInfo += "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
"merge what you can, report the rest, and print the summary."; "merge what you can, report the rest, and print the summary.";
@@ -564,6 +569,31 @@ public sealed class InteractiveLaunchSpecService
return handlerTask.Id; return handlerTask.Id;
} }
// The list-handler system prompt, plus the PowerUse addendum when this run is in PowerUse.
//
// PowerUse requires BOTH the global switch (AppSettingsEntity.PowerUseEnabled, toggled on the
// usage monitor) AND the list's own opt-in (ListConfigEntity.PowerUseAllowed). That is the whole
// point of the two-key design: the global switch can be left on overnight without turning some
// unrelated list's handler run unattended, because a list that never opted in keeps the
// interactive prompt unchanged.
//
// Appending rather than substituting a second prompt set keeps the shared 95% in one place --
// the addendum is written to read as an override of the base prompt's interactive rules. A run
// that is not in PowerUse gets a byte-identical prompt to what it got before PowerUse existed.
private static async Task<string> ComposeHandlerPromptAsync(
ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct)
{
var basePrompt = PromptFiles.ReadOrDefault(kind);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
if (!settings.PowerUseEnabled) return basePrompt;
var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct);
if (listConfig?.PowerUseAllowed != true) return basePrompt;
return basePrompt + "\n\n" + PromptFiles.ReadOrDefault(PromptKind.PowerUseAddendum);
}
// Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the // Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the
// "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait // "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait
// + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only // + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only
@@ -0,0 +1,98 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Tests;
public sealed class PowerUseTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
private const string ListId = "l1";
public PowerUseTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_poweruse_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
_ctx.Lists.Add(new ListEntity { Id = ListId, Name = "Test", CreatedAt = DateTime.UtcNow });
_ctx.SaveChanges();
}
public void Dispose()
{
_ctx.Dispose();
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
private ClaudeDoDbContext Reload() => new(
new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite(_ctx.Database.GetConnectionString()).Options);
[Fact]
public async Task ListConfig_PowerUseAllowed_DefaultsToFalse()
{
await new ListRepository(_ctx).SetConfigAsync(new ListConfigEntity { ListId = ListId, Model = "sonnet" });
using var reload = Reload();
var cfg = await new ListRepository(reload).GetConfigAsync(ListId);
Assert.False(cfg!.PowerUseAllowed);
}
[Fact]
public async Task ListConfig_PowerUseAllowed_RoundtripsTrue()
{
await new ListRepository(_ctx).SetConfigAsync(new ListConfigEntity { ListId = ListId, PowerUseAllowed = true });
using var reload = Reload();
var cfg = await new ListRepository(reload).GetConfigAsync(ListId);
Assert.True(cfg!.PowerUseAllowed);
}
/// SetConfigAsync copies the incoming entity field by field onto the existing row. A field
/// forgotten in that copy block reads as "silently reset on the next unrelated save", which is
/// exactly how the ticket-project link broke before. Pin it for PowerUseAllowed.
[Fact]
public async Task ListConfig_UpdatingAnotherField_DoesNotResetPowerUseAllowed()
{
var repo = new ListRepository(_ctx);
await repo.SetConfigAsync(new ListConfigEntity { ListId = ListId, PowerUseAllowed = true });
await repo.SetConfigAsync(new ListConfigEntity { ListId = ListId, PowerUseAllowed = true, VerifyCommand = "dotnet build" });
using var reload = Reload();
var cfg = await new ListRepository(reload).GetConfigAsync(ListId);
Assert.True(cfg!.PowerUseAllowed);
Assert.Equal("dotnet build", cfg.VerifyCommand);
}
[Fact]
public async Task AppSettings_PowerUseDefaults_AreOffAndFourHandoffs()
{
var settings = await new AppSettingsRepository(_ctx).GetAsync();
Assert.False(settings.PowerUseEnabled);
Assert.Equal(4, settings.ListHandlerMaxHandoffs);
}
[Fact]
public async Task AppSettings_PowerUseFields_Roundtrip()
{
await new AppSettingsRepository(_ctx).GetAsync();
var row = await _ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
row.PowerUseEnabled = true;
row.ListHandlerMaxHandoffs = 40;
await _ctx.SaveChangesAsync();
using var reload = Reload();
var settings = await new AppSettingsRepository(reload).GetAsync();
Assert.True(settings.PowerUseEnabled);
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
}
}
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External; using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External; namespace ClaudeDo.Worker.Tests.External;
@@ -26,7 +27,17 @@ public sealed class HandoffMcpToolsTests : IDisposable
public void Dispose() { _ctx.Dispose(); _db.Dispose(); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) => private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) =>
new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker()); new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker(), _db.CreateFactory());
/// The cap the SUT reads comes from app_settings, so a test that wants a different cap has to
/// write it there. GetAsync is AsNoTracking, hence the separate tracked query.
private async Task SetMaxHandoffsAsync(int value)
{
await new AppSettingsRepository(_ctx).GetAsync();
var row = await _ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
row.ListHandlerMaxHandoffs = value;
await _ctx.SaveChangesAsync();
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t") private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
{ {
@@ -212,7 +223,7 @@ public sealed class HandoffMcpToolsTests : IDisposable
var (count, sealedState) = tracker.GetState(taskId); var (count, sealedState) = tracker.GetState(taskId);
Assert.Equal(registrations, count); Assert.Equal(registrations, count);
Assert.Equal(expectedRemaining, HandoffRoundTracker.MaxHandoffs - count); Assert.Equal(expectedRemaining, HandoffRoundTracker.DefaultMaxHandoffs - count);
Assert.False(sealedState); Assert.False(sealedState);
} }
@@ -255,6 +266,79 @@ public sealed class HandoffMcpToolsTests : IDisposable
Assert.Contains("new handler run", result.Error, StringComparison.OrdinalIgnoreCase); Assert.Contains("new handler run", result.Error, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task HandoffListHandler_RaisedCapSetting_AllowsMoreRoundsBeforeCoercing()
{
await SetMaxHandoffsAsync(8);
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var tracker = new HandoffRoundTracker();
var survivors = new[] { survivor.Id };
// The 5th handoff is where the default cap of 4 would coerce; with 8 configured it must not.
foreach (var phase in new[] { "wait", "merge", "wait", "merge", "wait", "merge", "wait", "merge" })
{
var sut = BuildSut(tracker);
var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None);
Assert.False(result.Coerced);
Assert.Equal(phase, result.NextPhase);
}
var ninthSut = BuildSut(tracker);
var ninth = await ninthSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
Assert.True(ninth.Coerced);
Assert.Equal("wait_final", ninth.NextPhase);
}
[Fact]
public async Task HandoffListHandler_CapOfZero_IsClampedToOne_NotUnbounded()
{
// 0 must not read as "no cap" — that would remove the only bound on a runaway chain.
await SetMaxHandoffsAsync(0);
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var tracker = new HandoffRoundTracker();
var survivors = new[] { survivor.Id };
var firstSut = BuildSut(tracker);
var first = await firstSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
Assert.False(first.Coerced);
var secondSut = BuildSut(tracker);
var second = await secondSut.HandoffListHandler(handlerTask.Id, survivors, "merge", CancellationToken.None);
Assert.True(second.Coerced);
Assert.Equal("merge_final", second.NextPhase);
}
/// GetHandoffStatus must report against the SAME cap HandoffListHandler enforces. Reading it
/// off the constant would understate a raised budget and make the tool lie about how much room
/// the chain has left.
[Fact]
public async Task GetHandoffStatus_RaisedCap_ReportsConfiguredRemaining()
{
await SetMaxHandoffsAsync(40);
var listId = await SeedListAsync();
var handlerTask = await SeedTaskAsync(listId);
var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor");
var tracker = new HandoffRoundTracker();
var survivors = new[] { survivor.Id };
await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None);
await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "merge", CancellationToken.None);
var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None);
Assert.Equal(2, status.Round);
Assert.Equal(38, status.Remaining);
Assert.False(status.Final);
}
[Fact] [Fact]
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter() public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
{ {
@@ -290,7 +374,7 @@ public sealed class HandoffMcpToolsTests : IDisposable
Assert.Equal(handlerTask.Id, status.TaskId); Assert.Equal(handlerTask.Id, status.TaskId);
Assert.Equal(handlerTask.Number, status.Number); Assert.Equal(handlerTask.Number, status.Number);
Assert.Equal(0, status.Round); Assert.Equal(0, status.Round);
Assert.Equal(HandoffRoundTracker.MaxHandoffs, status.Remaining); Assert.Equal(HandoffRoundTracker.DefaultMaxHandoffs, status.Remaining);
Assert.False(status.Final); Assert.False(status.Final);
} }
@@ -309,7 +393,7 @@ public sealed class HandoffMcpToolsTests : IDisposable
var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None); var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None);
Assert.Equal(2, status.Round); Assert.Equal(2, status.Round);
Assert.Equal(HandoffRoundTracker.MaxHandoffs - 2, status.Remaining); Assert.Equal(HandoffRoundTracker.DefaultMaxHandoffs - 2, status.Remaining);
Assert.False(status.Final); Assert.False(status.Final);
} }
@@ -624,6 +624,134 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
return dir; return dir;
} }
// --- PowerUse composition -------------------------------------------------------------------
// PowerUse needs BOTH the global switch and the list's opt-in. These three tests pin all four
// corners of that AND, because the whole safety story of the feature is that the global switch
// alone cannot make a list unattended.
private async Task SetPowerUseEnabledAsync(bool value)
{
using var ctx = _db.CreateContext();
await new AppSettingsRepository(ctx).GetAsync();
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
row.PowerUseEnabled = value;
await ctx.SaveChangesAsync();
}
private async Task SetMaxHandoffsAsync(int value)
{
using var ctx = _db.CreateContext();
await new AppSettingsRepository(ctx).GetAsync();
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
row.ListHandlerMaxHandoffs = value;
await ctx.SaveChangesAsync();
}
/// The handoff brief reports the budget the chain is ACTUALLY bounded by. Recomputing it from
/// HandoffRoundTracker.DefaultMaxHandoffs would compile, look fine, and quietly tell the model
/// it has 2 rounds left when the configured cap gives it 39 — so pin the configured value.
[Fact]
public async Task BuildForMergeHelperHandoffAsync_RaisedCap_BriefReportsConfiguredBudget()
{
var repo = Path.Combine(_tempDir, "capBrief");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var handler = Guid.NewGuid().ToString();
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(handler, listId, TaskStatus.Idle, title: "List handler: Alpha");
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor task");
await SetMaxHandoffsAsync(40);
var tracker = new HandoffRoundTracker();
tracker.Register(handler, "wait", 40);
var svc = BuildService(tracker);
var spec = await svc.BuildForMergeHelperHandoffAsync(handler, new[] { survivor }, "wait", CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = await File.ReadAllTextAsync(Path.Combine(sessionDir, "handoff.md"));
Assert.Contains("round 1 of 40", brief);
Assert.Contains("39 more possible", brief);
}
private async Task SetPowerUseAllowedAsync(string listId, bool value)
{
using var ctx = _db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
{
ListId = listId,
PowerUseAllowed = value,
});
}
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId)
{
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md"));
}
private static string AddendumMarker =>
PromptFiles.DefaultFor(PromptKind.PowerUseAddendum).Split('\n')[0].Trim();
[Fact]
public async Task BuildForMergeHelperAsync_PowerUseOffGlobally_LeavesPromptUntouched()
{
var repo = Path.Combine(_tempDir, "puOffGlobal");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
await SetPowerUseEnabledAsync(false);
await SetPowerUseAllowedAsync(listId, true);
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
Assert.DoesNotContain(AddendumMarker, prompt);
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
}
[Fact]
public async Task BuildForMergeHelperAsync_PowerUseOnButListDidNotOptIn_LeavesPromptUntouched()
{
var repo = Path.Combine(_tempDir, "puNoOptIn");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
await SetPowerUseEnabledAsync(true);
// No list config row at all — the common case for an unrelated list.
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
Assert.DoesNotContain(AddendumMarker, prompt);
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
}
[Fact]
public async Task BuildForMergeHelperAsync_PowerUseOnAndListOptedIn_AppendsAddendum()
{
var repo = Path.Combine(_tempDir, "puOn");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
await SetPowerUseEnabledAsync(true);
await SetPowerUseAllowedAsync(listId, true);
var prompt = await ReadHandlerSystemPromptAsync(listId, taskId);
Assert.Contains(AddendumMarker, prompt);
// The addendum overrides the base prompt, so it has to come AFTER it, not replace it.
Assert.StartsWith(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
}
[Fact] [Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation() public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{ {
@@ -1099,8 +1227,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase); Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
// Round info always names the current round and the total cap -- this is the ONE // Round info always names the current round and the total cap -- this is the ONE
// Register call above, so round 1 of MaxHandoffs. // Register call above, and this test leaves ListHandlerMaxHandoffs at its default,
Assert.Contains($"round 1 of {HandoffRoundTracker.MaxHandoffs}", handoffBrief); // so round 1 of DefaultMaxHandoffs.
Assert.Contains($"round 1 of {HandoffRoundTracker.DefaultMaxHandoffs}", handoffBrief);
if (expectFinalNote) if (expectFinalNote)
{ {