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.
213 lines
8.8 KiB
C#
213 lines
8.8 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.External;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.State;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Worker.Config;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.External;
|
|
|
|
public sealed class LifecycleMcpToolsTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly List<GitRepoFixture> _repos = new();
|
|
|
|
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
|
|
|
public LifecycleMcpToolsTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var r in _repos) r.Dispose();
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private LifecycleMcpTools BuildSut()
|
|
{
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
|
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
|
};
|
|
var git = new GitService();
|
|
var dbFactory = _db.CreateFactory();
|
|
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
|
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
|
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
|
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
|
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
|
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
|
var planningMerge = new PlanningMergeOrchestrator(
|
|
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
|
var svc = new ExternalMcpService(
|
|
_tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster,
|
|
state, git, dbFactory, maintenance, merge, planningMerge,
|
|
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
|
var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager);
|
|
return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission);
|
|
}
|
|
|
|
private QueueService CreateQueue(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory, WorktreeManager wtManager, ITaskStateService state, HubBroadcaster broadcaster)
|
|
{
|
|
var cfg = new WorkerConfig
|
|
{
|
|
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cdq_{Guid.NewGuid():N}"),
|
|
LogRoot = Path.Combine(Path.GetTempPath(), $"cdql_{Guid.NewGuid():N}"),
|
|
QueueBackstopIntervalMs = 50,
|
|
};
|
|
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
|
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
|
var usageState = new UsageState();
|
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
|
|
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels,
|
|
new FakeUsageGate(), usageState, broadcaster,
|
|
new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger<UsageLimitAutoContinueCoordinator>.Instance));
|
|
}
|
|
|
|
private async Task<TaskEntity> SeedTaskAsync(
|
|
TaskStatus status, string? workingDir = null, string? handlerBaseCommit = null)
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow });
|
|
var task = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
|
Status = status, HandlerBaseCommit = handlerBaseCommit,
|
|
CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(task);
|
|
return task;
|
|
}
|
|
|
|
private GitRepoFixture CreateRepo()
|
|
{
|
|
var f = new GitRepoFixture();
|
|
_repos.Add(f);
|
|
return f;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
|
|
{
|
|
var task = await SeedTaskAsync(TaskStatus.Failed);
|
|
var sut = BuildSut();
|
|
|
|
var result = await sut.ResetFailedTask(task.Id, CancellationToken.None);
|
|
|
|
Assert.True(result.Reset);
|
|
Assert.Equal(task.Id, result.TaskId);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetFailedTask_OnNonFailed_Throws()
|
|
{
|
|
var task = await SeedTaskAsync(TaskStatus.Done);
|
|
var sut = BuildSut();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetFailedTask_NotFound_Throws()
|
|
{
|
|
var sut = BuildSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.ResetFailedTask("missing", CancellationToken.None));
|
|
}
|
|
|
|
// ── SubmitTaskForReview ──
|
|
|
|
[Fact]
|
|
public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview()
|
|
{
|
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
|
|
|
var repo = CreateRepo();
|
|
var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: repo.RepoDir, handlerBaseCommit: repo.BaseCommit);
|
|
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
|
var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
var sut = BuildSut();
|
|
|
|
var dto = await sut.SubmitTaskForReview(task.Id, CancellationToken.None);
|
|
|
|
Assert.Equal("WaitingForReview", dto.Status);
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
|
Assert.Equal(expectedHead, reloaded.HandlerHeadCommit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws()
|
|
{
|
|
var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: Path.GetTempPath());
|
|
var sut = BuildSut();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitTaskForReview_RunningTask_Throws()
|
|
{
|
|
var task = await SeedTaskAsync(TaskStatus.Running, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123");
|
|
var sut = BuildSut();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitTaskForReview_AlreadyDone_Throws_HandlerHeadCommitUntouched()
|
|
{
|
|
var task = await SeedTaskAsync(TaskStatus.Done, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123");
|
|
var sut = BuildSut();
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
|
Assert.Contains("Idle or Failed", ex.Message);
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Null(reloaded!.HandlerHeadCommit);
|
|
Assert.Equal(TaskStatus.Done, reloaded.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitTaskForReview_NotFound_Throws()
|
|
{
|
|
var sut = BuildSut();
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
sut.SubmitTaskForReview("missing", CancellationToken.None));
|
|
}
|
|
}
|