Merge branch 'main' into worktree-diff-side-by-side
# Conflicts: # src/ClaudeDo.Ui/CLAUDE.md
This commit is contained in:
@@ -29,9 +29,9 @@ public class ModelRegistryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByCostAscending_is_haiku_sonnet_opus()
|
||||
public void ByCostAscending_is_haiku_sonnet_opus_fable()
|
||||
{
|
||||
Assert.Equal(new[] { "haiku", "sonnet", "opus" }, ModelRegistry.ByCostAscending);
|
||||
Assert.Equal(new[] { "haiku", "sonnet", "opus", "fable" }, ModelRegistry.ByCostAscending);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -43,6 +43,33 @@ public class SettingsModalViewModelTests
|
||||
SessionSkills: null, ModelPresets: null,
|
||||
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
|
||||
|
||||
[Fact]
|
||||
public async Task Save_carries_dragged_throttle_stages_through_untouched()
|
||||
{
|
||||
// The throttle stages are only editable by dragging the usage-monitor gauges. Saving the
|
||||
// Settings modal rebuilds the whole DTO, so it must not reset them to the defaults.
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
AppToReturn = DtoWith(65, 95) with
|
||||
{
|
||||
UsageThrottleFiveHourSoftPct = 42,
|
||||
UsageThrottleFiveHourHardPct = 58,
|
||||
UsageThrottleSevenDaySoftPct = 71,
|
||||
UsageThrottleSevenDayHardPct = 88,
|
||||
},
|
||||
};
|
||||
var vm = MakeVm(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(worker.Saved);
|
||||
Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct);
|
||||
Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct);
|
||||
Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct);
|
||||
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
|
||||
}
|
||||
|
||||
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
|
||||
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
|
||||
MakeLocalizer(), new AppSettings());
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
// The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single
|
||||
// transient DB error therefore left the row on its old status forever — the "task stuck on
|
||||
// Queued although it is running" bug. It must retry, and fall back to a full reload.
|
||||
public class TasksIslandDeltaResilienceTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public TasksIslandDeltaResilienceTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
// Throws on the first N CreateDbContext calls, then behaves normally.
|
||||
private sealed class FlakyDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
private int _failuresLeft;
|
||||
public int CreateCalls { get; private set; }
|
||||
|
||||
public FlakyDbFactory(Func<ClaudeDoDbContext> create, int failuresLeft)
|
||||
{
|
||||
_create = create;
|
||||
_failuresLeft = failuresLeft;
|
||||
}
|
||||
|
||||
public ClaudeDoDbContext CreateDbContext()
|
||||
{
|
||||
CreateCalls++;
|
||||
if (_failuresLeft > 0)
|
||||
{
|
||||
_failuresLeft--;
|
||||
throw new InvalidOperationException("simulated transient DB failure");
|
||||
}
|
||||
return _create();
|
||||
}
|
||||
|
||||
public void FailNext() => _failuresLeft++;
|
||||
}
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
}
|
||||
|
||||
// A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList.
|
||||
private static ListNavItemViewModel UserList(string listEntityId, string name) =>
|
||||
new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name };
|
||||
|
||||
// LoadForList is void and fires a background task; this is the wait idiom the other
|
||||
// TasksIsland test files use.
|
||||
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
|
||||
{
|
||||
vm.LoadForList(list);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
if (vm.Items.Count > 0) break;
|
||||
}
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = "T1", ListId = "L1", Title = "Task one",
|
||||
Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0);
|
||||
var vm = new TasksIslandViewModel(flaky, new FakeWorker());
|
||||
var list = UserList("L1", "Work");
|
||||
|
||||
await LoadAndWaitAsync(vm, list);
|
||||
Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status);
|
||||
|
||||
// Worker flips the task to Running.
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
|
||||
t.Status = TaskStatus.Running;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// The next delta read fails once; the retry must still land the new status.
|
||||
flaky.FailNext();
|
||||
await vm.RefreshTaskFromWorkerAsync("T1");
|
||||
|
||||
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_delta_result_does_not_overwrite_a_newer_one()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
var factory = new FlakyDbFactory(NewContext, failuresLeft: 0);
|
||||
var vm = new TasksIslandViewModel(factory, new FakeWorker());
|
||||
var list = UserList("L1", "Work");
|
||||
await LoadAndWaitAsync(vm, list);
|
||||
|
||||
// Start refresh #1 while the DB still says Queued, but do not await it yet.
|
||||
var first = vm.RefreshTaskFromWorkerAsync("T1");
|
||||
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
|
||||
t.Status = TaskStatus.Running;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Refresh #2 sees Running and must win, regardless of completion order.
|
||||
var second = vm.RefreshTaskFromWorkerAsync("T1");
|
||||
|
||||
await Task.WhenAll(first, second);
|
||||
|
||||
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
|
||||
public int RefreshCalls;
|
||||
public Exception? RefreshThrows;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
|
||||
/// <summary>When set, the snapshot fetch never completes — stands in for the slow first
|
||||
/// transcript scan on the worker side.</summary>
|
||||
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
|
||||
public Exception? SnapshotThrows;
|
||||
|
||||
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
||||
{
|
||||
if (SnapshotThrows is not null) throw SnapshotThrows;
|
||||
return SnapshotGate?.Task ?? Task.FromResult(Snapshot);
|
||||
}
|
||||
|
||||
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
|
||||
{
|
||||
@@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests
|
||||
TaskUsageCalls++;
|
||||
return Task.FromResult(TaskRows);
|
||||
}
|
||||
|
||||
public AppSettingsDto? AppSettings;
|
||||
public AppSettingsDto? SavedSettings;
|
||||
|
||||
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppSettings);
|
||||
|
||||
public override Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||
{
|
||||
SavedSettings = dto;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppSettingsDto AppSettings() =>
|
||||
new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30,
|
||||
DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling",
|
||||
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7,
|
||||
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
|
||||
|
||||
private static UsageLimitDto Limit(
|
||||
string kind, double percent = 10, string severity = "normal",
|
||||
DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true)
|
||||
@@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests
|
||||
DateTime? fetchedAtUtc = null,
|
||||
int configuredSlots = 1,
|
||||
int effectiveSlots = 1,
|
||||
string? throttleBucket = null)
|
||||
string? throttleBucket = null,
|
||||
int throttleFiveHourSoftPct = 50,
|
||||
int throttleFiveHourHardPct = 65,
|
||||
int throttleSevenDaySoftPct = 50,
|
||||
int throttleSevenDayHardPct = 65)
|
||||
=> new(
|
||||
null, null, null, null,
|
||||
limits ?? Array.Empty<UsageLimitDto>(),
|
||||
fiveHourThresholdPct, sevenDayThresholdPct,
|
||||
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
|
||||
configuredSlots, effectiveSlots, throttleBucket);
|
||||
configuredSlots, effectiveSlots, throttleBucket,
|
||||
throttleFiveHourSoftPct, throttleFiveHourHardPct,
|
||||
throttleSevenDaySoftPct, throttleSevenDayHardPct);
|
||||
|
||||
// ── BeginLoad: the modal must open before the data lands ────────────────
|
||||
|
||||
[Fact]
|
||||
public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy()
|
||||
{
|
||||
var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource<UsageSnapshotDto?>() };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
|
||||
vm.BeginLoad();
|
||||
|
||||
Assert.True(vm.IsBusy);
|
||||
Assert.False(vm.ModelsEmpty);
|
||||
Assert.False(vm.TasksEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing()
|
||||
{
|
||||
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
vm.BeginLoad();
|
||||
|
||||
Assert.NotNull(reported);
|
||||
Assert.Contains("worker offline", reported);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy()
|
||||
{
|
||||
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.NotNull(reported);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
// ── Manual refresh ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
|
||||
Assert.Equal(80, vm.GaugeRows[0].GatePct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
|
||||
Assert.Null(vm.GaugeRows[0].GatePct);
|
||||
Assert.False(vm.GaugeRows[0].IsAdjustable);
|
||||
}
|
||||
|
||||
// ── Draggable stage markers ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_Session_CarriesPerBucketThrottleStages()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||
throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var row = vm.GaugeRows[0];
|
||||
Assert.Equal("five_hour", row.Bucket);
|
||||
Assert.Equal(45, row.SoftPct);
|
||||
Assert.Equal(60, row.HardPct);
|
||||
Assert.Equal(80, row.GatePct);
|
||||
Assert.True(row.IsAdjustable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
|
||||
throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var row = vm.GaugeRows[0];
|
||||
Assert.Equal("seven_day", row.Bucket);
|
||||
Assert.Equal(70, row.SoftPct);
|
||||
Assert.Equal(85, row.HardPct);
|
||||
Assert.Equal(90, row.GatePct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var row = vm.GaugeRows[0];
|
||||
row.SoftPct = 40;
|
||||
row.HardPct = 55;
|
||||
row.GatePct = 75;
|
||||
await row.CommitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(worker.SavedSettings);
|
||||
Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
|
||||
Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct);
|
||||
Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct);
|
||||
// The 7d bucket and unrelated settings ride along untouched.
|
||||
Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct);
|
||||
Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct);
|
||||
Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct);
|
||||
Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session") }),
|
||||
AppSettings = null,
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
string? reported = null;
|
||||
vm.ErrorReported += m => reported = m;
|
||||
|
||||
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(reported);
|
||||
Assert.Null(worker.SavedSettings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commit_NonAdjustableRow_SavesNothing()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(worker.SavedSettings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances()
|
||||
{
|
||||
// A poll landing mid-interaction must not swap the row the gauge is bound to.
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
var before = vm.GaugeRows[0];
|
||||
|
||||
vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44);
|
||||
|
||||
Assert.Same(before, vm.GaugeRows[0]);
|
||||
Assert.Equal(55, vm.GaugeRows[0].Percent);
|
||||
Assert.Equal(44, vm.GaugeRows[0].SoftPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSnapshot_NewLimitKind_AddsARow()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") });
|
||||
|
||||
Assert.Equal(2, vm.GaugeRows.Count);
|
||||
}
|
||||
|
||||
// ── Legend input boxes ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TypedStage_SavesTheEditedBucket()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var row = vm.GaugeRows[0];
|
||||
row.HardPct = 58;
|
||||
await row.CommitHardCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct);
|
||||
Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct);
|
||||
Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
|
||||
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
// Typing 95 into the soft box may not push past hard — and must not drag hard along.
|
||||
var row = vm.GaugeRows[0];
|
||||
row.SoftPct = 95;
|
||||
await row.CommitSoftCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(65, row.SoftPct);
|
||||
Assert.Equal(65, row.HardPct);
|
||||
Assert.Equal(80, row.GatePct);
|
||||
Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TypedGate_BelowHard_IsPinnedToHard()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
|
||||
throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
var row = vm.GaugeRows[0];
|
||||
row.GatePct = 20;
|
||||
await row.CommitGateCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(65, row.GatePct);
|
||||
Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TypedStage_OnNonAdjustableRow_SavesNothing()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
|
||||
AppSettings = AppSettings(),
|
||||
};
|
||||
var vm = new UsageMonitorModalViewModel(worker);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(worker.SavedSettings);
|
||||
}
|
||||
|
||||
// ── Drag math ────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard
|
||||
[InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard
|
||||
[InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0
|
||||
[InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate
|
||||
[InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft
|
||||
[InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate
|
||||
[InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100
|
||||
[InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard
|
||||
public void Drag_KeepsStagesOrderedAndInRange(
|
||||
double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate)
|
||||
{
|
||||
var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo);
|
||||
|
||||
Assert.Equal((expectedSoft, expectedHard, expectedGate), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drag_RoundsToWholePercent()
|
||||
{
|
||||
Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker()
|
||||
{
|
||||
// hard = 0 means "hard stage off" — soft must still be draggable up to the gate.
|
||||
Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drag_InconsistentStoredConfig_DoesNotThrow()
|
||||
{
|
||||
// soft above gate (only reachable by hand-editing the DB) must degrade, not crash.
|
||||
var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60);
|
||||
|
||||
Assert.Equal(50, result.Hard);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(50, UsageThresholdDrag.Stage.Soft)]
|
||||
[InlineData(63, UsageThresholdDrag.Stage.Hard)]
|
||||
[InlineData(82, UsageThresholdDrag.Stage.Gate)]
|
||||
public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected)
|
||||
{
|
||||
Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nearest_OutOfReach_GrabsNothing()
|
||||
{
|
||||
Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5));
|
||||
}
|
||||
|
||||
// ── Stale / gate bands ───────────────────────────────────────────────────
|
||||
|
||||
@@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
Assert.Equal(10, result.Config.MaxTurns);
|
||||
}
|
||||
|
||||
// ── GetTaskStatusValues ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskStatusValues_ContainsAllStatuses()
|
||||
{
|
||||
var sut = NewService();
|
||||
var values = await sut.GetTaskStatusValues();
|
||||
var names = values.Select(v => v.Status).ToHashSet();
|
||||
|
||||
foreach (var status in Enum.GetValues<TaskStatus>())
|
||||
Assert.Contains(status.ToString(), names);
|
||||
}
|
||||
|
||||
// ── ListTasks status filter ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable
|
||||
var repo = new AppSettingsRepository(ctx);
|
||||
var settings = await repo.GetAsync();
|
||||
settings.MaxParallelExecutions = maxParallel;
|
||||
settings.UsageThrottleSoftPct = softPct;
|
||||
settings.UsageThrottleHardPct = hardPct;
|
||||
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||
await repo.UpdateAsync(settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Online;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -55,7 +56,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private OnlineSyncService BuildService(FakeApi api, string? token = "test-token", bool enabled = true)
|
||||
private OnlineSyncService BuildService(
|
||||
FakeApi api, string? token = "test-token", bool enabled = true, HubBroadcaster? broadcaster = null)
|
||||
{
|
||||
var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 };
|
||||
var auth = new StaticTokenAuthProvider(token);
|
||||
@@ -64,7 +66,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
||||
api,
|
||||
auth,
|
||||
config,
|
||||
NullLogger<OnlineSyncService>.Instance);
|
||||
NullLogger<OnlineSyncService>.Instance,
|
||||
broadcaster ?? new HubBroadcaster(new CapturingHubContext()));
|
||||
}
|
||||
|
||||
private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
|
||||
@@ -103,6 +106,26 @@ public sealed class OnlineSyncServiceTests : IDisposable
|
||||
Assert.Contains(remoteId, api.MarkedImported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_Imports_RemoteTask_BroadcastsTaskUpdated()
|
||||
{
|
||||
var (listId, ctx, _, _) = await SeedAsync();
|
||||
using var _ = ctx;
|
||||
|
||||
var remoteId = Guid.NewGuid().ToString();
|
||||
var api = new FakeApi
|
||||
{
|
||||
UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)],
|
||||
};
|
||||
var hubContext = new CapturingHubContext();
|
||||
var svc = BuildService(api, broadcaster: new HubBroadcaster(hubContext));
|
||||
|
||||
await svc.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.Contains(hubContext.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == remoteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_UnknownList_Skips_And_DoesNotMark()
|
||||
{
|
||||
|
||||
@@ -121,4 +121,52 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable
|
||||
releaseProcess.TrySetResult();
|
||||
await runTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creating_a_worktree_broadcasts_WorktreeUpdated()
|
||||
{
|
||||
string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString();
|
||||
var repoDir = Path.Combine(_tempDir, "repo");
|
||||
Directory.CreateDirectory(repoDir);
|
||||
|
||||
// A real git repo — Worker.Tests run real git by design.
|
||||
await RunGitAsync(repoDir, "init");
|
||||
await RunGitAsync(repoDir, "config user.email t@t.t");
|
||||
await RunGitAsync(repoDir, "config user.name t");
|
||||
await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi");
|
||||
await RunGitAsync(repoDir, "add a.txt");
|
||||
await RunGitAsync(repoDir, "commit -m init");
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running,
|
||||
StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
|
||||
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
|
||||
var runner = BuildRunner(fake);
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue",
|
||||
CancellationToken.None, alreadyClaimed: true);
|
||||
|
||||
Assert.Contains(_hubContext.Proxy.Calls,
|
||||
c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId);
|
||||
}
|
||||
|
||||
private static async Task RunGitAsync(string dir, string args)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("git", args)
|
||||
{
|
||||
WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true,
|
||||
};
|
||||
using var p = System.Diagnostics.Process.Start(psi)!;
|
||||
await p.WaitForExitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Services;
|
||||
|
||||
// The queue picker's raw-SQL claim commits status='running' before the runner starts. If
|
||||
// anything then throws before the runner's own terminal-status write, the task used to stay
|
||||
// Running forever with the UI never notified (RunInSlotAsync's catch only logged the error).
|
||||
// It must now mark the task Failed for a real exception (which broadcasts TaskUpdated), but
|
||||
// must NOT do so for a cancellation — the cancel path already wrote the terminal status.
|
||||
//
|
||||
// These drive the real QueueService end to end (StartAsync + the waker), not just the
|
||||
// FailAsync contract, so they actually exercise the fixed catch block.
|
||||
public sealed class QueueServiceSlotFailureTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly string _tempDir;
|
||||
private readonly WorkerConfig _cfg;
|
||||
|
||||
public QueueServiceSlotFailureTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotfail_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
|
||||
LogRoot = Path.Combine(_tempDir, "logs"),
|
||||
QueueBackstopIntervalMs = 50, // fast for tests
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
try { Directory.Delete(_tempDir, true); } catch { }
|
||||
}
|
||||
|
||||
// Mirrors QueueServiceTests.CreateService but takes the picker as a parameter so each test
|
||||
// can engineer the exact failure path it needs to exercise.
|
||||
// Build() wires its own CapturingHubContext internally and hands it back as .Hub — the
|
||||
// broadcaster inside TaskStateService (and therefore FailAsync's TaskUpdated) uses that
|
||||
// exact instance, so everything else here must share it too rather than constructing a
|
||||
// second CapturingHubContext that would silently miss FailAsync's broadcast.
|
||||
private (QueueService service, CapturingHubContext hub, QueueWaker waker) CreateService(IQueuePicker picker)
|
||||
{
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var built = TaskStateServiceBuilder.Build(dbFactory);
|
||||
var broadcaster = new HubBroadcaster(built.Hub);
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||
NullLogger<TaskRunner>.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(),
|
||||
new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
||||
var waker = new QueueWaker();
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
|
||||
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, waker, picker,
|
||||
overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster);
|
||||
return (service, built.Hub, waker);
|
||||
}
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
using var ctx = _db.CreateContext();
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await ctx.SaveChangesAsync();
|
||||
return listId;
|
||||
}
|
||||
|
||||
// Directly rewrites the task's list_id via a raw connection with FK enforcement off,
|
||||
// reproducing "the list vanished between the queue claim and the run" without going
|
||||
// through EF's foreign-key-checked connections (which would reject the write).
|
||||
private void OrphanTaskListId(string taskId)
|
||||
{
|
||||
using var conn = new SqliteConnection($"Data Source={_db.DbPath}");
|
||||
conn.Open();
|
||||
using (var pragmaCmd = conn.CreateCommand())
|
||||
{
|
||||
pragmaCmd.CommandText = "PRAGMA foreign_keys=OFF;";
|
||||
pragmaCmd.ExecuteNonQuery();
|
||||
}
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE tasks SET list_id = 'orphaned-missing-list' WHERE id = $id;";
|
||||
cmd.Parameters.AddWithValue("$id", taskId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_slot_run_marks_the_task_Failed_and_broadcasts_TaskUpdated()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
|
||||
ReviewFeedback = "please fix", CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// A prior run with a session id routes RunInSlotAsync into TaskRunner.ContinueAsync
|
||||
// instead of RunAsync.
|
||||
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
|
||||
Prompt = "original", SessionId = "sess-1", StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||
});
|
||||
}
|
||||
|
||||
// ContinueAsync's setup block reads the list *before* its own try/catch starts
|
||||
// (TaskRunner.cs, ContinueAsync ~line 232-234) and throws InvalidOperationException
|
||||
// ("List not found.") straight past TaskRunner's own protection. That's the exact gap
|
||||
// QueueService.RunInSlotAsync's own catch now has to cover.
|
||||
OrphanTaskListId(taskId);
|
||||
|
||||
var (service, hub, waker) = CreateService(new QueuePicker(_db.CreateFactory()));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await service.StartAsync(cts.Token);
|
||||
waker.Wake();
|
||||
|
||||
// FailAsync (TaskStateService.cs:236-249) commits the DB status flip via
|
||||
// ExecuteUpdateAsync *before* it calls the broadcaster's TaskUpdated — so a poll that
|
||||
// breaks the instant it observes Status==Failed can race ahead of the broadcast still
|
||||
// landing in hub.Proxy.Calls. Wait for both signals together so the assertions below
|
||||
// never sample a genuinely-not-yet-broadcast window as a failure.
|
||||
TaskEntity? reloaded = null;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
using var verify = _db.CreateContext();
|
||||
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||
var broadcastSeen = hub.Proxy.Calls.Any(
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||
if (reloaded!.Status == TaskStatus.Failed && broadcastSeen) break;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
cts.Cancel();
|
||||
|
||||
Assert.Equal(TaskStatus.Failed, reloaded!.Status);
|
||||
Assert.Contains(hub.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||
}
|
||||
|
||||
// A fake IQueuePicker that performs the real atomic claim (so the DB row transitions
|
||||
// Queued->Running exactly like production) and then, synchronously before returning,
|
||||
// cancels the token QueueService's per-slot CTS is linked from. By the time
|
||||
// QueueService.ExecuteAsync creates that linked CTS and dispatches RunInSlotAsync, the
|
||||
// token is already cancelled — deterministic, no timing race required.
|
||||
private sealed class ClaimThenCancelPicker : IQueuePicker
|
||||
{
|
||||
private readonly IQueuePicker _inner;
|
||||
private readonly CancellationTokenSource _cancelAfterClaim;
|
||||
|
||||
public ClaimThenCancelPicker(IQueuePicker inner, CancellationTokenSource cancelAfterClaim)
|
||||
{
|
||||
_inner = inner;
|
||||
_cancelAfterClaim = cancelAfterClaim;
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
var claimed = await _inner.ClaimNextAsync(now, ct);
|
||||
if (claimed is not null) _cancelAfterClaim.Cancel();
|
||||
return claimed;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancelled_slot_run_does_not_mark_the_task_Failed()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var outerCts = new CancellationTokenSource();
|
||||
var realPicker = new QueuePicker(_db.CreateFactory());
|
||||
var picker = new ClaimThenCancelPicker(realPicker, outerCts);
|
||||
var (service, hub, waker) = CreateService(picker);
|
||||
|
||||
await service.StartAsync(outerCts.Token);
|
||||
waker.Wake();
|
||||
|
||||
// Wait for the slot to be claimed and then released again (RunInSlotAsync's
|
||||
// ContinueWith removes it once the catch block — ours or a stray one — finishes).
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (service.GetActive().Any(a => a.taskId == taskId) && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
await Task.Delay(100); // let the fire-and-forget continuation fully settle
|
||||
|
||||
TaskEntity? reloaded;
|
||||
using (var verify = _db.CreateContext())
|
||||
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||
|
||||
// The picker's atomic claim already flipped it to Running; the cancelled slot run must
|
||||
// leave it there rather than flipping it to Failed.
|
||||
Assert.Equal(TaskStatus.Running, reloaded!.Status);
|
||||
Assert.DoesNotContain(hub.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var repo = new AppSettingsRepository(ctx);
|
||||
var settings = await repo.GetAsync();
|
||||
settings.MaxParallelExecutions = maxParallel;
|
||||
settings.UsageThrottleSoftPct = softPct;
|
||||
settings.UsageThrottleHardPct = hardPct;
|
||||
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||
settings.UsageGateFiveHourPct = gateFive;
|
||||
settings.UsageGateSevenDayPct = gateSeven;
|
||||
await repo.UpdateAsync(settings);
|
||||
|
||||
@@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable
|
||||
Assert.Equal(1, row.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
|
||||
{
|
||||
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
|
||||
// record inside it, so it is skipped unread. Here the content would match the window —
|
||||
// proving the file was never opened, which is what keeps a 7-day range off the full history.
|
||||
var path = WriteSession("proj", "old.jsonl",
|
||||
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
|
||||
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
|
||||
|
||||
var reader = MakeReader();
|
||||
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
|
||||
{
|
||||
var path = WriteSession("proj", "edge.jsonl",
|
||||
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
|
||||
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
|
||||
|
||||
var reader = MakeReader();
|
||||
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
|
||||
|
||||
Assert.Single(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_Line_Does_Not_Abort_The_Run()
|
||||
{
|
||||
|
||||
@@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
|
||||
var repo = new AppSettingsRepository(ctx);
|
||||
var settings = await repo.GetAsync();
|
||||
settings.MaxParallelExecutions = maxParallel;
|
||||
settings.UsageThrottleSoftPct = softPct;
|
||||
settings.UsageThrottleHardPct = hardPct;
|
||||
settings.UsageThrottleFiveHourSoftPct = softPct;
|
||||
settings.UsageThrottleFiveHourHardPct = hardPct;
|
||||
settings.UsageThrottleSevenDaySoftPct = softPct;
|
||||
settings.UsageThrottleSevenDayHardPct = hardPct;
|
||||
await repo.UpdateAsync(settings);
|
||||
}
|
||||
|
||||
private async Task SetPerBucketThrottleAsync(
|
||||
int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
var repo = new AppSettingsRepository(ctx);
|
||||
var settings = await repo.GetAsync();
|
||||
settings.MaxParallelExecutions = maxParallel;
|
||||
settings.UsageThrottleFiveHourSoftPct = fiveSoft;
|
||||
settings.UsageThrottleFiveHourHardPct = fiveHard;
|
||||
settings.UsageThrottleSevenDaySoftPct = sevenSoft;
|
||||
settings.UsageThrottleSevenDayHardPct = sevenHard;
|
||||
await repo.UpdateAsync(settings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges()
|
||||
{
|
||||
await SetThresholdsAsync(80, 90);
|
||||
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85);
|
||||
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(new UsageSnapshot(
|
||||
new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||
|
||||
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||
|
||||
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
|
||||
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
|
||||
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
|
||||
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
|
||||
{
|
||||
await SetThresholdsAsync(80, 90);
|
||||
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
|
||||
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
|
||||
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(new UsageSnapshot(
|
||||
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
||||
|
||||
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
|
||||
|
||||
Assert.Equal(2, dto.EffectiveSlots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throttled_slots_and_decisive_bucket_reported()
|
||||
{
|
||||
|
||||
@@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage;
|
||||
|
||||
public sealed class UsageThrottleTests
|
||||
{
|
||||
private const int Soft = 50;
|
||||
private const int Hard = 65;
|
||||
private const int GateFive = 80;
|
||||
private const int GateSeven = 90;
|
||||
private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80);
|
||||
private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90);
|
||||
|
||||
private static int Effective(double? five, double? seven, int configured = 3) =>
|
||||
UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven);
|
||||
UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay);
|
||||
|
||||
[Fact]
|
||||
public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
|
||||
@@ -94,13 +92,19 @@ public sealed class UsageThrottleTests
|
||||
[Fact]
|
||||
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
|
||||
{
|
||||
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven));
|
||||
var five = new UsageThresholds(0, 0, 80);
|
||||
var seven = new UsageThresholds(0, 0, 90);
|
||||
|
||||
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroGateThresholds_NeverHardBlock()
|
||||
{
|
||||
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0));
|
||||
var five = new UsageThresholds(50, 65, 0);
|
||||
var seven = new UsageThresholds(50, 65, 0);
|
||||
|
||||
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,4 +112,46 @@ public sealed class UsageThrottleTests
|
||||
{
|
||||
Assert.Equal(1, Effective(10, 10, configured: 0));
|
||||
}
|
||||
|
||||
// ── Per-bucket thresholds are independent ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PerBucket_SameUtilization_DifferentStagesPerBucket()
|
||||
{
|
||||
// 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides.
|
||||
var five = new UsageThresholds(50, 65, 80);
|
||||
var seven = new UsageThresholds(70, 85, 90);
|
||||
|
||||
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne()
|
||||
{
|
||||
// 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles.
|
||||
var five = new UsageThresholds(90, 95, 99);
|
||||
var seven = new UsageThresholds(20, 35, 90);
|
||||
|
||||
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBucket_StrictestStageWins()
|
||||
{
|
||||
// 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins.
|
||||
var five = new UsageThresholds(50, 65, 80);
|
||||
var seven = new UsageThresholds(30, 40, 90);
|
||||
|
||||
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerBucket_MissingBucketNeverThrottles()
|
||||
{
|
||||
// No 7d reading at all: only the 5h bucket may step parallelism down.
|
||||
var five = new UsageThresholds(50, 65, 80);
|
||||
var seven = new UsageThresholds(1, 2, 3);
|
||||
|
||||
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user