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
+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;