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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user