Klassifiziert einen echten Usage-Limit-Abbruch als eigene FailureReason
"usage_limit" (TaskRunner.ClassifyFailureReason: nur bei terminal_reason
"api_error" plus einem Limit-Muster im gerenderten Fehlertext, nicht an
Status==Failed allein). Neuer Toggle AutoContinueOnUsageLimit (app_settings,
Default aus) unter Settings → General → "Usage limit stop":
- UsageLimitAutoContinueCoordinator feuert pro Task genau einmal ContinueTask
über OverrideSlotService, sobald das 5h-Fenster (UsageState.Snapshot.FiveHour
.ResetsAt) tatsächlich zurückgesetzt ist; ein persistenter Marker
(TaskEntity.UsageLimitAutoContinuedAt) verhindert einen zweiten Anlauf bei
einem erneuten Limit-Treffer.
- QueueService schedult zusätzlich einen exakten Wake-Timer auf den
Reset-Zeitpunkt, statt nur auf den 30s-Backstop zu warten.
- Fail-open durchgängig: kein Snapshot/keine Reset-Zeit → kein Timer, kein
Continue, kein Throw. Toggle aus ändert das heutige Verhalten nicht.
Migration AddUsageLimitAutoContinue fügt beide Spalten hinzu; die von
`dotnet ef migrations add` mitgescaffoldete leere UpdateData auf app_settings
(columns/values: []) erzeugte ungültiges SQL ("near WHERE") und wurde entfernt
— TaskNumberMigrationTests deckte das über den vollen Migrate()-Pfad auf.
224 lines
8.9 KiB
C#
224 lines
8.9 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Usage;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class QueueStateMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _taskRepo;
|
|
private readonly ListRepository _listRepo;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly string _tempDir;
|
|
private int _numberSeed;
|
|
|
|
public QueueStateMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_taskRepo = new TaskRepository(_ctx);
|
|
_listRepo = new ListRepository(_ctx);
|
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_tempDir);
|
|
_cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
|
|
LogRoot = Path.Combine(_tempDir, "logs"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
try { Directory.Delete(_tempDir, true); } catch { }
|
|
}
|
|
|
|
private (QueueService queue, QueueStateMcpTools sut) CreateSut(
|
|
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null,
|
|
UsageState? usageState = null)
|
|
{
|
|
var dbFactory = _db.CreateFactory();
|
|
var fake = new FakeClaudeProcess(handler);
|
|
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
|
var built = TaskStateServiceBuilder.Build(dbFactory);
|
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), _cfg,
|
|
NullLogger<TaskRunner>.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(),
|
|
new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var picker = new QueuePicker(dbFactory);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
|
|
var resolvedUsageState = usageState ?? new UsageState();
|
|
var queue = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, new QueueWaker(), picker,
|
|
overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), resolvedUsageState, broadcaster,
|
|
new UsageLimitAutoContinueCoordinator(dbFactory, resolvedUsageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
|
return (queue, new QueueStateMcpTools(queue, dbFactory));
|
|
}
|
|
|
|
private async Task<string> SeedListAsync()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
|
return listId;
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(
|
|
string listId, TaskStatus status, int sortOrder = 0, DateTime? createdAt = null,
|
|
bool isManual = false, string? blockedByTaskId = null, DateTime? scheduledFor = null)
|
|
{
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "Test task",
|
|
Status = status,
|
|
SortOrder = sortOrder,
|
|
CreatedAt = createdAt ?? DateTime.UtcNow,
|
|
IsManual = isManual,
|
|
BlockedByTaskId = blockedByTaskId,
|
|
ScheduledFor = scheduledFor,
|
|
Number = ++_numberSeed,
|
|
};
|
|
// Bypass TaskRepository.AddAsync, which overwrites SortOrder with max(listId)+1 --
|
|
// these tests need to control SortOrder directly to exercise queue pick order.
|
|
_ctx.Tasks.Add(task);
|
|
await _ctx.SaveChangesAsync();
|
|
return task;
|
|
}
|
|
|
|
private async Task SetMaxParallelAsync(int maxParallel, int softPct = 0, int hardPct = 0)
|
|
{
|
|
using var ctx = _db.CreateContext();
|
|
var repo = new AppSettingsRepository(ctx);
|
|
var settings = await repo.GetAsync();
|
|
settings.MaxParallelExecutions = maxParallel;
|
|
settings.UsageThrottleFiveHourSoftPct = softPct;
|
|
settings.UsageThrottleFiveHourHardPct = hardPct;
|
|
settings.UsageThrottleSevenDaySoftPct = softPct;
|
|
settings.UsageThrottleSevenDayHardPct = hardPct;
|
|
await repo.UpdateAsync(settings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_NoThrottle_EffectiveEqualsConfigured()
|
|
{
|
|
await SetMaxParallelAsync(maxParallel: 3);
|
|
var (_, sut) = CreateSut();
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
Assert.Equal(3, result.ConfiguredSlots);
|
|
Assert.Equal(3, result.EffectiveSlots);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_UsageThrottleActive_EffectiveBelowConfigured()
|
|
{
|
|
await SetMaxParallelAsync(maxParallel: 3, softPct: 50, hardPct: 65);
|
|
|
|
var usageState = new UsageState();
|
|
usageState.ReportSuccess(new UsageSnapshot(
|
|
new UsageBucket(60, null), new UsageBucket(0, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
|
|
|
|
var (_, sut) = CreateSut(usageState: usageState);
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
Assert.Equal(3, result.ConfiguredSlots);
|
|
Assert.Equal(2, result.EffectiveSlots);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_ActiveSlots_ReflectsOverrideSlot()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var tcs = new TaskCompletionSource<RunResult>();
|
|
var (queue, sut) = CreateSut((_, _, _, _, _) => tcs.Task);
|
|
|
|
var task = await SeedTaskAsync(listId, TaskStatus.Queued);
|
|
await queue.RunNow(task.Id);
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
var slot = Assert.Single(result.ActiveSlots);
|
|
Assert.Equal("override", slot.Slot);
|
|
Assert.Equal(task.Id, slot.TaskId);
|
|
|
|
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_WaitingTaskIds_OrderedBySortOrderThenCreatedAt_AndFiltersIneligible()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var (_, sut) = CreateSut();
|
|
|
|
var second = await SeedTaskAsync(listId, TaskStatus.Queued, sortOrder: 1, createdAt: DateTime.UtcNow);
|
|
var first = await SeedTaskAsync(listId, TaskStatus.Queued, sortOrder: 0, createdAt: DateTime.UtcNow.AddMinutes(1));
|
|
await SeedTaskAsync(listId, TaskStatus.Queued, sortOrder: 2, isManual: true);
|
|
await SeedTaskAsync(listId, TaskStatus.Queued, sortOrder: 3, blockedByTaskId: first.Id);
|
|
await SeedTaskAsync(listId, TaskStatus.Queued, sortOrder: 4, scheduledFor: DateTime.UtcNow.AddHours(1));
|
|
await SeedTaskAsync(listId, TaskStatus.Running, sortOrder: -1);
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
Assert.Equal(new[] { first.Id, second.Id }, result.WaitingTaskIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_ScopeBlockedTasks_Empty_WhenNoListSerializes()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
var (_, sut) = CreateSut();
|
|
|
|
var running = await SeedTaskAsync(listId, TaskStatus.Running);
|
|
running.ScopeGlobs = "src/Foo.cs";
|
|
await _ctx.SaveChangesAsync();
|
|
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
|
|
queued.ScopeGlobs = "src/Foo.cs";
|
|
await _ctx.SaveChangesAsync();
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
Assert.Contains(queued.Id, result.WaitingTaskIds);
|
|
Assert.Empty(result.ScopeBlockedTasks);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetQueueState_ScopeBlockedTasks_ReportsReasonAndBlocker_WhenListSerializes()
|
|
{
|
|
var listId = await SeedListAsync();
|
|
await _listRepo.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = true });
|
|
|
|
var (_, sut) = CreateSut();
|
|
|
|
var running = await SeedTaskAsync(listId, TaskStatus.Running);
|
|
running.ScopeGlobs = "src/Foo.cs";
|
|
await _ctx.SaveChangesAsync();
|
|
|
|
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
|
|
queued.ScopeGlobs = "src/Foo.cs";
|
|
await _ctx.SaveChangesAsync();
|
|
|
|
var result = await sut.GetQueueState(CancellationToken.None);
|
|
|
|
Assert.Contains(queued.Id, result.WaitingTaskIds);
|
|
var reason = Assert.Single(result.ScopeBlockedTasks);
|
|
Assert.Equal(queued.Id, reason.TaskId);
|
|
Assert.Equal(running.Id, reason.BlockedByTaskId);
|
|
Assert.Equal("scope_overlap", reason.Reason);
|
|
}
|
|
}
|