Files
EugeneTes fd6b9027b6 fix(scheduler): address review findings
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock;
  a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step
- Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00)
- core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering
- Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir
- ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule
- tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage
- docs: spec notes for the above; README row for the scheduler
2026-09-27 10:56:45 +02:00

94 lines
5.2 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Cronos;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
public static partial class Schedules
{
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
public static CronExpression ParseCron(string expression)
{
var text = expression?.Trim() ?? "";
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
if (!text.StartsWith('@'))
{
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
if (fields.Length == 6)
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
if (fields.Length != 5)
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
}
try
{
return CronExpression.Parse(text, CronFormat.Standard);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
}
}
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
/// <summary>
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
/// they'd be resolved against the machine's clock, not the chat's.
/// </summary>
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
{
var trimmed = text?.Trim() ?? "";
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
var match = IsoAt().Match(trimmed);
if (!match.Success) throw new JobValidationException(hint);
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
throw new JobValidationException(hint);
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
if (match.Groups["offset"] is { Success: true } group)
{
try
{
return new DateTimeOffset(wall, ParseOffset(group.Value));
}
catch (ArgumentException)
{
throw new JobValidationException(hint); // an offset beyond ±14:00
}
}
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
// An ambiguous fall-back time is read as the first of the two (the larger offset).
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
return new DateTimeOffset(wall, offset);
}
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
private static TimeSpan ParseOffset(string text)
{
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
var offset = new TimeSpan(hours, minutes, 0);
return text[0] == '-' ? -offset : offset;
}
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
private static partial Regex IsoAt();
}