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
+27 -18
View File
@@ -207,61 +207,70 @@ public static class TaskSchedule
/// 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)
=> OccurrenceTimesBetween(task, fromUtc, toUtc, cap).Count;
/// <summary>
/// #716: When the task will run from <paramref name="fromUtc"/> up to (excluding)
/// <paramref name="toUtc"/>. A run due right now is listed at <paramref name="fromUtc"/>.
/// </summary>
public static IReadOnlyList<DateTime> OccurrenceTimesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
{
fromUtc = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc);
toUtc = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc);
var times = new List<DateTime>();
if (toUtc <= fromUtc)
return 0;
return times;
var dueNow = DueOccurrence(task, fromUtc) is not null ? 1 : 0;
var dueNow = DueOccurrence(task, fromUtc) is not null;
if (dueNow)
times.Add(fromUtc);
if (task.When is not { } when)
return dueNow; // one-time without a schedule: due until it ran
return times; // 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;
if (!dueNow && task.LastOccurrence is null
&& TryResolveInstant(when.Value, when.TimeZone, out var at) && at < toUtc)
times.Add(at);
return times;
}
case TaskWhenKind.Every:
{
if (!TryParseInterval(when.Value, out var interval) || interval <= TimeSpan.Zero)
return dueNow;
return times;
var next = dueNow == 1
var next = dueNow
? fromUtc + interval
: NextOccurrenceUtc(task, fromUtc) ?? toUtc;
var count = dueNow;
while (count < cap && next < toUtc)
while (times.Count < cap && next < toUtc)
{
count++;
times.Add(next);
next += interval;
}
return count;
return times;
}
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return 0;
return times;
var count = dueNow;
var cursor = fromUtc;
while (count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
while (times.Count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
{
if (next > fromUtc)
count++;
times.Add(next);
cursor = next;
}
return count;
return times;
}
default:
return dueNow;
return times;
}
}