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