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
This commit is contained in:
EugeneTes
2026-09-27 10:56:45 +02:00
parent 126d3afff1
commit fd6b9027b6
25 changed files with 326 additions and 101 deletions

View File

@@ -161,13 +161,17 @@ public static class ClaudeArgs
return env;
}
/// <summary>The IANA id for <c>TZ</c>: the zone's own id, a converted Windows id, or null for an unnamed <c>Local</c> zone.</summary>
/// <summary>
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
public static string? IanaZoneId(TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id;
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>

View File

@@ -27,9 +27,6 @@ public static class JobFormat
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
/// <summary><c>UTC+02:00</c> at <paramref name="at"/>.</summary>
public static string Offset(TimeZoneInfo zone, DateTimeOffset at) => ZoneNames.Offset(zone.GetUtcOffset(at));
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
public static string FailureReason(JobResult result) => result switch
{

View File

@@ -33,6 +33,7 @@ public sealed class JobScheduler : IAsyncDisposable
private DateTimeOffset? _wakeAt;
private bool _stopped;
private Task _pendingTick = Task.CompletedTask;
private int _firesStarted;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
{
@@ -53,6 +54,12 @@ public sealed class JobScheduler : IAsyncDisposable
/// <summary>When the timer fires next (tests).</summary>
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
/// <summary>Load the store, fire overdue one-offs (late), compute cron occurrences strictly after now (no catch-up), arm the timer.</summary>
public async Task StartAsync(IJobDelivery delivery, CancellationToken cancellationToken)
{
@@ -69,10 +76,19 @@ public sealed class JobScheduler : IAsyncDisposable
if (job.Schedule.At is { } at) entry.Next = at;
else
{
entry.Next = NextCron(job, now);
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
if (missed is { } m && m < now)
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
try
{
entry.Next = NextCron(job, now);
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
if (missed is { } m && m < now)
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
}
catch (Exception ex)
{
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
entry.Next = null;
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
}
}
_jobs[job.Id] = entry;
}
@@ -148,19 +164,25 @@ public sealed class JobScheduler : IAsyncDisposable
}
/// <summary>Remove a job of <paramref name="target"/>. A running watcher is killed and its outcome dropped; a prompt run in progress isn't aborted. False: unknown, or another target's.</summary>
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken)
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
{
Fire? running;
ScheduledJob removed;
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return false;
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
removed = entry.Job;
_jobs.Remove(id);
_inFlight.TryGetValue(id, out running);
}
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return true;
return removed;
}
/// <summary>The target's zone changed: cron jobs get their next occurrence in the new zone (strictly after now), one-offs keep their instant.</summary>
@@ -250,10 +272,13 @@ public sealed class JobScheduler : IAsyncDisposable
}
}
/// <summary>
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
/// </summary>
private void Arm()
{
ITimer timer;
TimeSpan delay;
lock (_lock)
{
if (_stopped || _timer is null) return;
@@ -262,16 +287,14 @@ public sealed class JobScheduler : IAsyncDisposable
var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
if (wake < now) wake = now;
_wakeAt = wake;
timer = _timer;
delay = wake - now;
}
try
{
timer.Change(delay, Timeout.InfiniteTimeSpan); // outside the lock: a zero delay may call back synchronously
}
catch (ObjectDisposedException)
{
// Stopped meanwhile.
try
{
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Stopped meanwhile.
}
}
}
@@ -288,6 +311,7 @@ public sealed class JobScheduler : IAsyncDisposable
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
_inFlight[job.Id] = new Fire(task, cancel);
_firesStarted++;
gate.SetResult();
}
@@ -324,12 +348,14 @@ public sealed class JobScheduler : IAsyncDisposable
switch (job.Kind)
{
case JobKind.Reminder:
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false);
result = new JobResult(true, null, false, null, _time.GetUtcNow());
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break;
case JobKind.Prompt:
await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false);
result = new JobResult(true, null, false, null, _time.GetUtcNow());
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break;
default:
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
@@ -338,7 +364,7 @@ public sealed class JobScheduler : IAsyncDisposable
if (result is not null) await RecordAsync(job.Id, result).ConfigureAwait(false);
}
/// <summary>Null when the run was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
/// <summary>Null when the run or its delivery was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
@@ -359,12 +385,14 @@ public sealed class JobScheduler : IAsyncDisposable
{
case ScriptOutcome.Output output:
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false);
return new JobResult(true, 0, false, null, at);
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
? new JobResult(true, 0, false, null, at)
: null;
case ScriptOutcome.Failed failed:
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
if (!IsRepeat(LastResult(job.Id), result))
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false);
if (!IsRepeat(LastResult(job.Id), result)
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
return result;
default:
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
@@ -375,17 +403,27 @@ public sealed class JobScheduler : IAsyncDisposable
private static bool IsRepeat(JobResult? previous, JobResult current) =>
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;
/// <summary>A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue).</summary>
private async Task DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
/// <summary>
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
/// after the next start (at-least-once).
/// </summary>
private async Task<bool> DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
{
try
{
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
return true;
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
{
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
return false;
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { }
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
return true;
}
}

View File

@@ -88,9 +88,8 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
{
var key = id?.Trim().ToLowerInvariant() ?? "";
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == key)?.Job;
if (job is null || !await scheduler.CancelAsync(target, key, cancellationToken).ConfigureAwait(false))
throw Error($"No job {key} in this chat.");
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
?? throw Error($"No job {key} in this chat.");
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
}

View File

@@ -38,19 +38,36 @@ public static partial class Schedules
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
/// <summary>ISO 8601. With an offset (or <c>Z</c>) it's used as given; without one it's wall-clock time in <paramref name="zone"/>.</summary>
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, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
if (HasOffset().IsMatch(trimmed))
{
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute)
? absolute
: throw new JobValidationException(hint);
}
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
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).
@@ -58,7 +75,19 @@ public static partial class Schedules
return new DateTimeOffset(wall, offset);
}
/// <summary>A time of day followed by <c>Z</c> or an offset. A bare date such as <c>2026-09-27</c> must not match (its <c>-27</c> isn't an offset).</summary>
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)]
private static partial Regex HasOffset();
/// <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();
}

View File

@@ -75,7 +75,8 @@ public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy p
Directory.CreateDirectory(spawn.Cwd);
process.Start();
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException)
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
{
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
}

View File

@@ -1,22 +1,22 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Zone ids and offsets for display and <c>TZ</c> (the extension can't reference the core's <c>TimeZoneIds</c>).</summary>
/// <summary>
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
internal static class ZoneNames
{
/// <summary>The IANA id (converting a Windows id), or null for an unnamed <c>Local</c> zone.</summary>
/// <summary>
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
/// </summary>
public static string? Iana(TimeZoneInfo zone)
{
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id;
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The IANA id when there is one, else the zone's own id.</summary>
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
/// <summary><c>UTC+09:00</c>.</summary>
public static string Offset(TimeSpan offset) =>
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", CultureInfo.InvariantCulture);
}