Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Budget/FillWorkTests.cs
T
RichardandClaude Opus 5.5 183e922278 Fuellarbeit: zeitlich flexible Arbeit fuellt ungenutztes Budget (#717, Stufe 1)
Eine einmalige Aufgabe mit fill_by (spaetestens bis) statt Termin laeuft,
wenn der Tag Budget uebrig laesst: nach regulaerer und nachgeholter Arbeit,
nur wenn nichts vom Agenten wartet, bevorzugt ab 18 Uhr, und nur wenn ihre
eigene Schaetzung in den Spielraum aus #716 passt. Kurz vor der Frist wird
einmal ein Mensch gefragt; nach der Frist laeuft sie als regulaere Arbeit.

Ehrliche Schaetzung (TaskCostEstimator): eigene Laeufe, dann Geschwister
unter derselben Elternaufgabe, dann die Angabe des Autors (als Schaetzung
gekennzeichnet), zuletzt das Mittel des Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:34:15 +02:00

384 lines
16 KiB
C#

using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #717: Fill work — large, not urgent — runs on budget the day would otherwise leave unused,
/// and never overruns its deadline silently. Whether there is room is decided by the code.
/// </summary>
public sealed class FillWorkTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly InMemoryAuditRepository _audit = new();
// 2027-03-10 14:00 UTC; the clocks' local zone is UTC.
private static readonly DateTime Afternoon = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Evening = new(2027, 3, 10, 20, 0, 0, DateTimeKind.Utc);
public FillWorkTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// <summary>The agent's runs average 0.05 USD over 1,000 tokens.</summary>
private static BudgetGuard Guard(decimal used = 0m)
=> new(new FakeUsage(used, [.. Enumerable.Range(0, 5).Select(_ => new RunUsage
{
AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.05m, CostIsKnown = true
})]));
private static TaskItem Fill(string id, DateTime deadline, decimal? estimate = null, string parent = "") => new()
{
Id = id, Title = id, Assignee = "@worker", FillBy = deadline, EstimateUsd = estimate, ParentId = parent,
CreatedAt = Afternoon.AddDays(-1)
};
private void Receipt(string taskId, decimal cost, int tokens = 2_000)
=> _audit.Receipts.Enqueue(new RunReceipt
{
RunId = Guid.NewGuid().ToString("N"), AgentId = "worker", TaskId = taskId,
PromptTokens = tokens, CostUsd = cost, OccurredAt = Afternoon
});
// ═══════════════════ estimating, honestly ═══════════════════
[Fact]
public async Task Measured_runs_of_the_task_beat_everything_else()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 5m), default);
Receipt("t-1", 0.40m);
Receipt("t-1", 0.60m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Measured);
estimate.Run.CostUsd.ShouldBe(0.50m);
estimate.Samples.ShouldBe(2);
estimate.IsGuess.ShouldBeFalse();
}
[Fact]
public async Task A_refusal_without_usage_is_not_a_measurement()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 2m), default);
Receipt("t-1", 0m, tokens: 0);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated, "a budget refusal costs nothing and says nothing");
}
[Fact]
public async Task After_the_first_part_ran_its_siblings_are_estimated_from_it()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-big", Title = "big", CreatedAt = Afternoon }, default);
await _repo.UpsertAsync(Fill("t-part-1", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
var second = await _repo.UpsertAsync(Fill("t-part-2", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
Receipt("t-part-1", 0.80m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(second, default);
estimate.Source.ShouldBe(EstimateSource.Siblings);
estimate.Run.CostUsd.ShouldBe(0.80m, "the measured part replaces the author's guess of 3 USD");
}
[Fact]
public async Task A_stated_estimate_is_a_guess_and_gets_tokens_at_the_agents_price()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 0.50m), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated);
estimate.IsGuess.ShouldBeTrue();
estimate.Run.Tokens.ShouldBe(10_000, "0.50 USD at 0.05 USD per 1,000 tokens");
FillWorkNotice.Describe(estimate).ShouldContain("estimated by its author, not measured");
}
[Fact]
public async Task Without_anything_the_agents_average_is_used_and_called_a_guess()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3)), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.AgentAverage);
estimate.Run.CostUsd.ShouldBe(0.05m);
estimate.IsGuess.ShouldBeTrue();
}
// ═══════════════════ is there room? ═══════════════════
private BudgetHeadroom Headroom(DateTime now, decimal used, AgentConfig? agent = null, FillWorkSettings? settings = null)
{
var guard = Guard(used);
return new BudgetHeadroom(guard, () => [agent ?? Agent()], () => InstanceBudget.Unlimited, new UtcClock(now),
new TaskCostEstimator(_audit, _repo, guard), () => settings ?? new FillWorkSettings());
}
[Fact]
public async Task In_the_afternoon_fill_work_waits_for_the_evening()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.NotYet, "at 14:00 'there is room' is a guess");
}
[Fact]
public async Task In_the_evening_it_runs_when_its_estimate_fits()
{
// Limit 1.00, used 0.50, margin 0.10: 0.40 left for an estimate of 0.30.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task It_is_measured_with_its_own_estimate_not_the_agents_average()
{
// 0.40 left — enough for the agent's average run of 0.05, not for this task's 0.60.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.60m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task The_rest_of_the_days_schedule_comes_first()
{
// Four hourly runs à 0.05 until midnight (20:00 … 23:00) = 0.20: 1.00 - 0.50 - 0.20 - 0.10 = 0.20 < 0.30.
var hourly = new TaskItem
{
Id = "t-hourly", Title = "hourly", Assignee = "@worker", CreatedAt = Afternoon.AddDays(-1),
LastOccurrence = "2027-03-10T19:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" }
};
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [hourly, fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task Close_to_its_deadline_it_no_longer_waits_for_the_evening()
{
var fill = Fill("t-fill", Afternoon.AddHours(10), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task Without_a_limit_there_is_nothing_to_wait_for()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 50m);
(await Headroom(Afternoon, used: 0m, agent: Agent(dailyCost: 0m)).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.Run);
}
// ═══════════════════ the scanner ═══════════════════
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(Evening, TimeSpan.Zero));
private TaskScanner Scanner(RecordingDispatcher dispatcher, IFillGate gate, List<TaskItem>? asked = null, int maxParallel = 4)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, maxParallelRuns: maxParallel,
fillGate: gate,
askAboutFill: (task, _) => { asked?.Add(task); return Task.CompletedTask; },
fillWarnBefore: () => TimeSpan.FromHours(24));
[Fact]
public async Task Fill_work_starts_only_when_the_gate_sees_room()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var dispatcher = new RecordingDispatcher();
var gate = new FixedGate(FillVerdict.NoRoom);
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(0);
gate.Verdict = FillVerdict.Run;
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"]);
}
[Fact]
public async Task Fill_work_waits_while_other_work_of_its_agent_waits()
{
// A carried-over task of the same agent waits for headroom. The fill task would fit —
// but late work was due and fill work never was, so it does not overtake it.
await _repo.UpsertAsync(new TaskItem { Id = "t-carried", Title = "carried", Assignee = "@worker" }, default);
await _repo.TryClaimAsync("t-carried", "occ-1", "token", Evening, Evening.AddMinutes(-15), default);
await _repo.CarryOverAsync("t-carried", "token", null, Evening.AddMinutes(-1), Evening, default);
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
await _repo.UpsertAsync(Fill("t-fill-other", Evening.AddDays(3)) with { Assignee = "@other" }, default);
var dispatcher = new RecordingDispatcher();
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock,
lateRunGate: new NoRoomForLateWork(), fillGate: new FixedGate(FillVerdict.Run));
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-fill-other"], "another agent's fill work is not held back");
}
[Fact]
public async Task Regular_work_goes_first_and_the_nearest_deadline_leads_the_fill_work()
{
await _repo.UpsertAsync(Fill("t-fill-late", Evening.AddDays(5)), default);
await _repo.UpsertAsync(Fill("t-fill-soon", Evening.AddDays(2)), default);
await _repo.UpsertAsync(new TaskItem { Id = "t-regular", Title = "regular", Assignee = "@worker", Priority = 1 }, default);
var dispatcher = new RecordingDispatcher();
await Scanner(dispatcher, new FixedGate(FillVerdict.Run)).ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-regular", "t-fill-soon", "t-fill-late"]);
}
[Fact]
public async Task A_human_is_asked_once_when_the_deadline_comes_close()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(20)), default);
var asked = new List<TaskItem>();
var scanner = Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked);
await scanner.ScanOnceAsync(default);
await scanner.ScanOnceAsync(default);
asked.Select(t => t.Id).ShouldBe(["t-fill"], "asked once, not every minute");
(await _repo.GetAsync("t-fill", default))!.FillAlertedAt.ShouldNotBeNull();
}
[Fact]
public async Task Far_from_the_deadline_nobody_is_bothered()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var asked = new List<TaskItem>();
await Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked).ScanOnceAsync(default);
asked.ShouldBeEmpty();
}
[Fact]
public async Task Past_its_deadline_fill_work_runs_as_regular_work()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(2)), default);
_clock.SetUtcNow(new DateTimeOffset(Evening.AddHours(3), TimeSpan.Zero));
var dispatcher = new RecordingDispatcher();
(await Scanner(dispatcher, new FixedGate(FillVerdict.NoRoom)).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"], "the deadline was the promise");
}
[Fact]
public void The_question_to_the_human_hangs_below_the_fill_work()
{
var question = FillWorkNotice.QuestionFor(Fill("t-fill", Evening.AddDays(1)),
new TaskEstimate(new RunEstimate(0.80m, 0), EstimateSource.Measured, 3));
question.Assignee.ShouldBe(TaskAssignee.Human);
question.ParentId.ShouldBe("t-fill");
question.Body.ShouldContain("0.80 USD (measured over 3 run(s))");
question.Body.ShouldContain("raise the agent's daily budget");
}
// ═══════════════════ the definition ═══════════════════
[Fact]
public void Deadline_and_estimate_survive_the_file()
{
var task = Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 1.25m);
TaskFrontmatter.TryParse(TaskFrontmatter.Serialize(task), out var read, out _).ShouldBeTrue();
read.FillBy.ShouldBe(task.FillBy);
read.EstimateUsd.ShouldBe(1.25m);
read.IsFillWork.ShouldBeTrue();
}
[Fact]
public void A_bare_date_means_the_end_of_that_day()
=> TaskFrontmatter.ParseUtc("2027-03-12").ShouldBe(new DateTime(2027, 3, 13, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Deadline_and_estimate_survive_the_database()
{
await _repo.UpsertAsync(Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 0.75m), default);
var read = (await _repo.GetAsync("t-fill", default))!;
read.FillBy.ShouldBe(new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc));
read.EstimateUsd.ShouldBe(0.75m);
}
[Fact]
public void A_recurring_task_is_never_fill_work()
=> (Fill("t", Evening) with { When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "1h", TimeZone = "UTC" } })
.IsFillWork.ShouldBeFalse();
// ═══════════════════ test doubles ═══════════════════
private sealed class FixedGate(FillVerdict verdict) : IFillGate
{
public FillVerdict Verdict { get; set; } = verdict;
public Task<FillVerdict> CheckAsync(TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(Verdict);
}
private sealed class NoRoomForLateWork : ILateRunGate
{
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(false);
}
private sealed class RecordingDispatcher : ITaskDispatcher
{
public List<string> Started { get; } = [];
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (Started)
Started.Add(task.Id);
return Task.FromResult(TaskLauf.Erledigt);
}
}
private sealed class UtcClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(now, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
public Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}