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
@@ -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()
{