Budgetende, Stufe 4: den Engpass melden, bevor er eintritt (#716)

- TaskSchedule.OccurrenceTimesBetween liefert die Zeitpunkte der restlichen
  Laeufe; OccurrencesBetween zaehlt sie nur noch.
- BudgetHeadroom implementiert IBudgetOutlook: Die restlichen geplanten Laeufe
  des Tages werden in zeitlicher Reihenfolge durchgegangen, je Agent mit
  Grenze und fuer die Instanz, bis zum ersten Lauf, der das Budget verbraucht
  vorfaende. Das spiegelt die Pruefung beim Start eines Laufs. Spaetwerk
  zaehlt nicht mit, es laeuft ohnehin nur im Spielraum.
- Der Scanner schaut alle 30 Minuten voraus und nennt je Agent und Tag einmal,
  wann das Budget ausgeht und wie viele der geplanten Laeufe dann ausfallen,
  solange noch Zeit ist, nachzulegen oder zu entscheiden, was wartet.

Die Meldung steht vorerst im Protokoll (Warnung). Der Weg zum Menschen ueber
einen Kanal gehoert zu #646, die Anzeige ins Lagebild (#711).

Tests: 4 neu fuer die Vorschau. 1.261 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:12:44 +02:00
co-authored by Claude Opus 5.5
parent 9cf9a2cfc2
commit 32383119da
5 changed files with 229 additions and 24 deletions
+69 -1
View File
@@ -53,6 +53,21 @@ public interface ILateRunGate
Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>
/// #716: The day ahead — where the budget will not reach the end of today's schedule.
/// </summary>
public interface IBudgetOutlook
{
Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
}
/// <summary>
/// #716: An agent whose budget runs out before today's schedule does — from when on, and
/// how many planned runs will be refused. <see cref="AgentId"/> is empty for the instance
/// limit.
/// </summary>
public sealed record BudgetShortfall(string AgentId, DateTime ExhaustsAtUtc, int RunsRefused, int RunsPlanned);
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
{
@@ -121,6 +136,16 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly ILateRunGate? _lateRunGate;
private readonly IBudgetOutlook? _outlook;
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
private DateTime _lastOutlookAt = DateTime.MinValue;
/// <summary>Agents already warned about today, with the day — once per agent and day.</summary>
private readonly Dictionary<string, DateOnly> _shortfallWarned = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many late runs waited for budget headroom at the last tick.</summary>
private int _lastWaitingForHeadroom;
@@ -137,7 +162,8 @@ public sealed class TaskScanner : IAsyncDisposable
TimeSpan? lease = null,
int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null)
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null)
{
_repo = repo;
_dispatcher = dispatcher;
@@ -149,6 +175,7 @@ public sealed class TaskScanner : IAsyncDisposable
_maxParallelRuns = Math.Max(1, maxParallelRuns);
_missPolicy = budgetMissPolicy ?? (_ => BudgetMissPolicy.Expire);
_lateRunGate = lateRunGate;
_outlook = budgetOutlook;
}
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
@@ -257,6 +284,7 @@ public sealed class TaskScanner : IAsyncDisposable
var candidates = await _repo.ListRunnableAsync(ct);
SummarizeMissesIfNewDay(now);
await WarnAboutShortfallsAsync(candidates, now, ct);
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
foreach (var task in candidates)
@@ -563,6 +591,46 @@ public sealed class TaskScanner : IAsyncDisposable
: "occurrence lapsed (policy: expire)");
}
/// <summary>
/// #716: Prevent rather than catch up. Every half hour the day ahead is checked; an
/// agent whose budget will not last until midnight is named once a day — with the time
/// it runs out and how many planned runs that costs — while there is still time to
/// raise the budget or decide what can go.
/// </summary>
private async Task WarnAboutShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
if (_outlook is null || nowUtc - _lastOutlookAt < OutlookInterval)
return;
_lastOutlookAt = nowUtc;
IReadOnlyList<BudgetShortfall> shortfalls;
try
{
shortfalls = await _outlook.ShortfallsAsync(runnable, nowUtc, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Budget outlook failed — no forecast this time");
return;
}
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
foreach (var shortfall in shortfalls)
{
if (_shortfallWarned.TryGetValue(shortfall.AgentId, out var warnedOn) && warnedOn == today)
continue;
_shortfallWarned[shortfall.AgentId] = today;
var local = TimeZoneInfo.ConvertTimeFromUtc(shortfall.ExhaustsAtUtc, _clock.LocalTimeZone);
_logger.LogWarning(
"Budget forecast: the daily budget of {Who} will run out around {Time:HH:mm} — "
+ "{Refused} of {Planned} runs planned for the rest of today would be refused. "
+ "Raise the budget or decide what can wait.",
shortfall.AgentId.Length == 0 ? "the instance" : $"agent '{shortfall.AgentId}'",
local, shortfall.RunsRefused, shortfall.RunsPlanned);
}
}
/// <summary>
/// #716: On the first tick of a new local day, one line per agent about what the
/// budget refused the day before. The details are on the tasks themselves.