Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)

- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem
  Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs.
- BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf
  startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus
  Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht
  minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens.
  Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich
  mitgenommene Aufgaben gegenseitig.
- Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es
  warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und
  Engine dieselben Reservierungen sehen.
- Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein
  fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig.

Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt):
Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und
task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger
Prioritaet unsichtbar wurden.

Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500
Aufgaben (1). 1.257 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:10:40 +02:00
co-authored by Claude Opus 5.5
parent 8a83a67fb4
commit 9cf9a2cfc2
15 changed files with 524 additions and 11 deletions
+8 -3
View File
@@ -954,9 +954,14 @@ scheitert:
| `carry_over` | wird mitgenommen | einmalige Aufgaben: echte Arbeit, die nicht verschwinden darf |
Dazu gilt: **Der neue Tag hat Vorfahrt.** Nachholläufe kommen nach den regulär fälligen,
unterhalb jeder Priorität, und dürfen höchstens einen Anteil des neuen Tagesbudgets
verbrauchen (Vorgabe etwa 25 %). Ohne diese Regel schiebt ein einziger überzogener Tag
seinen Ausfall Tag für Tag vor sich her, statt ihn aufzulösen. Eine einmalige Aufgabe, die
unterhalb jeder Priorität, und sie nutzen nur den **Spielraum**, den der Rest des Tages
nicht braucht: Grenze minus Verbrauch, minus die Reservierung laufender Läufe, minus der
Bedarf der noch geplanten Läufe bis Mitternacht, minus 10 % Reserve. Ohne diese Regel
schiebt ein einziger überzogener Tag seinen Ausfall Tag für Tag vor sich her, statt ihn
aufzulösen. *Beim Bau am 2026-09-24 geändert:* Das Konzept sah hier zunächst einen
pauschalen Anteil von 25 % vor. Ein fester Anteil ist an einem vollen Tag zu viel und an
einem ruhigen unnötig wenig; der Spielraum folgt dem tatsächlichen Plan, und dieselbe
Rechnung trägt später die Füllarbeit. Eine einmalige Aufgabe, die
nach einigen Tagen immer noch nicht drankam, geht an den Menschen: Was nie läuft, darf
nicht unbemerkt liegen bleiben.
+6 -1
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Logging;
@@ -734,7 +735,11 @@ public sealed class AppHost : IAsyncDisposable
return instance.Agents
.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
?.Budget.MissPolicy ?? BudgetMissPolicy.Expire;
});
},
// #716: Late work waits for headroom the rest of the day does not need.
lateRunGate: engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
: null);
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
@@ -187,7 +187,8 @@ public sealed class TaskAdministration(
if (board is null)
return null;
var aufgaben = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
// #716: ungekappt - vorher fehlten ab 500 Aufgaben die übrigen in der Prognose.
var aufgaben = await board.ListAllAsync(ct);
var belege = new Dictionary<string, IReadOnlyList<RunReceipt>>(StringComparer.Ordinal);
if (audit is not null)
@@ -117,6 +117,10 @@ public sealed class BudgetGuard
}
}
/// <summary>Today's usage of an agent, or with an empty id of the whole instance.</summary>
public Task<DailyUsage> GetUsedTodayAsync(string agentId, CancellationToken ct)
=> _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.</summary>
public RunEstimate Reserved(string agentId)
@@ -0,0 +1,144 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now,
/// without taking budget the rest of the day still needs.
///
/// <para><b>The rule: the new day has right of way.</b> Headroom is the limit minus what
/// was used, minus what runs in flight have reserved, minus what the day's remaining
/// scheduled runs will need, minus a safety margin. A late run starts only if its
/// estimate fits into that. Otherwise one overdrawn day would push its shortfall into the
/// next, and the next, instead of resolving it.</para>
///
/// <para>This replaces the flat "at most 25 % of the new day" from the concept: a fixed
/// share is either too much on a busy day or needlessly little on a quiet one. The same
/// calculation is what fill work (#717) will run on.</para>
///
/// <para><b>Estimates, honestly.</b> A run is estimated from the agent's recent runs
/// (<see cref="BudgetGuard.EstimateRunAsync"/>). Without history the estimate is zero —
/// then only the margin keeps a reserve. That errs towards letting late work through;
/// the budget check at the start of the run still refuses once the limit is reached.</para>
/// </summary>
public sealed class BudgetHeadroom : ILateRunGate
{
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
public const decimal SafetyShare = 0.10m;
private readonly BudgetGuard _guard;
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
private readonly Func<InstanceBudget> _instanceBudget;
private readonly TimeProvider _clock;
public BudgetHeadroom(
BudgetGuard guard,
Func<IReadOnlyList<AgentConfig>> agents,
Func<InstanceBudget> instanceBudget,
TimeProvider? clock = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
}
public async Task<bool> HasRoomAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var agent = FindAgent(agents, lateTask);
var instanceBudget = _instanceBudget();
// Without a known agent there is nothing to calculate against; the budget check at
// the start of the run still applies.
if (agent is null || (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit))
return true;
var endOfDay = StartOfNextLocalDay(nowUtc);
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
async Task<RunEstimate> EstimateFor(string agentId)
{
if (!estimates.TryGetValue(agentId, out var estimate))
estimates[agentId] = estimate = await _guard.EstimateRunAsync(agentId, ct);
return estimate;
}
var ownEstimate = await EstimateFor(agent.AgentId);
// Regular work of the rest of the day: everything scheduled except late work
// itself — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
if (agent.Budget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
var reserved = _guard.Reserved(agent.AgentId);
if (!Fits(agent.Budget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|| !Fits(agent.Budget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
return false;
}
if (instanceBudget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
if (agentId.Length == 0)
continue;
demand += Times(await EstimateFor(agentId), TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
}
var used = await _guard.GetUsedTodayAsync("", ct);
var reserved = _guard.Reserved("");
if (!Fits(instanceBudget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|| !Fits(instanceBudget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
return false;
}
return true;
}
/// <summary>Carried-over work and pending catch-ups are the late runs.</summary>
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
/// <summary>0 or less means: no limit of this kind.</summary>
private static bool Fits(decimal limit, decimal used, decimal reserved, decimal demand, decimal estimate)
=> limit <= 0 || limit - used - reserved - demand - limit * SafetyShare >= estimate;
private static bool Fits(long limit, long used, long reserved, long demand, long estimate)
=> limit <= 0 || limit - used - reserved - demand - (long)(limit * SafetyShare) >= estimate;
private static RunEstimate Times(RunEstimate estimate, int runs)
=> new(estimate.CostUsd * runs, estimate.Tokens * runs);
private static bool IsOf(TaskItem task, string agentId)
=> string.Equals(TaskAssignee.AgentId(task.Assignee), agentId, StringComparison.OrdinalIgnoreCase);
private static AgentConfig? FindAgent(IReadOnlyList<AgentConfig> agents, TaskItem task)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
return agentId.Length == 0
? (agents.Count == 1 ? agents[0] : null)
: agents.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
}
private DateTime StartOfNextLocalDay(DateTime nowUtc)
{
var zone = _clock.LocalTimeZone;
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), zone);
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
while (zone.IsInvalidTime(midnight))
midnight = midnight.AddMinutes(1);
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
}
}
@@ -35,6 +35,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
/// <summary>
/// #716: The guard that checks and reserves every run — the scanner's headroom
/// calculation has to see the same reservations. <c>null</c> without usage recording.
/// </summary>
public BudgetGuard? BudgetGuard => _budgetGuard;
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -29,6 +29,13 @@ public interface ITaskRepository
/// </summary>
Task<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct);
/// <summary>
/// Every task, without a cap — for the few callers that must see the whole board: the
/// cost forecast, duplicate detection, the import's orphan sweep. Before #716 they used
/// <see cref="ListAsync"/> with its limit of 500 and silently saw only part of it.
/// </summary>
Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct);
/// <summary>
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
@@ -270,6 +270,22 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM Tasks"
+ (includeArchived ? "" : " WHERE Status <> 'archived'")
+ " ORDER BY Priority DESC, UpdatedAt DESC";
var results = new List<TaskItem>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public async Task<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
+37 -2
View File
@@ -44,6 +44,15 @@ public interface IMarketCalendar
bool IsOpen(DateTime nowUtc);
}
/// <summary>
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now.
/// Without one, late runs only wait for the regular ones of the same tick.
/// </summary>
public interface ILateRunGate
{
Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
{
@@ -110,6 +119,11 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly Dictionary<string, (int Misses, int CarriedOver, int CatchUps)> _missesToday = new(StringComparer.OrdinalIgnoreCase);
private DateOnly? _missDay;
private readonly ILateRunGate? _lateRunGate;
/// <summary>How many late runs waited for budget headroom at the last tick.</summary>
private int _lastWaitingForHeadroom;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
@@ -122,7 +136,8 @@ public sealed class TaskScanner : IAsyncDisposable
TimeSpan? tick = null,
TimeSpan? lease = null,
int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null)
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null)
{
_repo = repo;
_dispatcher = dispatcher;
@@ -133,6 +148,7 @@ public sealed class TaskScanner : IAsyncDisposable
_lease = lease ?? TimeSpan.FromMinutes(15);
_maxParallelRuns = Math.Max(1, maxParallelRuns);
_missPolicy = budgetMissPolicy ?? (_ => BudgetMissPolicy.Expire);
_lateRunGate = lateRunGate;
}
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
@@ -319,7 +335,8 @@ public sealed class TaskScanner : IAsyncDisposable
var launched = new List<Task>();
var waiting = 0;
foreach (var (task, occ, _) in due)
var waitingForHeadroom = 0;
foreach (var (task, occ, late) in due)
{
// OpenCount also counts runs started by hand (RunTaskNowAsync) — they take a
// slot, even though they did not wait for one.
@@ -329,6 +346,14 @@ public sealed class TaskScanner : IAsyncDisposable
continue;
}
// #716: Late work only uses what the rest of the day does not need.
if (late && _lateRunGate is not null
&& !await _lateRunGate.HasRoomAsync(task, candidates, now, ct))
{
waitingForHeadroom++;
continue;
}
var token = Guid.NewGuid().ToString("N");
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
launched.Add(Track(RunLoggedAsync(task, token, ct)));
@@ -347,6 +372,16 @@ public sealed class TaskScanner : IAsyncDisposable
_lastWaiting = waiting;
}
if (waitingForHeadroom != _lastWaitingForHeadroom)
{
if (waitingForHeadroom > 0)
_logger.LogInformation(
"{Waiting} carried-over or catch-up run(s) waiting for budget headroom — "
+ "the rest of today's schedule comes first",
waitingForHeadroom);
_lastWaitingForHeadroom = waitingForHeadroom;
}
return launched;
}
@@ -201,6 +201,70 @@ public static class TaskSchedule
}
}
/// <summary>
/// #716: How many runs the task will still trigger from <paramref name="fromUtc"/> up to
/// (excluding) <paramref name="toUtc"/> — including one that is due right now. The
/// basis for "how much budget does the rest of the day still need".
/// </summary>
public static int OccurrencesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
{
fromUtc = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc);
toUtc = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc);
if (toUtc <= fromUtc)
return 0;
var dueNow = DueOccurrence(task, fromUtc) is not null ? 1 : 0;
if (task.When is not { } when)
return dueNow; // one-time without a schedule: due until it ran
switch (when.Kind)
{
case TaskWhenKind.At:
{
if (dueNow == 1 || task.LastOccurrence is not null)
return dueNow;
return TryResolveInstant(when.Value, when.TimeZone, out var at) && at < toUtc ? 1 : 0;
}
case TaskWhenKind.Every:
{
if (!TryParseInterval(when.Value, out var interval) || interval <= TimeSpan.Zero)
return dueNow;
var next = dueNow == 1
? fromUtc + interval
: NextOccurrenceUtc(task, fromUtc) ?? toUtc;
var count = dueNow;
while (count < cap && next < toUtc)
{
count++;
next += interval;
}
return count;
}
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return 0;
var count = dueNow;
var cursor = fromUtc;
while (count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
{
if (next > fromUtc)
count++;
cursor = next;
}
return count;
}
default:
return dueNow;
}
}
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
{
cron = null!;
@@ -31,6 +31,10 @@ public sealed class TaskboardService
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
=> _repo.ListAllAsync(includeArchived, ct);
// ─── Anlegen ───
/// <summary>
@@ -104,9 +108,9 @@ public sealed class TaskboardService
if (task is not null) { seen.Add(task.Id); imported++; }
}
// Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
// wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
// Verwaiste Zeilen archivieren. Ungekappt (#716): Vorher sah dieser Abgleich nur die
// ersten 500 Zeilen, und darüber blieben verwaiste Zeilen still stehen.
var known = await _repo.ListAllAsync(includeArchived: true, ct);
foreach (var task in known)
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
@@ -210,7 +210,9 @@ public sealed class TaskboardTool : IAgentTool
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
{
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
// stayed invisible once the board grew beyond that.
var alle = await board.ListAllAsync(ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
}
@@ -0,0 +1,166 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #716, stage 3: late work — carried-over tasks and catch-ups — only uses budget the rest
/// of the day does not need. The new day has right of way.
/// </summary>
public sealed class BudgetHeadroomTests
{
// 2027-03-10 14:00 UTC; the test clock's local zone is UTC, so the day ends at 24:00 UTC.
private static readonly DateTime Now = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CreatedEarlier = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc);
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// <summary>Runs averaging 0.05 USD.</summary>
private static List<RunUsage> History(decimal cost = 0.05m)
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = cost, CostIsKnown = true })];
private static TaskItem Cron(string id, string expression, string? lastOccurrence = null) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, LastOccurrence = lastOccurrence,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = expression, TimeZone = "UTC" }
};
private static TaskItem Carried(string id) => new()
{
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, BudgetMissCount = 1
};
private static BudgetHeadroom Headroom(decimal used, AgentConfig agent, InstanceBudget? instance = null, decimal perRun = 0.05m)
=> new(new BudgetGuard(new FakeUsage(used, History(perRun))),
() => [agent], () => instance ?? InstanceBudget.Unlimited, new UtcClock(Now));
// ═══════════════════ counting the rest of the day ═══════════════════
[Fact]
public void Hourly_from_14_30_until_midnight_is_nine_more_runs()
{
// 15:00 … 23:00. The 14:00 run is done (marker), so nothing is due right now.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T14:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(30), Now.Date.AddDays(1)).ShouldBe(9);
}
[Fact]
public void A_run_due_right_now_counts_as_well()
{
// Last run 13:00, so the 14:00 run is due now; then 15:00 … 23:00.
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(1), Now.Date.AddDays(1)).ShouldBe(10);
}
[Fact]
public void A_one_time_task_counts_once_until_it_ran()
{
var open = new TaskItem { Id = "t", CreatedAt = CreatedEarlier };
var done = open with { LastOccurrence = "2027-03-10T09:00:00.0000000Z" };
TaskSchedule.OccurrencesBetween(open, Now, Now.AddHours(1)).ShouldBe(1);
TaskSchedule.OccurrencesBetween(done, Now, Now.AddHours(1)).ShouldBe(0);
}
[Fact]
public void An_interval_counts_its_remaining_ticks()
{
var task = new TaskItem
{
Id = "t", CreatedAt = CreatedEarlier, LastOccurrence = "2027-03-10T13:45:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
};
// 14:15, 14:45, 15:15, 15:45 before 16:00.
TaskSchedule.OccurrencesBetween(task, Now, Now.AddHours(2)).ShouldBe(4);
}
// ═══════════════════ headroom ═══════════════════
[Fact]
public async Task Late_work_fits_when_the_rest_of_the_day_leaves_room()
{
// Limit 1.00, used 0.30, margin 0.10. Rest of the day: 10 hourly runs à 0.05 = 0.50.
// Left: 1.00 - 0.30 - 0.50 - 0.10 = 0.10 ≥ 0.05 for the late run.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.30m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task Late_work_waits_when_the_rest_of_the_day_needs_the_budget()
{
// Same, but 0.40 used: 1.00 - 0.40 - 0.50 - 0.10 = 0.00 < 0.05.
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
var room = await Headroom(used: 0.40m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
room.ShouldBeFalse("the day's own schedule has right of way");
}
[Fact]
public async Task Other_late_work_does_not_count_as_the_days_demand()
{
// Ten carried-over tasks do not reserve budget against each other — they are what
// waits for headroom, not what headroom is kept for.
var late = Carried("t-late");
var others = Enumerable.Range(0, 10).Select(i => Carried($"t-other-{i}")).ToList();
var room = await Headroom(used: 0.50m, Agent()).HasRoomAsync(late, [late, .. others], Now, default);
room.ShouldBeTrue();
}
[Fact]
public async Task The_instance_limit_counts_the_schedules_of_all_agents()
{
var agent = Agent(dailyCost: 0); // no limit of its own
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
var late = Carried("t-late");
(await Headroom(used: 0.30m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeTrue();
(await Headroom(used: 0.40m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeFalse();
}
[Fact]
public async Task Without_any_limit_there_is_always_room()
{
var late = Carried("t-late");
(await Headroom(used: 99m, Agent(dailyCost: 0)).HasRoomAsync(late, [late], Now, default)).ShouldBeTrue();
}
// ═══════════════════ test doubles ═══════════════════
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);
}
}
@@ -238,6 +238,42 @@ public sealed class BudgetMissTests : IDisposable
task.DeferredUntil.ShouldBeNull("a shutdown keeps the old 30-minute retry");
}
[Fact]
public async Task Late_work_waits_while_the_headroom_gate_says_no()
{
await SeedOneTime("t-yesterday");
var dispatcher = new BudgetDispatcher { HasBudget = false };
var gate = new FixedGate { Room = false };
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance,
clock: _clock, lateRunGate: gate);
await scanner.ScanOnceAsync(default);
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
await SeedOneTime("t-today");
dispatcher.HasBudget = true;
dispatcher.Started.Clear();
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-today"], "regular work never asks the gate");
gate.Asked.ShouldBe(["t-yesterday"]);
gate.Room = true;
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-today", "t-yesterday"]);
}
private sealed class FixedGate : ILateRunGate
{
public bool Room { get; set; }
public List<string> Asked { get; } = [];
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
Asked.Add(lateTask.Id);
return Task.FromResult(Room);
}
}
// ═══════════════════ test double ═══════════════════
private sealed class BudgetDispatcher : ITaskDispatcher
@@ -143,6 +143,24 @@ public sealed class DublettenTests : IDisposable
(await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count.ShouldBe(2);
}
[Fact]
public async Task Die_Dublette_wird_auch_hinter_500_anderen_Aufgaben_gefunden()
{
// #716: Die Suche las ListAsync mit Limit 500, sortiert nach Prioritaet. Eine alte
// Aufgabe mit niedriger Prioritaet fiel damit aus dem Blick, sobald das Brett wuchs.
var alt = await _board.CreateAsync(new TaskItem
{
Title = "Betriebsluecke: Eur Kurse nicht aktuell", Assignee = "@human", Priority = 1
}, default);
for (var i = 0; i < 510; i++)
await _repo.UpsertAsync(new TaskItem { Id = $"t-fill-{i}", Title = $"Anderes Thema {i}", Priority = 5 }, default);
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}""");
result.Success.ShouldBeFalse("die alte Aufgabe meint dasselbe");
result.ErrorMessage.ShouldNotBeNull().ShouldContain(alt.Id);
}
[Fact]
public async Task Eine_erledigte_Aufgabe_blockiert_den_naechsten_Befund_nicht()
{