feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings

Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
This commit is contained in:
mika kuns
2026-09-02 19:09:47 +02:00
parent 20b3b4ce2b
commit 69898be5c9
21 changed files with 1596 additions and 14 deletions
+8
View File
@@ -41,10 +41,18 @@ Beyond the basics it carries:
| `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. |
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the
singleton row — a property missing from that block never persists, however correctly the caller
filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and
`ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column
to that block, not just to the entity + the hub.
### Model / effort registries
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
@@ -72,6 +72,12 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.PowerUseEnabled)
.HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false);
builder.Property(s => s.PowerUseWindowStart)
.HasColumnName("power_use_window_start");
builder.Property(s => s.PowerUseWindowEnd)
.HasColumnName("power_use_window_end");
builder.Property(s => s.PowerUseHandoffPct)
.HasColumnName("power_use_handoff_pct").IsRequired().HasDefaultValue(95);
builder.Property(s => s.ListHandlerMaxHandoffs)
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerUseWindow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "power_use_handoff_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 95);
migrationBuilder.AddColumn<string>(
name: "power_use_window_end",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "power_use_window_start",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "power_use_handoff_pct", "power_use_window_end", "power_use_window_start" },
values: new object[] { 95, null, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "power_use_handoff_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_end",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_start",
table: "app_settings");
}
}
}
@@ -100,6 +100,20 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false)
.HasColumnName("power_use_enabled");
b.Property<int>("PowerUseHandoffPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(95)
.HasColumnName("power_use_handoff_pct");
b.Property<string>("PowerUseWindowEnd")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_end");
b.Property<string>("PowerUseWindowStart")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_start");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
@@ -192,6 +206,7 @@ namespace ClaudeDo.Data.Migrations
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
PowerUseEnabled = false,
PowerUseHandoffPct = 95,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
@@ -69,6 +69,17 @@ public sealed class AppSettingsEntity
// run unattended. Composition lives in InteractiveLaunchSpecService.
public bool PowerUseEnabled { get; set; }
// Optional local-time window (both `HH:mm`) narrowing when PowerUse applies, checked per phase
// at prompt-build time. Both null (the default) = round the clock, byte-identical to the
// pre-window behaviour; only one set counts as unset too. End before start wraps over midnight.
// Semantics live in PowerUseWindow.
public string? PowerUseWindowStart { get; set; }
public string? PowerUseWindowEnd { get; set; }
// Percentage of the 5h usage window at which a PowerUse handler session is told to hand off
// rather than start more work. Stored and displayed here; the consumer is the handoff prompt.
public int PowerUseHandoffPct { get; set; } = 95;
// 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.
@@ -0,0 +1,32 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// The optional local-time window restricting when PowerUse applies
/// (<see cref="AppSettingsEntity.PowerUseWindowStart"/> / <c>End</c>, both <c>HH:mm</c>).
/// Pure on purpose — the caller supplies "now", so the window logic is testable without a clock.
/// </summary>
public static class PowerUseWindow
{
/// <summary>Parses an <c>HH:mm</c> string; anything else (including null/blank) is null.</summary>
public static TimeOnly? Parse(string? value)
=> TimeOnly.TryParseExact(value?.Trim(), "HH:mm", out var t) ? t : null;
/// <summary>Round-trips a stored/typed value through <see cref="Parse"/>, so an unparseable
/// string is stored as null instead of sitting in the column pretending to be a window.</summary>
public static string? Normalize(string? value) => Parse(value)?.ToString("HH:mm");
/// <summary>
/// True when <paramref name="now"/> falls inside the window. Half-open: start is included,
/// end is not. Either bound missing = no window at all = always true (the pre-window
/// behaviour). A window whose end is before its start wraps over midnight.
/// </summary>
public static bool Contains(TimeOnly now, TimeOnly? start, TimeOnly? end)
{
if (start is not { } from || end is not { } to) return true;
return from < to ? now >= from && now < to : now >= from || now < to;
}
/// <summary>Convenience overload for the two stored strings and a wall clock.</summary>
public static bool IsOpen(DateTime now, string? start, string? end)
=> Contains(TimeOnly.FromDateTime(now), Parse(start), Parse(end));
}
@@ -93,6 +93,16 @@ public sealed class AppSettingsRepository
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit;
// ModelPresets / PowerUse* / ListHandlerMaxHandoffs were missing from this copy block, so
// every write of them was silently dropped (the row keeps whatever it already had). This
// block is the single write path for the singleton row — a field absent here does not
// persist, no matter how correctly the hub fills the entity.
row.ModelPresets = string.IsNullOrWhiteSpace(updated.ModelPresets) ? row.ModelPresets : updated.ModelPresets;
row.PowerUseEnabled = updated.PowerUseEnabled;
row.PowerUseWindowStart = PowerUseWindow.Normalize(updated.PowerUseWindowStart);
row.PowerUseWindowEnd = PowerUseWindow.Normalize(updated.PowerUseWindowEnd);
row.PowerUseHandoffPct = Math.Clamp(updated.PowerUseHandoffPct, 50, 99);
row.ListHandlerMaxHandoffs = Math.Max(1, updated.ListHandlerMaxHandoffs);
await _context.SaveChangesAsync(ct);
}
+7 -1
View File
@@ -40,7 +40,13 @@ public record AppSettingsDto(
// 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);
int ListHandlerMaxHandoffs = 4,
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
// which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has
// to round-trip them too.
string? PowerUseWindowStart = null,
string? PowerUseWindowEnd = null,
int PowerUseHandoffPct = 95);
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -511,6 +511,10 @@
"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.",
"powerUseWindow": "Nur zwischen",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
"refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})",
@@ -511,6 +511,10 @@
"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.",
"powerUseWindow": "Only between",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Hand off at (% of 5h)",
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
"refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})",
@@ -134,6 +134,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
// here, but they must survive a Settings save.
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
public async Task LoadAsync()
{
IsBusy = true;
@@ -156,6 +160,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -224,7 +229,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.SevenHard,
General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs);
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -108,6 +109,14 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
[ObservableProperty] private int _powerUseHandoffPct = 95;
// 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;
@@ -115,13 +124,16 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUseAsync(value);
_ = SavePowerUse();
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own.
private async Task SavePowerUseAsync(bool value)
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
/// value the user just typed into a sibling box.
[RelayCommand]
private async Task SavePowerUse()
{
if (_loadingPowerUse) return;
try
{
var settings = await _worker.GetAppSettingsAsync();
@@ -131,7 +143,28 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
return;
}
await _worker.UpdateAppSettingsAsync(settings with { PowerUseEnabled = value });
// The worker stores an unparseable time as null; normalize here too so the box shows
// what was actually saved instead of a typo that looks like a live window.
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
await _worker.UpdateAppSettingsAsync(settings with
{
PowerUseEnabled = PowerUseEnabled,
PowerUseWindowStart = start,
PowerUseWindowEnd = end,
PowerUseHandoffPct = pct,
});
_loadingPowerUse = true;
try
{
PowerUseWindowStart = start;
PowerUseWindowEnd = end;
PowerUseHandoffPct = pct;
}
finally { _loadingPowerUse = false; }
}
catch (Exception ex)
{
@@ -192,7 +225,13 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
if (settings is not null)
{
_loadingPowerUse = true;
try { PowerUseEnabled = settings.PowerUseEnabled; }
try
{
PowerUseEnabled = settings.PowerUseEnabled;
PowerUseWindowStart = settings.PowerUseWindowStart;
PowerUseWindowEnd = settings.PowerUseWindowEnd;
PowerUseHandoffPct = settings.PowerUseHandoffPct;
}
finally { _loadingPowerUse = false; }
}
@@ -144,6 +144,29 @@
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
<!-- Window + handoff threshold. Same commit-on-blur/Enter discipline as the gauge
legend boxes: a hub write per keystroke would be one save per typed digit. -->
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.powerUseWindow}"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowStart, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowEnd, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Margin="12,0,0,0"
Text="{loc:Tr modals.usageMonitor.powerUseHandoffPct}"/>
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
Minimum="50" Maximum="99" Increment="1" FormatString="0"
Value="{Binding PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
</StackPanel>
<TextBlock Classes="meta" TextWrapping="Wrap" Opacity="0.75"
Text="{loc:Tr modals.usageMonitor.powerUseWindowHint}"/>
</StackPanel>
</Border>
@@ -33,4 +33,19 @@ public partial class UsageMonitorModalView : Window
OnStageBoxCommit(sender, e);
e.Handled = true;
}
/// <summary>Persists the PowerUse window / handoff threshold on blur — a save per keystroke
/// would be one hub round-trip per typed digit.</summary>
private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e)
{
if (DataContext is not UsageMonitorModalViewModel vm) return;
if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null);
}
private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnPowerUseBoxCommit(sender, e);
e.Handled = true;
}
}
+10 -1
View File
@@ -323,7 +323,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.UsageThrottleSevenDayHardPct,
row.AutoContinueOnUsageLimit,
row.PowerUseEnabled,
row.ListHandlerMaxHandoffs);
row.ListHandlerMaxHandoffs,
row.PowerUseWindowStart,
row.PowerUseWindowEnd,
row.PowerUseHandoffPct);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -363,6 +366,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// 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),
// An unparseable time string is stored as null (= no window) rather than rejected: the
// window is an optional narrowing, and a typo must not silently mean "PowerUse never
// applies". Below 50% a handoff threshold would fire before the session got anywhere.
PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart),
PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd),
PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99),
});
}
@@ -45,6 +45,9 @@ public sealed class InteractiveLaunchSpecService
private readonly string _claudePath;
private readonly HandoffRoundTracker _handoffRounds;
// Local wall clock, injectable so the PowerUse time window is testable without sleeping.
private readonly Func<DateTime> _now;
public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ISessionSkillSeeder skillSeeder,
@@ -52,8 +55,10 @@ public sealed class InteractiveLaunchSpecService
WorktreeManager wtManager,
GitService git,
WorkerConfig cfg,
HandoffRoundTracker handoffRounds)
HandoffRoundTracker handoffRounds,
Func<DateTime>? now = null)
{
_now = now ?? (() => DateTime.Now);
_dbFactory = dbFactory;
_skillSeeder = skillSeeder;
_skillRegistry = skillRegistry;
@@ -580,7 +585,11 @@ public sealed class InteractiveLaunchSpecService
// 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(
//
// The optional time window is checked HERE, i.e. per phase at prompt-build time: a chain that
// hands off out of the window drops the addendum from that point on instead of staying
// unattended because the first phase happened to start inside it.
private async Task<string> ComposeHandlerPromptAsync(
ClaudeDoDbContext ctx, string listId, PromptKind kind, CancellationToken ct)
{
var basePrompt = PromptFiles.ReadOrDefault(kind);
@@ -588,6 +597,9 @@ public sealed class InteractiveLaunchSpecService
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
if (!settings.PowerUseEnabled) return basePrompt;
if (!PowerUseWindow.IsOpen(_now(), settings.PowerUseWindowStart, settings.PowerUseWindowEnd))
return basePrompt;
var listConfig = await new ListRepository(ctx).GetConfigAsync(listId, ct);
if (listConfig?.PowerUseAllowed != true) return basePrompt;
@@ -95,4 +95,56 @@ public sealed class PowerUseTests : IDisposable
Assert.True(settings.PowerUseEnabled);
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
}
[Fact]
public async Task AppSettings_WindowDefaults_AreEmptyAnd95Percent()
{
var settings = await new AppSettingsRepository(_ctx).GetAsync();
Assert.Null(settings.PowerUseWindowStart);
Assert.Null(settings.PowerUseWindowEnd);
Assert.Equal(95, settings.PowerUseHandoffPct);
}
/// UpdateAsync copies the incoming entity field by field onto the singleton row — a field
/// missing from that block never persists, however correctly the hub filled the entity. That
/// was true for PowerUseEnabled, ListHandlerMaxHandoffs and ModelPresets until 2026-09-02, so
/// pin the whole PowerUse group through the real write path.
[Fact]
public async Task AppSettings_UpdateAsync_PersistsThePowerUseGroup()
{
var repo = new AppSettingsRepository(_ctx);
var current = await repo.GetAsync();
current.PowerUseEnabled = true;
current.PowerUseWindowStart = "22:00";
current.PowerUseWindowEnd = "07:00";
current.PowerUseHandoffPct = 88;
current.ListHandlerMaxHandoffs = 40;
await repo.UpdateAsync(current);
using var reload = Reload();
var settings = await new AppSettingsRepository(reload).GetAsync();
Assert.True(settings.PowerUseEnabled);
Assert.Equal("22:00", settings.PowerUseWindowStart);
Assert.Equal("07:00", settings.PowerUseWindowEnd);
Assert.Equal(88, settings.PowerUseHandoffPct);
Assert.Equal(40, settings.ListHandlerMaxHandoffs);
}
[Fact]
public async Task AppSettings_UpdateAsync_NormalizesAndClamps()
{
var repo = new AppSettingsRepository(_ctx);
var current = await repo.GetAsync();
current.PowerUseWindowStart = "nonsense";
current.PowerUseWindowEnd = " 07:00 ";
current.PowerUseHandoffPct = 5;
await repo.UpdateAsync(current);
using var reload = Reload();
var settings = await new AppSettingsRepository(reload).GetAsync();
Assert.Null(settings.PowerUseWindowStart);
Assert.Equal("07:00", settings.PowerUseWindowEnd);
Assert.Equal(50, settings.PowerUseHandoffPct);
}
}
@@ -0,0 +1,54 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Tests;
/// The window decides whether a handler session runs unattended, so its edges matter: an
/// off-by-one at midnight would leave an overnight window either always or never open.
public sealed class PowerUseWindowTests
{
private static TimeOnly T(string hhmm) => TimeOnly.ParseExact(hhmm, "HH:mm");
[Theory]
// Plain window, half-open: start included, end excluded.
[InlineData("09:00", "08:00", "17:00", true)]
[InlineData("08:00", "08:00", "17:00", true)]
[InlineData("17:00", "08:00", "17:00", false)]
[InlineData("07:59", "08:00", "17:00", false)]
[InlineData("23:30", "08:00", "17:00", false)]
// Wrap over midnight.
[InlineData("23:30", "22:00", "07:00", true)]
[InlineData("00:30", "22:00", "07:00", true)]
[InlineData("12:00", "22:00", "07:00", false)]
[InlineData("07:00", "22:00", "07:00", false)]
public void Contains_BothBoundsSet(string now, string start, string end, bool expected)
=> Assert.Equal(expected, PowerUseWindow.Contains(T(now), T(start), T(end)));
/// Only one bound is not a window — it must behave exactly like no window, or a half-filled
/// pair of boxes would silently switch PowerUse off for most of the day.
[Fact]
public void Contains_OnlyOneBoundSet_IsAlwaysOpen()
{
Assert.True(PowerUseWindow.Contains(T("12:00"), T("22:00"), null));
Assert.True(PowerUseWindow.Contains(T("12:00"), null, T("07:00")));
Assert.True(PowerUseWindow.Contains(T("12:00"), null, null));
}
[Theory]
[InlineData("22:00", "22:00")]
[InlineData(" 07:30 ", "07:30")]
[InlineData("7:30", null)]
[InlineData("22:00:00", null)]
[InlineData("nonsense", null)]
[InlineData("", null)]
[InlineData(null, null)]
public void Normalize_KeepsOnlyExactHhMm(string? input, string? expected)
=> Assert.Equal(expected, PowerUseWindow.Normalize(input));
[Fact]
public void IsOpen_UsesTheTimeOfDayOfTheGivenInstant()
{
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 23, 30, 0), "22:00", "07:00"));
Assert.False(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), "22:00", "07:00"));
Assert.True(PowerUseWindow.IsOpen(new DateTime(2026, 9, 2, 12, 0, 0), null, null));
}
}
@@ -0,0 +1,101 @@
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
/// The PowerUse window and handoff threshold are edited on the usage monitor, but Settings→General
/// rebuilds the whole DTO positionally on save. So the contract that matters is: whatever
/// GetAppSettings hands out, feeding it straight back must change nothing.
public sealed class PowerUseSettingsHubTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
[Fact]
public async Task GetAppSettings_Defaults_HaveNoWindowAnd95Percent()
{
var settings = await CreateHub().GetAppSettings();
Assert.Null(settings.PowerUseWindowStart);
Assert.Null(settings.PowerUseWindowEnd);
Assert.Equal(95, settings.PowerUseHandoffPct);
}
[Fact]
public async Task UpdateAppSettings_RoundTripsTheWindowAndThreshold()
{
var hub = CreateHub();
var before = await hub.GetAppSettings();
await hub.UpdateAppSettings(before with
{
PowerUseEnabled = true,
PowerUseWindowStart = "22:00",
PowerUseWindowEnd = "07:00",
PowerUseHandoffPct = 88,
});
var after = await hub.GetAppSettings();
Assert.True(after.PowerUseEnabled);
Assert.Equal("22:00", after.PowerUseWindowStart);
Assert.Equal("07:00", after.PowerUseWindowEnd);
Assert.Equal(88, after.PowerUseHandoffPct);
}
/// The Settings→General case: a save that touches none of the PowerUse fields must leave them
/// exactly as they were.
[Fact]
public async Task UpdateAppSettings_UnchangedDto_LeavesPowerUseFieldsAlone()
{
var hub = CreateHub();
await hub.UpdateAppSettings((await hub.GetAppSettings()) with
{
PowerUseEnabled = true,
PowerUseWindowStart = "22:00",
PowerUseWindowEnd = "07:00",
PowerUseHandoffPct = 88,
});
var stored = await hub.GetAppSettings();
await hub.UpdateAppSettings(stored);
var after = await hub.GetAppSettings();
Assert.Equal(stored.PowerUseEnabled, after.PowerUseEnabled);
Assert.Equal(stored.PowerUseWindowStart, after.PowerUseWindowStart);
Assert.Equal(stored.PowerUseWindowEnd, after.PowerUseWindowEnd);
Assert.Equal(stored.PowerUseHandoffPct, after.PowerUseHandoffPct);
}
[Fact]
public async Task UpdateAppSettings_InvalidTimeAndOutOfRangePct_AreNormalized()
{
var hub = CreateHub();
await hub.UpdateAppSettings((await hub.GetAppSettings()) with
{
PowerUseWindowStart = "25:99",
PowerUseWindowEnd = "07:00",
PowerUseHandoffPct = 120,
});
var after = await hub.GetAppSettings();
Assert.Null(after.PowerUseWindowStart);
Assert.Equal("07:00", after.PowerUseWindowEnd);
Assert.Equal(99, after.PowerUseHandoffPct);
}
}
@@ -73,12 +73,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
}
private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) =>
private InteractiveLaunchSpecService BuildService(
HandoffRoundTracker? handoffRounds = null, DateTime? now = null) =>
new(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(),
new WorkerConfig { ClaudeBin = _claudeStubPath },
handoffRounds ?? new HandoffRoundTracker());
handoffRounds ?? new HandoffRoundTracker(),
now is { } t ? () => t : null);
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
{
@@ -685,9 +687,19 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
});
}
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId)
private async Task SetPowerUseWindowAsync(string? start, string? end)
{
var svc = BuildService();
using var ctx = _db.CreateContext();
await new AppSettingsRepository(ctx).GetAsync();
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
row.PowerUseWindowStart = start;
row.PowerUseWindowEnd = end;
await ctx.SaveChangesAsync();
}
private async Task<string> ReadHandlerSystemPromptAsync(string listId, string taskId, DateTime? now = null)
{
var svc = BuildService(now: now);
var spec = await svc.BuildForMergeHelperAsync(
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
@@ -752,6 +764,67 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.StartsWith(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
}
/// The window is a third key on top of the global switch and the list opt-in. Both corners
/// use the same overnight window, so only the injected clock differs — that is the whole point
/// of injecting it rather than reading DateTime.Now inside the composition.
[Fact]
public async Task BuildForMergeHelperAsync_PowerUseOutsideWindow_LeavesPromptUntouched()
{
var repo = Path.Combine(_tempDir, "puOutsideWindow");
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);
await SetPowerUseWindowAsync("22:00", "07:00");
var prompt = await ReadHandlerSystemPromptAsync(
listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0));
Assert.DoesNotContain(AddendumMarker, prompt);
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), prompt);
}
[Fact]
public async Task BuildForMergeHelperAsync_PowerUseInsideWindow_AppendsAddendum()
{
var repo = Path.Combine(_tempDir, "puInsideWindow");
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);
await SetPowerUseWindowAsync("22:00", "07:00");
var prompt = await ReadHandlerSystemPromptAsync(
listId, taskId, now: new DateTime(2026, 9, 2, 23, 30, 0));
Assert.Contains(AddendumMarker, prompt);
}
[Fact]
public async Task BuildForMergeHelperAsync_HalfSetWindow_AppendsAddendumAtAnyTime()
{
var repo = Path.Combine(_tempDir, "puHalfWindow");
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);
await SetPowerUseWindowAsync("22:00", null);
var prompt = await ReadHandlerSystemPromptAsync(
listId, taskId, now: new DateTime(2026, 9, 2, 12, 0, 0));
Assert.Contains(AddendumMarker, prompt);
}
[Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{