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
+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);
}