Files
ClawdDotNet/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
T
RichardandClaude Opus 5.5 32383119da 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>
2026-09-24 12:12:44 +02:00

484 lines
18 KiB
C#

using System.Globalization;
using ClawdDotNet.Core.Scheduling;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
///
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
///
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
/// eine Welle aus.
/// </summary>
public static class TaskSchedule
{
/// <summary>
/// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
/// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
/// (<see cref="TaskItem.LastOccurrence"/>) liegt.
/// </summary>
public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
{
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (task.When is null)
{
// Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
if (task.LastOccurrence is not null) return null;
var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
return Iso(basis);
}
return task.When.Kind switch
{
TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
_ => null
};
}
/// <summary>
/// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
/// <c>null</c>.
///
/// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
/// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
/// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
/// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
/// </summary>
public static string? UnresolvableTimeZone(TaskItem task)
=> task.When is { } when
&& !string.IsNullOrWhiteSpace(when.TimeZone)
&& !TimeZones.IsKnown(when.TimeZone)
? when.TimeZone
: null;
/// <summary>
/// Der Parserfehler eines Cron-Termins, wenn der Ausdruck ungültig ist — sonst
/// <c>null</c>.
///
/// Ein solcher Termin feuert nie (<c>DueCron</c> fängt den Fehler ab, damit ein
/// kaputter Ausdruck nicht den ganzen Takt kippt). Wie bei der Zeitzone meldet der
/// Scanner das einmal je Aufgabe — vorher stand die Aufgabe als <c>todo</c> mit Termin
/// im Taskboard und lief schlicht nie (#597).
/// </summary>
public static string? InvalidCron(TaskItem task)
{
if (task.When is not { Kind: TaskWhenKind.Cron } when) return null;
try
{
CronExpression.Parse(when.Value);
return null;
}
catch (FormatException ex)
{
return ex.Message;
}
}
/// <summary>
/// Prueft einen Termin, bevor er gespeichert wird - liefert den Grund, warum er nie
/// feuern wuerde, oder <c>null</c> (#597).
///
/// <para>Ein ungueltiger Termin laesst den Scanner nicht abstuerzen, sondern feuert
/// schlicht nie. Beim Anlegen ist der richtige Moment, das zu sagen: Dann sieht es der,
/// der ihn eingetragen hat, statt Stunden spaeter niemand.</para>
/// </summary>
public static string? ValidateWhen(TaskWhen when)
{
if (!string.IsNullOrWhiteSpace(when.TimeZone) && !TimeZones.IsKnown(when.TimeZone))
return $"Die Zeitzone '{when.TimeZone}' ist auf diesem System nicht bekannt (IANA-Schreibweise, z.B. 'Europe/Berlin').";
switch (when.Kind)
{
case TaskWhenKind.Cron:
try
{
CronExpression.Parse(when.Value);
return null;
}
catch (FormatException ex)
{
return ex.Message;
}
case TaskWhenKind.Every:
return TryParseInterval(when.Value, out _)
? null
: $"'{when.Value}' ist kein Intervall (erwartet z.B. 30m, 2h oder 1d).";
case TaskWhenKind.At:
return TryResolveInstant(when.Value, when.TimeZone, out _)
? null
: $"'{when.Value}' ist kein Zeitpunkt (erwartet z.B. 2026-09-15T08:00).";
default:
return "Unbekannte Terminart.";
}
}
/// <summary>
/// Der naechste geplante Lauf in UTC - fuer die Anzeige im Taskboard (#594). <c>null</c>,
/// wenn keiner mehr ansteht oder der Termin nicht lesbar ist.
/// </summary>
public static DateTime? NextOccurrenceUtc(TaskItem task, DateTime nowUtc)
{
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (task.When is not { } when)
return task.LastOccurrence is null ? nowUtc : null;
switch (when.Kind)
{
case TaskWhenKind.At:
return task.LastOccurrence is null && TryResolveInstant(when.Value, when.TimeZone, out var at)
? at
: null;
case TaskWhenKind.Every:
{
if (!TryParseInterval(when.Value, out var interval))
return null;
var anchor = task.LastOccurrence is not null
? ParseIso(task.LastOccurrence)
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
var next = anchor + interval;
return next < nowUtc ? nowUtc : next;
}
case TaskWhenKind.Cron:
return TryPrepareCron(when, out var cron, out var tz) ? NextCronUtc(cron, tz, nowUtc) : null;
default:
return null;
}
}
/// <summary>
/// Wie viele Laeufe der Termin im Schnitt je Tag ausloest, gerechnet ueber die naechsten
/// <paramref name="days"/> Tage - fuer die Kostenprognose (#595). Einmalige Termine
/// liefern 0.
/// </summary>
public static double RunsPerDay(TaskItem task, DateTime nowUtc, int days = 7)
{
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (task.When is not { } when || days <= 0)
return 0;
switch (when.Kind)
{
case TaskWhenKind.Every:
return TryParseInterval(when.Value, out var interval) ? TimeSpan.FromDays(1) / interval : 0;
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return 0;
var end = nowUtc.AddDays(days);
var count = 0;
var cursor = nowUtc;
// Obergrenze: jede Minute ueber sieben Tage sind 10.080 - mehr gibt es nicht.
while (count < 100_000 && NextCronUtc(cron, tz, cursor) is { } next && next <= end)
{
count++;
cursor = next;
}
return count / (double)days;
}
default:
return 0;
}
}
/// <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)
=> 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 times;
var dueNow = DueOccurrence(task, fromUtc) is not null;
if (dueNow)
times.Add(fromUtc);
if (task.When is not { } when)
return times; // one-time without a schedule: due until it ran
switch (when.Kind)
{
case TaskWhenKind.At:
{
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 times;
var next = dueNow
? fromUtc + interval
: NextOccurrenceUtc(task, fromUtc) ?? toUtc;
while (times.Count < cap && next < toUtc)
{
times.Add(next);
next += interval;
}
return times;
}
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return times;
var cursor = fromUtc;
while (times.Count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
{
if (next > fromUtc)
times.Add(next);
cursor = next;
}
return times;
}
default:
return times;
}
}
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
{
cron = null!;
tz = null!;
try { cron = CronExpression.Parse(when.Value); }
catch (FormatException) { return false; }
if (ResolveTimeZone(when.TimeZone) is not { } zone)
return false;
tz = zone;
return true;
}
private static DateTime? NextCronUtc(CronExpression cron, TimeZoneInfo tz, DateTime afterUtc)
{
var local = Truncate(TimeZoneInfo.ConvertTimeFromUtc(afterUtc, tz));
if (cron.GetNextOccurrence(local) is not { } nextLocal)
return null;
return TryToUtc(nextLocal, tz, out var utc) ? utc : null;
}
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
{
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
if (nowUtc < targetUtc) return null;
return Iso(targetUtc);
}
private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
{
if (!TryParseInterval(task.When!.Value, out var interval)) return null;
var anchor = marker is not null
? ParseIso(marker)
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
return nowUtc < anchor + interval ? null : Iso(nowUtc);
}
private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
{
var when = task.When!;
CronExpression cron;
try { cron = CronExpression.Parse(when.Value); }
catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
var tz = ResolveTimeZone(when.TimeZone);
if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
// Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
// ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
// nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
var lowerUtc = marker is not null ? ParseIso(marker)
: task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
: nowUtc;
var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
var limit = lowerLocal;
DateTime? matchLocal = null;
for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
{
if (cron.Matches(candidate)) { matchLocal = candidate; break; }
}
if (matchLocal is null) return null;
if (!TryToUtc(matchLocal.Value, tz, out var occUtc)) return null;
var occ = Iso(occUtc);
// Nur fällig, wenn der Termin echt über dem Marker liegt.
return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
}
// ─── Hilfsfunktionen ───
/// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
/// angegebenen Zeitzone.</summary>
private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
{
utc = default;
var v = value.Trim();
if (v.Length == 0) return false;
if (v.EndsWith('Z') || v.EndsWith('z'))
{
if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var dto))
{
utc = dto.UtcDateTime;
return true;
}
return false;
}
if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
{
var tz = ResolveTimeZone(timeZone);
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
return TryToUtc(local, tz, out utc);
}
return false;
}
/// <summary>
/// Rechnet eine Wanduhrzeit in ihren UTC-Zeitpunkt um — auch dann, wenn es diese
/// Uhrzeit an diesem Tag gar nicht gibt.
///
/// <para><b>Der Frühjahrssprung.</b> In der Nacht der Umstellung springt die Uhr von
/// 02:00 auf 03:00; „02:30" existiert an diesem Tag nicht. Vorher fiel ein Termin
/// darin schlicht aus — einmal im Jahr lief eine tägliche Aufgabe nicht, und weil
/// nichts scheiterte, fiel es niemandem auf. Ein ausgefallener Lauf ist kein
/// harmloseres Ergebnis als ein verschobener, nur ein leiseres.</para>
///
/// <para>Der Termin wandert deshalb auf den ersten Zeitpunkt, den es nach der Lücke
/// gibt — aus 02:30 wird 03:00, nicht 03:30. So spät wie nötig, so früh wie möglich;
/// die Reihenfolge mehrerer Termine in derselben Lücke bleibt dabei erhalten, weil
/// sie alle auf denselben Zeitpunkt fallen und der Marker den zweiten abfängt.</para>
///
/// <para><b>Der Herbstsprung</b> braucht nichts Eigenes: Eine doppelt vorkommende
/// Uhrzeit rechnet <c>ConvertTimeToUtc</c> auf die Normalzeit-Fassung um, also auf
/// den zweiten Durchlauf. Beide Durchläufe ergeben denselben Occurrence-Key, und der
/// Marker sorgt dafür, dass nur einer davon feuert.</para>
/// </summary>
private static bool TryToUtc(DateTime local, TimeZoneInfo tz, out DateTime utc)
{
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
if (!tz.IsInvalidTime(unspecified))
{
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
return true;
}
// Minutenweise vorrücken, bis die Uhr wieder eine Zeit zeigt, die es gibt.
// Die Obergrenze ist Vorsicht, kein Fachwissen: Die größte gebräuchliche Lücke
// ist eine Stunde (Lord Howe Island: 30 Minuten). Bräuchte es je mehr als drei,
// stimmt etwas anderes nicht, und dann ist „gar nicht" das ehrlichere Ergebnis.
for (var probe = unspecified.AddMinutes(1);
probe <= unspecified.AddHours(3);
probe = probe.AddMinutes(1))
{
if (tz.IsInvalidTime(probe)) continue;
utc = TimeZoneInfo.ConvertTimeToUtc(probe, tz);
return true;
}
utc = default;
return false;
}
/// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
private static bool TryParseInterval(string value, out TimeSpan interval)
{
interval = default;
var v = value.Trim().ToLowerInvariant();
if (v.Length < 2) return false;
var unit = v[^1];
if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
return false;
interval = unit switch
{
's' => TimeSpan.FromSeconds(n),
'm' => TimeSpan.FromMinutes(n),
'h' => TimeSpan.FromHours(n),
'd' => TimeSpan.FromDays(n),
_ => TimeSpan.Zero
};
return interval > TimeSpan.Zero;
}
/// <summary>
/// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
///
/// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
/// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
/// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
/// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
///
/// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
/// mit unbekannter Zone mit Meldung ab.
/// </summary>
private static TimeZoneInfo? ResolveTimeZone(string id)
{
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
return TimeZones.TryResolve(id);
}
private static DateTime Truncate(DateTime value)
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
private static DateTime ParseIso(string value)
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
? dt.ToUniversalTime()
: DateTime.MinValue;
}