From 9cf9a2cfc2b5c67a5bcb418c89082a9ee3604ae0 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 24 Sep 2026 12:10:40 +0200 Subject: [PATCH] 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 --- docs/Organisation-Konzept.md | 11 +- src/ClawdDotNet.App/AppHost.cs | 7 +- .../Services/TaskAdministration.cs | 3 +- src/ClawdDotNet.Core/Budget/BudgetGuard.cs | 4 + src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs | 144 +++++++++++++++ src/ClawdDotNet.Core/Engine/AgentEngine.cs | 6 + src/ClawdDotNet.Core/Tasks/ITaskRepository.cs | 7 + .../Tasks/SqliteTaskRepository.cs | 16 ++ src/ClawdDotNet.Core/Tasks/TaskScanner.cs | 39 +++- src/ClawdDotNet.Core/Tasks/TaskSchedule.cs | 64 +++++++ .../Tasks/TaskboardService.cs | 10 +- .../TaskboardTool.cs | 4 +- .../Budget/BudgetHeadroomTests.cs | 166 ++++++++++++++++++ .../Tasks/BudgetMissTests.cs | 36 ++++ .../Taskboard/DublettenTests.cs | 18 ++ 15 files changed, 524 insertions(+), 11 deletions(-) create mode 100644 src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Budget/BudgetHeadroomTests.cs diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 63fad21..e3c9ce7 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -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. diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 675cf46..3268f28 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -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 () => diff --git a/src/ClawdDotNet.App/Services/TaskAdministration.cs b/src/ClawdDotNet.App/Services/TaskAdministration.cs index cf390e0..2a71fd5 100644 --- a/src/ClawdDotNet.App/Services/TaskAdministration.cs +++ b/src/ClawdDotNet.App/Services/TaskAdministration.cs @@ -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>(StringComparer.Ordinal); if (audit is not null) diff --git a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs index 6419b60..5e252b2 100644 --- a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs +++ b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs @@ -117,6 +117,10 @@ public sealed class BudgetGuard } } + /// Today's usage of an agent, or with an empty id of the whole instance. + public Task GetUsedTodayAsync(string agentId, CancellationToken ct) + => _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct); + /// What is currently reserved by runs in flight — for the agent, or with an /// empty id for the whole instance. public RunEstimate Reserved(string agentId) diff --git a/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs b/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs new file mode 100644 index 0000000..d871ee5 --- /dev/null +++ b/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs @@ -0,0 +1,144 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Tasks; + +namespace ClawdDotNet.Core.Budget; + +/// +/// #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. +/// +/// The rule: the new day has right of way. 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. +/// +/// 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. +/// +/// Estimates, honestly. A run is estimated from the agent's recent runs +/// (). 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. +/// +public sealed class BudgetHeadroom : ILateRunGate +{ + /// Share of each limit kept free as a reserve for surprises. + public const decimal SafetyShare = 0.10m; + + private readonly BudgetGuard _guard; + private readonly Func> _agents; + private readonly Func _instanceBudget; + private readonly TimeProvider _clock; + + public BudgetHeadroom( + BudgetGuard guard, + Func> agents, + Func instanceBudget, + TimeProvider? clock = null) + { + _guard = guard; + _agents = agents; + _instanceBudget = instanceBudget; + _clock = clock ?? TimeProvider.System; + } + + public async Task HasRoomAsync( + TaskItem lateTask, IReadOnlyList 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(StringComparer.OrdinalIgnoreCase); + + async Task 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; + } + + /// Carried-over work and pending catch-ups are the late runs. + public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending; + + /// 0 or less means: no limit of this kind. + 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 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); + } +} diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index cc8389b..b7c44a6 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -35,6 +35,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// Tagesgrenzen der Instanz. Wird vom Host gesetzt. public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited; + /// + /// #716: The guard that checks and reserves every run — the scanner's headroom + /// calculation has to see the same reservations. null without usage recording. + /// + public BudgetGuard? BudgetGuard => _budgetGuard; + /// /// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8). /// A→B ist die Tiefe 1, A→B→C die Tiefe 2. diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs index a8cadc2..be99078 100644 --- a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs @@ -29,6 +29,13 @@ public interface ITaskRepository /// Task> ListRunnableAsync(CancellationToken ct); + /// + /// 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 + /// with its limit of 500 and silently saw only part of it. + /// + Task> ListAllAsync(bool includeArchived, CancellationToken ct); + /// /// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt; /// jeder weitere erhält false. Setzt den Marker () diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs index dba39e2..0a7d7ff 100644 --- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs @@ -270,6 +270,22 @@ public sealed class SqliteTaskRepository : ITaskRepository return await cmd.ExecuteNonQueryAsync(ct); }, ct); + public async Task> 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(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + + return results; + } + public async Task> ListRunnableAsync(CancellationToken ct) { await using var conn = await _storage.OpenConnectionAsync(ct); diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs index c1571c6..da5f36e 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs @@ -44,6 +44,15 @@ public interface IMarketCalendar bool IsOpen(DateTime nowUtc); } +/// +/// #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. +/// +public interface ILateRunGate +{ + Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct); +} + /// Bis C1 kommt: der Markt gilt als immer offen. public sealed class AlwaysOpenMarketCalendar : IMarketCalendar { @@ -110,6 +119,11 @@ public sealed class TaskScanner : IAsyncDisposable private readonly Dictionary _missesToday = new(StringComparer.OrdinalIgnoreCase); private DateOnly? _missDay; + private readonly ILateRunGate? _lateRunGate; + + /// How many late runs waited for budget headroom at the last tick. + 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? budgetMissPolicy = null) + Func? 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; } /// How many scheduled runs may be in flight at once (#682). @@ -319,7 +335,8 @@ public sealed class TaskScanner : IAsyncDisposable var launched = new List(); 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; } diff --git a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs index 4c87ea9..58bee19 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs @@ -201,6 +201,70 @@ public static class TaskSchedule } } + /// + /// #716: How many runs the task will still trigger from up to + /// (excluding) — including one that is due right now. The + /// basis for "how much budget does the rest of the day still need". + /// + 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!; diff --git a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs index b0612f3..171c8e1 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs @@ -31,6 +31,10 @@ public sealed class TaskboardService public Task> ListAsync(TaskQuery query, CancellationToken ct) => _repo.ListAsync(query, ct); + /// The whole board, without the cap of . + public Task> ListAllAsync(CancellationToken ct, bool includeArchived = false) + => _repo.ListAllAsync(includeArchived, ct); + // ─── Anlegen ─── /// @@ -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); diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs index fb2ab8c..e0c71b3 100644 --- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs @@ -210,7 +210,9 @@ public sealed class TaskboardTool : IAgentTool private static async Task> 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); } diff --git a/tests/ClawdDotNet.Core.Tests/Budget/BudgetHeadroomTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/BudgetHeadroomTests.cs new file mode 100644 index 0000000..d0fa360 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Budget/BudgetHeadroomTests.cs @@ -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; + +/// +/// #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. +/// +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 } }; + + /// Runs averaging 0.05 USD. + private static List 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 recent) : IUsageRepository + { + public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask; + + public Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct) + => Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0)); + + public Task> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent); + + public Task> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct) + => Task.FromResult(recent); + + public Task PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs index c3cb850..44edfa7 100644 --- a/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Tasks/BudgetMissTests.cs @@ -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 Asked { get; } = []; + + public Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct) + { + Asked.Add(lateTask.Id); + return Task.FromResult(Room); + } + } + // ═══════════════════ test double ═══════════════════ private sealed class BudgetDispatcher : ITaskDispatcher diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs index 2051746..2c7e548 100644 --- a/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/DublettenTests.cs @@ -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() {