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:
co-authored by
Claude Opus 5.5
parent
8a83a67fb4
commit
9cf9a2cfc2
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user