Files
ClawdDotNet/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs
T
RichardandClaude Opus 5 8ab9d99abe Cron: Bereich mit Schritt (7-22/3) lesen, ungueltige Ausdruecke im Scanner melden (#597)
Der Nachrichten-Digest im Dauerbetrieb der Test-VM lief nie: CronExpression
verstand 'von-bis/schritt' nicht, TaskSchedule verschluckte den Fehler.
Version 0.1.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:51:36 +02:00

270 lines
11 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;
}
}
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;
}