feat(scheduler): JobScheduler on TimeProvider with watcher, reminder and prompt firing

Spec: a skipped one-off fires after the next start; the late note's placement and no-exit-code failure wording are pinned down; IsDeliverable is part of the watcher invariant and must not create a session; saves snapshot inside a serialising gate.
This commit is contained in:
EugeneTes
2026-09-27 10:20:22 +02:00
parent 51bc0cedf1
commit 4bf99dc57c
8 changed files with 1145 additions and 9 deletions

View File

@@ -199,11 +199,11 @@ public interface IJobDelivery
Task SendTextAsync(string target, string markdown, CancellationToken ct);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
/// <summary>Working directory for the target's scripts.</summary>
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted.</summary>
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached.</summary>
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}
```
@@ -215,15 +215,16 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
| Kind | Action | Message format |
|---|---|---|
| reminder | `SendTextAsync` | `⏰ <text>` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>` |
| reminder | `SendTextAsync` | `⏰ <text>`; fired more than 60 s late: `⏰ <text> (due <d MMM HH:mm>, delivered late)` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>`; fired more than 60 s late, the note gets its own line after the header: `[Scheduled job "<name>" (<id>)]\nThis was due at <d MMM HH:mm>; the bot was offline.\n<text>` |
| watcher, matched (exit 0, non-empty stdout) | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout>` |
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only `LastRunAt`/`LastResult` are updated |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n```` |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n````; with no exit code it reads `failed (timed out)` or `failed (did not start)` |
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `WorkspaceFor` and `TimeZoneFor`.
- Watcher matches never get a late note (a watcher only runs when its occurrence comes due; there's no catch-up).
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `IsDeliverable`, `WorkspaceFor` and `TimeZoneFor`.
It never calls `RunPromptAsync` or `SendTextAsync`, and the Telegram delivery's `WorkspaceFor`/`TimeZoneFor` don't
create or look up a chat session. Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
create or look up a chat session (nor does `IsDeliverable`). Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
`Failed` → at most one `SendTextAsync` per failure streak, `Silent` → nothing.
- **Redaction.** A watcher's stdout goes through `redactor.Redact` before it's put into the prompt, and the stderr
tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known
@@ -237,7 +238,9 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
throws `ChatDeliveryException`. The job's state still advances: there's no redelivery queue.
- `CancelAsync` removes the job. If that job's watcher script is running it's killed and its outcome thrown away. A
prompt run that's in progress isn't aborted: that's what `/stop` is for.
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances).
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances); a skipped one-off
stays in the store and fires again after the next start.
- Saves are serialised and each one snapshots the jobs inside that gate, so a save never overwrites newer state.
- `TimeZoneChangedAsync(target)` recomputes `NextRunAt` for the target's cron jobs in the new zone (strictly after now,
no catch-up), leaves one-offs untouched, re-arms the timer and returns
`ZoneChange(int CronJobs, int OneOffs)`. A cron occurrence already in flight isn't affected.

View File

@@ -0,0 +1,20 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>What the host does for the scheduler. Passed to <see cref="JobScheduler.StartAsync"/>, not the constructor, to break the host's DI cycle.</summary>
public interface IJobDelivery
{
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken);
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}

View File

@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
public static class JobFormat
{
public static string Icon(JobKind kind) => kind switch { JobKind.Reminder => "⏰", JobKind.Prompt => "💬", _ => "🔍" };
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("d MMM HH:mm", CultureInfo.InvariantCulture);
/// <summary><c>14:10</c> when on the same local day as <paramref name="now"/>, else <see cref="DayTime"/>.</summary>
public static string When(DateTimeOffset at, TimeZoneInfo zone, DateTimeOffset now)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
return local.Date == TimeZoneInfo.ConvertTime(now, zone).Date
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
: DayTime(at, zone);
}
/// <summary><c>Sat 2026-09-26 14:03</c> (the model-facing form: weekday and full date).</summary>
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
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
{
{ ExitCode: { } code, TimedOut: true } => $"exit {code.ToString(CultureInfo.InvariantCulture)}, timed out",
{ ExitCode: { } code } => $"exit {code.ToString(CultureInfo.InvariantCulture)}",
{ TimedOut: true } => "timed out",
_ => "did not start",
};
/// <summary><c>`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
{
var job = view.Job;
var sb = new StringBuilder().Append('`').Append(job.Id).Append("` ").Append(Icon(job.Kind)).Append(' ').Append(job.Name);
if (job.Schedule.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
else
{
sb.Append(" · `").Append(job.Schedule.Cron).Append('`');
if (view.NextRunAt is { } next) sb.Append(" · next ").Append(When(next, zone, now));
}
if (job.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
return sb.ToString();
}
}

View File

@@ -0,0 +1,18 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The texts a fire sends (spec §4 table).</summary>
internal static class JobMessages
{
public static string Reminder(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"⏰ {job.Text}" + (lateDue is { } due ? $" (due {JobFormat.DayTime(due, zone)}, delivered late)" : "");
public static string Prompt(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"{Header(job)}\n" + (lateDue is { } due ? $"This was due at {JobFormat.DayTime(due, zone)}; the bot was offline.\n" : "") + job.Text;
public static string WatcherMatch(ScheduledJob job, string output) => $"{Header(job)} The watcher script printed:\n{output}";
public static string Failure(ScheduledJob job, JobResult result) =>
$"⚠️ Job \"{job.Name}\" ({job.Id}) failed ({JobFormat.FailureReason(result)}):\n```\n{(string.IsNullOrWhiteSpace(result.StderrTail) ? "(no output on stderr)" : result.StderrTail)}\n```";
private static string Header(ScheduledJob job) => $"[Scheduled job \"{job.Name}\" ({job.Id})]";
}

View File

@@ -0,0 +1,460 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// The process-wide scheduler: loads <c>jobs.json</c>, arms one <see cref="TimeProvider"/> timer for the soonest job
/// (capped at 1 h, re-armed on every tick, create, cancel, fire and zone change), and fires jobs through the host's
/// <see cref="IJobDelivery"/>. A fire is tracked in flight and never awaited by the tick; an occurrence that comes due
/// while the job is still in flight is skipped. A watcher's script run touches only the runner, <c>IsDeliverable</c>,
/// <c>WorkspaceFor</c> and <c>TimeZoneFor</c>: a silent run makes no delivery call at all.
/// </summary>
public sealed class JobScheduler : IAsyncDisposable
{
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
private readonly SchedulerOptions _options;
private readonly IScriptRunner _runner;
private readonly ISecretRedactor _redactor;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly JobStore _store;
private readonly SemaphoreSlim _saveGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<string, Entry> _jobs = new(StringComparer.Ordinal);
private readonly Dictionary<string, Fire> _inFlight = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _stopping = new();
private IJobDelivery? _delivery;
private ITimer? _timer;
private DateTimeOffset? _wakeAt;
private bool _stopped;
private Task _pendingTick = Task.CompletedTask;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.StorePath)) throw new ArgumentException("SchedulerOptions.StorePath is required.", nameof(options));
_options = options;
_runner = runner;
_redactor = redactor;
_time = time;
_logger = logger;
_store = new JobStore(options.StorePath, time, logger);
}
public bool IsStarted { get { lock (_lock) return _delivery is not null && !_stopped; } }
public DateTimeOffset Now => _time.GetUtcNow();
public string StorePath => _store.Path;
/// <summary>When the timer fires next (tests).</summary>
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
/// <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)
{
ArgumentNullException.ThrowIfNull(delivery);
var jobs = await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
var now = _time.GetUtcNow();
lock (_lock)
{
if (_delivery is not null || _stopped) throw new InvalidOperationException("The scheduler was already started.");
_delivery = delivery;
foreach (var job in jobs)
{
var entry = new Entry(job);
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);
}
_jobs[job.Id] = entry;
}
_timer = _time.CreateTimer(_ => OnTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
Tick(); // overdue one-offs fire now
}
/// <summary>Disarm, kill running scripts, and wait at most <see cref="StopGrace"/> for in-flight fires.</summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
Fire[] fires;
ITimer? timer;
lock (_lock)
{
if (_stopped) return;
_stopped = true;
fires = [.. _inFlight.Values];
timer = _timer;
}
timer?.Dispose();
await _stopping.CancelAsync().ConfigureAwait(false); // every fire's token is linked to this one
try
{
await Task.WhenAll(fires.Select(f => f.Task)).WaitAsync(StopGrace, _time, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.LogWarning("Scheduler stop: {Count} job(s) still running after {Grace}", fires.Count(f => !f.Task.IsCompleted), StopGrace);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public ValueTask DisposeAsync() => new(StopAsync(CancellationToken.None));
public IReadOnlyList<JobView> List(string target)
{
lock (_lock)
return _jobs.Values.Where(e => e.Job.Target == target)
.Select(e => new JobView(e.Job, e.Next))
.OrderBy(v => v.NextRunAt ?? DateTimeOffset.MaxValue).ThenBy(v => v.Job.CreatedAt)
.ToList();
}
public TimeZoneInfo TimeZoneFor(string target) => Volatile.Read(ref _delivery)?.TimeZoneFor(target) ?? TimeZoneInfo.Local;
/// <summary>Validate (an offset-less <c>at</c> is read in the target's zone), persist, arm. Throws <see cref="JobValidationException"/>.</summary>
public async Task<JobView> CreateAsync(string target, NewJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
if (!IsStarted) throw new InvalidOperationException("The scheduler isn't running.");
var zone = TimeZoneFor(target);
var now = _time.GetUtcNow();
var schedule = JobValidation.ValidateNew(job, now, zone);
ScheduledJob created;
DateTimeOffset? next;
lock (_lock)
{
if (_jobs.Values.Count(e => e.Job.Target == target) >= _options.MaxJobsPerTarget)
throw new JobValidationException($"This chat already has {_options.MaxJobsPerTarget} scheduled jobs, the maximum. Cancel one first.");
created = new ScheduledJob
{
Id = NewId(), Target = target, Name = job.Name.Trim(), Kind = job.Kind, Schedule = schedule,
Text = job.Kind == JobKind.Watcher ? null : job.Text, Command = job.Kind == JobKind.Watcher ? job.Command : null,
TimeoutSeconds = job.TimeoutSeconds, CreatedAt = now,
};
next = schedule.At ?? Schedules.NextCron(schedule.Cron!, now, zone);
_jobs[created.Id] = new Entry(created) { Next = next };
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return new JobView(created, next);
}
/// <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)
{
Fire? running;
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return false;
_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;
}
/// <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>
public Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken cancellationToken)
{
var now = _time.GetUtcNow();
var zone = TimeZoneFor(target);
int cron = 0, oneOffs = 0;
lock (_lock)
{
foreach (var entry in _jobs.Values.Where(e => e.Job.Target == target))
{
if (entry.Job.Schedule.Cron is { } expression)
{
cron++;
entry.Next = Schedules.NextCron(expression, now, zone);
}
else oneOffs++;
}
}
Arm();
return Task.FromResult(new ZoneChange(cron, oneOffs));
}
/// <summary>Wait for the pending tick and every fire in flight (tests).</summary>
internal async Task IdleAsync()
{
while (true)
{
Task tick;
Task[] fires;
lock (_lock)
{
tick = _pendingTick;
fires = _inFlight.Values.Select(f => f.Task).ToArray();
}
if (tick.IsCompleted && fires.All(f => f.IsCompleted)) return;
await Task.WhenAll(fires.Append(tick)).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
// ---- Timer and ticks ----
/// <summary>The timer callback: queues a tick behind the previous one (it may run synchronously inside <c>ITimer.Change</c>).</summary>
private void OnTimer()
{
lock (_lock)
{
if (_stopped) return;
_pendingTick = _pendingTick.ContinueWith(_ => Tick(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
}
/// <summary>Starts every due job that isn't in flight, advances each due job's next occurrence, re-arms. Never awaits a fire.</summary>
private void Tick()
{
try
{
var now = _time.GetUtcNow();
lock (_lock)
{
if (_stopped || _delivery is null) return;
foreach (var entry in _jobs.Values)
{
if (entry.Next is not { } next || next > now) continue;
if (_inFlight.ContainsKey(entry.Job.Id))
_logger.LogInformation("Job {Id} ({Name}) is still running; skipped the occurrence due {Due}", entry.Job.Id, entry.Job.Name, next);
else StartFire(entry.Job, next, now);
// Cron: the next occurrence strictly after now. One-off: none (it's removed once its fire completes).
entry.Next = null;
if (entry.Job.Schedule.Cron is null) continue;
try
{
entry.Next = NextCron(entry.Job, now);
}
catch (Exception ex)
{
// Left unarmed rather than due forever (a zero-delay timer loop); a restart or zone change recomputes it.
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence failed", entry.Job.Id, entry.Job.Name);
}
}
}
}
finally
{
Arm();
}
}
private void Arm()
{
ITimer timer;
TimeSpan delay;
lock (_lock)
{
if (_stopped || _timer is null) return;
var now = _time.GetUtcNow();
var soonest = _jobs.Values.Select(e => e.Next).Where(n => n is not null).DefaultIfEmpty(null).Min();
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.
}
}
/// <summary>Under the lock. The next cron occurrence strictly after <paramref name="after"/> in the target's current zone.</summary>
private DateTimeOffset? NextCron(ScheduledJob job, DateTimeOffset after) =>
Schedules.NextCron(job.Schedule.Cron!, after, _delivery!.TimeZoneFor(job.Target));
// ---- Firing ----
/// <summary>Called under the lock: registers the fire in flight before it can complete.</summary>
private void StartFire(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now)
{
var cancel = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
_inFlight[job.Id] = new Fire(task, cancel);
gate.SetResult();
}
private async Task RunFireAsync(Task gate, ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationTokenSource cancel)
{
await gate.ConfigureAwait(false);
try
{
await FireAsync(job, dueAt, now, cancel.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}) failed to fire", job.Id, job.Name);
}
finally
{
lock (_lock) _inFlight.Remove(job.Id);
cancel.Dispose();
}
}
private async Task FireAsync(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationToken scriptToken)
{
var delivery = _delivery!;
if (!delivery.IsDeliverable(job.Target))
{
// Kept: a cron job has already advanced; a one-off fires again after the next start.
_logger.LogWarning("Job {Id} ({Name}): target {Target} can't be served any more; skipped", job.Id, job.Name, job.Target);
return;
}
var zone = delivery.TimeZoneFor(job.Target);
DateTimeOffset? lateDue = now - dueAt > LateThreshold ? dueAt : null;
JobResult? result;
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());
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());
break;
default:
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
break;
}
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>
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone));
ScriptOutcome outcome;
try
{
outcome = await _runner.RunAsync(request, scriptToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (scriptToken.IsCancellationRequested)
{
return null;
}
if (scriptToken.IsCancellationRequested || !Exists(job.Id)) return null;
var at = _time.GetUtcNow();
switch (outcome)
{
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);
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);
return result;
default:
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
}
}
/// <summary>The previous run failed the same way (exit code and timeout): the alert isn't sent again.</summary>
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)
{
try
{
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { }
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
}
}
/// <summary>Update and persist the run fields; a one-off is removed now that its fire has completed.</summary>
private async Task RecordAsync(string id, JobResult result)
{
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry)) return; // cancelled meanwhile
if (entry.Job.Schedule.At is not null) _jobs.Remove(id);
else entry.Job = entry.Job with { LastRunAt = result.At, LastResult = result };
}
try
{
await PersistAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Saving the job store failed");
}
Arm();
}
/// <summary>Saves the current jobs. The snapshot is taken inside the gate, so a later save never writes older state.</summary>
private async Task PersistAsync(CancellationToken cancellationToken)
{
await _saveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<ScheduledJob> snapshot;
lock (_lock) snapshot = [.. _jobs.Values.Select(e => e.Job).OrderBy(j => j.CreatedAt).ThenBy(j => j.Id, StringComparer.Ordinal)];
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
}
finally
{
_saveGate.Release();
}
}
private static async Task TryCancelAsync(CancellationTokenSource cancel)
{
try
{
await cancel.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// The fire completed meanwhile.
}
}
private bool Exists(string id) { lock (_lock) return _jobs.ContainsKey(id); }
private JobResult? LastResult(string id) { lock (_lock) return _jobs.TryGetValue(id, out var e) ? e.Job.LastResult : null; }
/// <summary>Under the lock. Not reused while a cancelled job with that id is still in flight.</summary>
private string NewId()
{
string id;
do id = new string(Random.Shared.GetItems(IdAlphabet.AsSpan(), 5));
while (_jobs.ContainsKey(id) || _inFlight.ContainsKey(id));
return id;
}
private sealed class Entry(ScheduledJob job)
{
public ScheduledJob Job { get; set; } = job;
public DateTimeOffset? Next { get; set; }
}
private sealed record Fire(Task Task, CancellationTokenSource Cancel);
}

View File

@@ -0,0 +1,40 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobFormatTests
{
private static readonly DateTimeOffset Now = SchedulerHarness.Start; // Sat 26 Sep 14:03 Berlin
private static readonly TimeZoneInfo Berlin = SchedulerHarness.Berlin;
private static ScheduledJob Job(string id, string name, JobKind kind, JobSchedule schedule) => new()
{
Id = id, Target = "111", Name = name, Kind = kind, Schedule = schedule, CreatedAt = Now,
Text = kind == JobKind.Watcher ? null : name, Command = kind == JobKind.Watcher ? "check" : null,
};
[Fact]
public void Job_lines_show_kind_schedule_next_and_last_failure()
{
var once = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal("`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00",
JobFormat.Line(new JobView(Job("k7m2q", "Call the dentist", JobKind.Reminder, new JobSchedule(once, null)), once), Berlin, Now));
Assert.Equal("`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 28 Sep 08:00",
JobFormat.Line(new JobView(Job("p3xz9", "Inbox summary", JobKind.Prompt, new JobSchedule(null, "0 8 * * 1-5")), new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero)), Berlin, Now));
var failing = Job("w8r4t", "Price check", JobKind.Watcher, new JobSchedule(null, "*/10 * * * *")) with { LastResult = new JobResult(false, 1, false, "x", Now) };
Assert.Equal("`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)",
JobFormat.Line(new JobView(failing, new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero)), Berlin, Now));
}
[Fact]
public void When_omits_the_date_today()
{
Assert.Equal("14:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Berlin, Now));
Assert.Equal("27 Sep 00:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 22, 10, 0, TimeSpan.Zero), Berlin, Now));
Assert.Equal("Sat 2026-09-26 14:03", JobFormat.Stamp(Now, Berlin));
Assert.Equal("UTC+02:00", JobFormat.Offset(Berlin, Now));
Assert.Equal("exit 1, timed out", JobFormat.FailureReason(new JobResult(false, 1, true, null, Now)));
Assert.Equal("did not start", JobFormat.FailureReason(new JobResult(false, null, false, "failed to start", Now)));
}
}

View File

@@ -0,0 +1,384 @@
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
public sealed class JobSchedulerTests
{
private static readonly TimeSpan Minute = TimeSpan.FromMinutes(1);
private static readonly DateTimeOffset Start = SchedulerHarness.Start; // 12:03Z = Sat 14:03 Berlin
private static ScheduledJob Stored(string id, JobKind kind, JobSchedule schedule, string? text = "Call the dentist", string target = "111") => new()
{
Id = id, Target = target, Name = text ?? "Watch", Kind = kind, Schedule = schedule, CreatedAt = Start.AddDays(-1),
Text = kind == JobKind.Watcher ? null : text, Command = kind == JobKind.Watcher ? "scripts/check.sh" : null,
};
private static NewJob Watcher(string cron = "* * * * *") => new("Price check", JobKind.Watcher, Cron: cron, Command: "scripts/price.sh");
[Fact]
public async Task An_overdue_one_off_fires_on_start_with_the_late_note_and_is_removed_after_delivery()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
h.Delivery.HoldTexts = new TaskCompletionSource();
await h.StartAsync();
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
Assert.Single(await h.Store.LoadAsync(CancellationToken.None)); // not removed while the delivery is in progress
h.Delivery.HoldTexts.SetResult();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist (due 26 Sep 13:58, delivered late)"), Assert.Single(h.Delivery.Texts));
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
public async Task An_overdue_one_off_within_60_s_has_no_late_note()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
}
[Fact]
public async Task An_overdue_prompt_gets_the_offline_note()
{
await using var h = new SchedulerHarness();
await h.Store.SaveAsync([Stored("p3xz9", JobKind.Prompt, new JobSchedule(Start.AddHours(-1), null), text: "Summarise inbox/")], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Equal("[Scheduled job \"Summarise inbox/\" (p3xz9)]\nThis was due at 26 Sep 13:03; the bot was offline.\nSummarise inbox/",
Assert.Single(h.Delivery.Prompts).Prompt);
}
[Fact]
public async Task Cron_does_not_catch_up_after_downtime()
{
await using var h = new SchedulerHarness();
var job = Stored("c1c1c", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")) with { LastRunAt = Start.AddHours(-3) };
await h.Store.SaveAsync([job], CancellationToken.None);
await h.StartAsync();
await h.Scheduler.IdleAsync();
Assert.Empty(h.Delivery.Texts);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
}
[Fact]
public async Task Advancing_the_clock_fires_and_the_1_h_cap_rearms_without_firing()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Later", JobKind.Reminder, At: "2026-09-26T17:03", Text: "Later")); // 15:03Z, 3 h away
Assert.Equal(Start.AddHours(1), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Empty(h.Delivery.Texts);
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
await h.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Equal(("111", "⏰ Later"), Assert.Single(h.Delivery.Texts));
Assert.Empty(h.Scheduler.List("111"));
}
[Fact]
public async Task A_reminder_is_sent_as_text_and_a_prompt_as_a_prompt()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Stretch", JobKind.Reminder, At: "2026-09-26T14:04", Text: "Stretch"));
var prompt = await h.CreateAsync(new NewJob("Summary", JobKind.Prompt, At: "2026-09-26T14:04", Text: "Summarise inbox/"));
await h.AdvanceAsync(Minute);
Assert.Equal(("111", "⏰ Stretch"), Assert.Single(h.Delivery.Texts));
Assert.Equal(("111", $"[Scheduled job \"Summary\" ({prompt.Job.Id})]\nSummarise inbox/"), Assert.Single(h.Delivery.Prompts));
}
[Fact]
public async Task A_silent_watcher_makes_no_delivery_calls_and_only_updates_its_run_fields()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var created = (await h.CreateAsync(Watcher())).Job;
h.Runner.Then(Enumerable.Repeat<ScriptOutcome>(new ScriptOutcome.Silent(), 10).Append(new ScriptOutcome.Output("price 48")).ToArray());
for (var i = 0; i < 10; i++) await h.AdvanceAsync(Minute);
Assert.Equal(10, h.Runner.Requests.Count);
Assert.Empty(h.Delivery.Prompts);
Assert.Empty(h.Delivery.Texts);
Assert.DoesNotContain(h.Delivery.Calls, c => c.StartsWith("RunPrompt", StringComparison.Ordinal) || c.StartsWith("SendText", StringComparison.Ordinal));
var stored = Assert.Single(await h.Store.LoadAsync(CancellationToken.None));
Assert.Equal(created, stored with { LastRunAt = null, LastResult = null }); // nothing but the run fields changed
Assert.Equal(Start.AddMinutes(10), stored.LastRunAt);
Assert.True(stored.LastResult!.Ok);
await h.AdvanceAsync(Minute);
Assert.Equal(("111", $"[Scheduled job \"Price check\" ({created.Id})] The watcher script printed:\nprice 48"), Assert.Single(h.Delivery.Prompts));
Assert.Empty(h.Delivery.Texts);
}
[Fact]
public async Task A_failure_is_reported_once_per_streak()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var id = (await h.CreateAsync(Watcher())).Job.Id;
h.Runner.Then(
new ScriptOutcome.Failed(1, "boom", false),
new ScriptOutcome.Failed(1, "boom again", false),
new ScriptOutcome.Failed(2, "other", false),
new ScriptOutcome.Failed(null, "slow", true),
new ScriptOutcome.Silent(),
new ScriptOutcome.Failed(2, "back", false));
var counts = new List<int>();
for (var i = 0; i < 6; i++)
{
await h.AdvanceAsync(Minute);
counts.Add(h.Delivery.Texts.Count);
}
Assert.Equal([1, 1, 2, 3, 3, 4], counts);
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (exit 1):\n```\nboom\n```", h.Delivery.Texts[0].Text);
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (timed out):\n```\nslow\n```", h.Delivery.Texts[2].Text);
Assert.Empty(h.Delivery.Prompts); // failures never go through the model
}
[Fact]
public async Task Overlapping_occurrences_are_skipped_while_a_watcher_runs()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the first run");
h.Time.Advance(Minute);
h.Time.Advance(Minute);
await Task.Delay(100);
Assert.Single(h.Runner.Requests);
h.Runner.Hold.SetResult();
h.Runner.Hold = null;
await h.Scheduler.IdleAsync();
await h.AdvanceAsync(Minute);
Assert.Equal(2, h.Runner.Requests.Count);
}
[Fact]
public async Task Overlapping_occurrences_are_skipped_while_a_prompt_run_is_held_open()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Every minute", JobKind.Prompt, Cron: "* * * * *", Text: "tick"));
h.Delivery.HoldPrompts = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt");
h.Time.Advance(Minute);
await Task.Delay(100);
Assert.Single(h.Delivery.Prompts);
h.Delivery.HoldPrompts.SetResult();
h.Delivery.HoldPrompts = null;
await h.Scheduler.IdleAsync();
await h.AdvanceAsync(Minute);
Assert.Equal(2, h.Delivery.Prompts.Count);
}
[Fact]
public async Task Targets_are_isolated_in_List_and_CancelAsync()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var mine = (await h.CreateAsync(new NewJob("Mine", JobKind.Reminder, Cron: "@daily", Text: "a"), "111")).Job;
var theirs = (await h.CreateAsync(new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "b"), "222")).Job;
Assert.Equal([mine.Id], h.Scheduler.List("111").Select(v => v.Job.Id));
Assert.False(await h.Scheduler.CancelAsync("222", mine.Id, CancellationToken.None));
Assert.True(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
Assert.False(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
Assert.Equal([theirs.Id], (await h.Store.LoadAsync(CancellationToken.None)).Select(j => j.Id));
}
[Fact]
public async Task Cancel_kills_a_running_watcher_and_drops_its_output()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
var id = (await h.CreateAsync(Watcher())).Job.Id;
h.Runner.Fallback = new ScriptOutcome.Output("late output");
h.Runner.IgnoreCancellation = true; // the outcome still arrives after the cancel; it must be thrown away
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
Assert.True(await h.Scheduler.CancelAsync("111", id, CancellationToken.None));
Assert.Equal(1, h.Runner.CancellationsSeen);
h.Runner.Hold.SetResult();
await h.Scheduler.IdleAsync();
Assert.Empty(h.Delivery.Prompts);
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
}
[Fact]
public async Task An_undeliverable_target_is_skipped_and_its_job_kept()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "ping"));
await h.CreateAsync(new NewJob("Once", JobKind.Reminder, At: "2026-09-26T14:30", Text: "once"));
h.Delivery.Undeliverable.Add("111");
await h.AdvanceAsync(TimeSpan.FromMinutes(57)); // past 14:30 and 15:00 Berlin
Assert.Empty(h.Delivery.Texts);
var jobs = h.Scheduler.List("111");
Assert.Equal(2, jobs.Count);
Assert.Equal(new DateTimeOffset(2026, 9, 26, 14, 0, 0, TimeSpan.Zero), jobs.Single(j => j.Job.Name == "Hourly").NextRunAt); // advanced
Assert.Equal(2, (await h.Store.LoadAsync(CancellationToken.None)).Count);
}
[Fact]
public async Task StopAsync_kills_scripts_and_returns()
{
var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(1, h.Runner.CancellationsSeen);
await h.DisposeAsync();
}
[Fact]
public async Task StopAsync_gives_up_after_the_grace_period()
{
var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.IgnoreCancellation = true;
h.Runner.Hold = new TaskCompletionSource();
h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
var stop = h.Scheduler.StopAsync(CancellationToken.None);
await Task.Delay(100);
Assert.False(stop.IsCompleted);
h.Time.Advance(TimeSpan.FromSeconds(10));
await stop.WaitAsync(TimeSpan.FromSeconds(5));
h.Runner.Hold.SetResult();
await h.DisposeAsync();
}
[Fact]
public async Task A_zone_change_recomputes_cron_and_leaves_one_offs()
{
var start = new DateTimeOffset(2026, 9, 26, 23, 30, 0, TimeSpan.Zero); // Sun 01:30 Berlin, Sun 08:30 Tokyo
await using var h = new SchedulerHarness(start);
await h.StartAsync();
var cron = (await h.CreateAsync(new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"))).Job;
var once = (await h.CreateAsync(new NewJob("Dentist", JobKind.Reminder, At: "2026-09-27T10:00", Text: "Dentist"))).Job;
var other = (await h.CreateAsync(new NewJob("Other", JobKind.Reminder, Cron: "0 9 * * *", Text: "x"), "222")).Job;
var berlinNine = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
var change = await h.Scheduler.TimeZoneChangedAsync("111", CancellationToken.None);
Assert.Equal(new ZoneChange(1, 1), change);
var views = h.Scheduler.List("111").ToDictionary(v => v.Job.Id);
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), views[cron.Id].NextRunAt); // 09:00 Tokyo
Assert.Equal(new DateTimeOffset(2026, 9, 27, 8, 0, 0, TimeSpan.Zero), views[once.Id].NextRunAt); // unchanged instant
Assert.Equal(berlinNine, Assert.Single(h.Scheduler.List("222")).NextRunAt);
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), h.Scheduler.NextWakeAt); // re-armed
Assert.Equal(other.Id, h.Scheduler.List("222")[0].Job.Id);
}
[Fact]
public async Task Watcher_output_and_failure_tails_are_redacted()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(Watcher());
h.Runner.Then(new ScriptOutcome.Output("token=hunter2-secret"), new ScriptOutcome.Failed(1, "auth hunter2-secret rejected", false));
await h.AdvanceAsync(Minute);
await h.AdvanceAsync(Minute);
var prompt = Assert.Single(h.Delivery.Prompts).Prompt;
Assert.EndsWith("token=[REDACTED]", prompt);
var alert = Assert.Single(h.Delivery.Texts).Text;
Assert.Contains("auth [REDACTED] rejected", alert);
Assert.DoesNotContain("hunter2", prompt + alert);
Assert.Equal("auth [REDACTED] rejected", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).LastResult!.StderrTail);
}
[Fact]
public async Task Delivery_failures_are_logged_and_the_job_still_advances()
{
await using var h = new SchedulerHarness();
await h.StartAsync();
await h.CreateAsync(new NewJob("Tick", JobKind.Reminder, Cron: "* * * * *", Text: "tick"));
h.Delivery.FailSends = new InvalidOperationException("Telegram is down");
await h.AdvanceAsync(Minute);
var view = Assert.Single(h.Scheduler.List("111"));
Assert.Equal(Start.AddMinutes(1), view.Job.LastRunAt);
Assert.Equal(Start.AddMinutes(2), view.NextRunAt);
}
[Fact]
public async Task CreateAsync_validates_and_caps_jobs_per_target()
{
await using var h = new SchedulerHarness(configure: o => o.MaxJobsPerTarget = 2);
await h.StartAsync();
await h.CreateAsync(new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "a"));
await h.CreateAsync(new NewJob("b", JobKind.Reminder, Cron: "@daily", Text: "b"));
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c")));
Assert.Equal("This chat already has 2 scheduled jobs, the maximum. Cancel one first.", full.Message);
await h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Reminder, Cron: "0 0 9 * * *", Text: "x"), "333"));
var ids = h.Scheduler.List("111").Select(v => v.Job.Id).ToList();
Assert.All(ids, id => Assert.Matches("^[abcdefghjkmnpqrstuvwxyz23456789]{5}$", id));
}
[Fact]
public async Task Watcher_runs_with_the_target_workspace_zone_and_timeout()
{
await using var h = new SchedulerHarness();
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
await h.StartAsync();
var job = (await h.CreateAsync(Watcher())).Job;
await h.CreateAsync(new NewJob("Quick", JobKind.Watcher, Cron: "* * * * *", Command: "true", TimeoutSeconds: 5), "222");
await h.AdvanceAsync(Minute);
var requests = h.Runner.Requests.OrderBy(r => r.Target).ToList();
Assert.Equal(new ScriptRequest("scripts/price.sh", Path.Combine(h.Delivery.WorkspaceRoot, "111"), TimeSpan.FromSeconds(300), "111", job.Id, "Asia/Tokyo"), requests[0]);
Assert.Equal(TimeSpan.FromSeconds(5), requests[1].Timeout);
Assert.Equal("Europe/Berlin", requests[1].TimeZoneId);
}
}

View File

@@ -0,0 +1,155 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
/// <summary>Records every delivery call; can hold sends and prompt runs open, fail sends, and switch a target's zone.</summary>
internal sealed class FakeJobDelivery : IJobDelivery
{
private readonly Lock _lock = new();
private readonly List<(string Target, string Text)> _texts = [];
private readonly List<(string Target, string Prompt)> _prompts = [];
private readonly List<string> _calls = [];
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public HashSet<string> Undeliverable { get; } = [];
public TaskCompletionSource? HoldTexts { get; set; }
public TaskCompletionSource? HoldPrompts { get; set; }
public Exception? FailSends { get; set; }
public string WorkspaceRoot { get; set; } = Path.Combine(Path.GetTempPath(), "fake-workspaces");
public IReadOnlyList<(string Target, string Text)> Texts { get { lock (_lock) return _texts.ToList(); } }
public IReadOnlyList<(string Target, string Prompt)> Prompts { get { lock (_lock) return _prompts.ToList(); } }
/// <summary>"SendText:111", "RunPrompt:111", "WorkspaceFor:111", "TimeZoneFor:111", "IsDeliverable:111".</summary>
public IReadOnlyList<string> Calls { get { lock (_lock) return _calls.ToList(); } }
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("SendText:" + target); _texts.Add((target, markdown)); }
if (HoldTexts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
}
public string WorkspaceFor(string target)
{
lock (_lock) _calls.Add("WorkspaceFor:" + target);
return Path.Combine(WorkspaceRoot, target);
}
public bool IsDeliverable(string target)
{
lock (_lock) _calls.Add("IsDeliverable:" + target);
return !Undeliverable.Contains(target);
}
public TimeZoneInfo TimeZoneFor(string target)
{
lock (_lock) _calls.Add("TimeZoneFor:" + target);
return Zones.GetValueOrDefault(target, DefaultZone);
}
}
/// <summary>Returns queued outcomes (then <see cref="Fallback"/>); can hold runs open, honouring or ignoring cancellation.</summary>
internal sealed class FakeScriptRunner : IScriptRunner
{
private readonly Lock _lock = new();
private readonly Queue<ScriptOutcome> _outcomes = new();
private readonly List<ScriptRequest> _requests = [];
private int _cancellations;
public ScriptOutcome Fallback { get; set; } = new ScriptOutcome.Silent();
public TaskCompletionSource? Hold { get; set; }
public bool IgnoreCancellation { get; set; }
public IReadOnlyList<ScriptRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public int CancellationsSeen => Volatile.Read(ref _cancellations);
public FakeScriptRunner Then(params ScriptOutcome[] outcomes)
{
lock (_lock) foreach (var o in outcomes) _outcomes.Enqueue(o);
return this;
}
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
ScriptOutcome outcome;
lock (_lock)
{
_requests.Add(request);
outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() : Fallback;
}
if (Hold is { } hold)
{
if (IgnoreCancellation)
{
using var _ = cancellationToken.Register(() => Interlocked.Increment(ref _cancellations));
await hold.Task;
}
else
{
try { await hold.Task.WaitAsync(cancellationToken); }
catch (OperationCanceledException) { Interlocked.Increment(ref _cancellations); throw; }
}
}
return outcome;
}
}
internal sealed class FakeRedactor(params string[] secrets) : ISecretRedactor
{
public string Redact(string text) => secrets.Aggregate(text, (t, s) => t.Replace(s, "[REDACTED]", StringComparison.Ordinal));
}
/// <summary>A <see cref="JobScheduler"/> on a <see cref="FakeTimeProvider"/> at Sat 2026-09-26 14:03 Berlin, a temp store and the fakes.</summary>
internal sealed class SchedulerHarness : IAsyncDisposable
{
public static readonly DateTimeOffset Start = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
public SchedulerHarness(DateTimeOffset? start = null, Action<SchedulerOptions>? configure = null)
{
Time = new FakeTimeProvider(start ?? Start);
Options = new SchedulerOptions { StorePath = Path.Combine(Root, "scheduler", "jobs.json") };
configure?.Invoke(Options);
Delivery.WorkspaceRoot = Path.Combine(Root, "workspaces");
Scheduler = new JobScheduler(Options, Runner, Redactor, Time, NullLogger<JobScheduler>.Instance);
}
public string Root { get; } = Directory.CreateTempSubdirectory("scheduler-").FullName;
public FakeTimeProvider Time { get; }
public SchedulerOptions Options { get; }
public FakeJobDelivery Delivery { get; } = new();
public FakeScriptRunner Runner { get; } = new();
public FakeRedactor Redactor { get; } = new("hunter2-secret");
public JobScheduler Scheduler { get; }
public JobStore Store => new(Options.StorePath!, Time, NullLogger.Instance);
public Task StartAsync() => Scheduler.StartAsync(Delivery, CancellationToken.None);
public Task<JobView> CreateAsync(NewJob job, string target = "111") => Scheduler.CreateAsync(target, job, CancellationToken.None);
/// <summary>Advance the clock and wait for the tick and every fire it started (don't use while a fire is held open).</summary>
public async Task AdvanceAsync(TimeSpan by)
{
Time.Advance(by);
await Scheduler.IdleAsync();
}
public async ValueTask DisposeAsync()
{
Runner.Hold?.TrySetResult();
Delivery.HoldTexts?.TrySetResult();
Delivery.HoldPrompts?.TrySetResult();
await Scheduler.StopAsync(CancellationToken.None);
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
}
}