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>
384 lines
16 KiB
C#
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);
|
|
}
|
|
}
|