Merge branch 'main' into worktree-diff-side-by-side

# Conflicts:
#	src/ClaudeDo.Ui/CLAUDE.md
This commit is contained in:
mika kuns
2026-08-07 11:16:49 +02:00
64 changed files with 4270 additions and 493 deletions
@@ -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));
}
}