forked from admin/VolcanicAgenticCore
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the scheduler loads every chat's file, moves the retired shared <agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>. A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted in place for that chat; the original is kept as .secretary-<ts>. Also adds the VolcanicSecretary data migration plan.
466 lines
22 KiB
C#
466 lines
22 KiB
C#
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.Prompt, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
|
|
h.Delivery.HoldPrompts = new TaskCompletionSource();
|
|
|
|
await h.StartAsync();
|
|
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the prompt run");
|
|
Assert.Single(await h.Store.LoadAsync(CancellationToken.None)); // not removed while the delivery is in progress
|
|
|
|
h.Delivery.HoldPrompts.SetResult();
|
|
await h.Scheduler.IdleAsync();
|
|
|
|
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nThis was due at 26 Sep 13:58; the bot was offline.\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
|
|
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.Prompt, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
|
|
await h.StartAsync();
|
|
await h.Scheduler.IdleAsync();
|
|
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
|
|
}
|
|
|
|
[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.Prompt, 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.Prompts);
|
|
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.Prompt, 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.Prompts);
|
|
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
|
|
await h.AdvanceAsync(TimeSpan.FromHours(1));
|
|
Assert.Empty(h.Delivery.Prompts);
|
|
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
|
|
await h.AdvanceAsync(TimeSpan.FromHours(1));
|
|
Assert.Equal("Later", Assert.Single(h.Delivery.Prompts).Prompt.Split('\n')[^1]);
|
|
Assert.Empty(h.Scheduler.List("111"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_prompt_runs_through_the_agent_and_sends_no_text()
|
|
{
|
|
await using var h = new SchedulerHarness();
|
|
await h.StartAsync();
|
|
var prompt = await h.CreateAsync(new NewJob("Summary", JobKind.Prompt, At: "2026-09-26T14:04", Text: "Summarise inbox/"));
|
|
|
|
await h.AdvanceAsync(Minute);
|
|
|
|
Assert.Empty(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);
|
|
await h.Scheduler.PendingTick;
|
|
h.Time.Advance(Minute);
|
|
await h.Scheduler.PendingTick;
|
|
Assert.Equal(1, h.Scheduler.FiresStarted); // both occurrences were skipped by their ticks
|
|
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 h.Scheduler.PendingTick;
|
|
Assert.Equal(1, h.Scheduler.FiresStarted);
|
|
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.Prompt, Cron: "@daily", Text: "a"), "111")).Job;
|
|
var theirs = (await h.CreateAsync(new NewJob("Theirs", JobKind.Prompt, 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.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
|
Assert.Equal([theirs.Id], (await h.StoreFor("222").LoadAsync(CancellationToken.None)).Select(j => j.Id)); // each target has its own file
|
|
}
|
|
|
|
[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.Prompt, Cron: "0 * * * *", Text: "ping"));
|
|
await h.CreateAsync(new NewJob("Once", JobKind.Prompt, 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.Prompts);
|
|
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 stoppedAt = h.Time.GetUtcNow();
|
|
var stop = h.Scheduler.StopAsync(CancellationToken.None);
|
|
Assert.False(stop.IsCompleted); // waiting on the fake clock's grace period
|
|
// Second by second: the grace timer may be created after the first advances.
|
|
await Eventually.UntilAsync(() => { h.Time.Advance(TimeSpan.FromSeconds(1)); return stop.IsCompleted; }, "the stop after the grace period");
|
|
Assert.True(h.Time.GetUtcNow() - stoppedAt >= JobScheduler.StopGrace);
|
|
await stop;
|
|
|
|
h.Runner.Hold.SetResult();
|
|
await h.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stored_job_whose_zone_lookup_throws_does_not_stop_the_start()
|
|
{
|
|
await using var h = new SchedulerHarness();
|
|
await h.StoreFor("not-a-chat").SaveAsync([Stored("bad11", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat")], CancellationToken.None);
|
|
await h.Store.SaveAsync([Stored("good1", JobKind.Prompt, new JobSchedule(null, "*/10 * * * *"))], CancellationToken.None);
|
|
h.Delivery.BadTargets.Add("not-a-chat");
|
|
|
|
await h.StartAsync();
|
|
|
|
Assert.True(h.Scheduler.IsStarted);
|
|
Assert.Null(Assert.Single(h.Scheduler.List("not-a-chat")).NextRunAt); // kept, unarmed
|
|
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
|
|
await h.AdvanceAsync(TimeSpan.FromMinutes(7));
|
|
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (good1)]\nCall the dentist"), Assert.Single(h.Delivery.Prompts));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_one_off_whose_delivery_is_cut_off_by_shutdown_is_kept_and_fires_after_the_next_start()
|
|
{
|
|
var h = new SchedulerHarness();
|
|
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Prompt, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
|
|
h.Delivery.HoldPrompts = new TaskCompletionSource(); // the prompt run is in flight when the bot stops
|
|
await h.StartAsync();
|
|
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the prompt run");
|
|
|
|
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Equal("k7m2q", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).Id);
|
|
var delivery = new FakeJobDelivery { WorkspaceRoot = h.Delivery.WorkspaceRoot };
|
|
await using (var restarted = new JobScheduler(h.Options, h.Runner, h.Redactor, h.Time, Microsoft.Extensions.Logging.Abstractions.NullLogger<JobScheduler>.Instance))
|
|
{
|
|
await restarted.StartAsync(delivery, CancellationToken.None);
|
|
await restarted.IdleAsync();
|
|
}
|
|
Assert.Equal(("111", "[Scheduled job \"Call the dentist\" (k7m2q)]\nCall the dentist"), Assert.Single(delivery.Prompts));
|
|
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
|
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.Prompt, Cron: "0 9 * * *", Text: "Good morning"))).Job;
|
|
var once = (await h.CreateAsync(new NewJob("Dentist", JobKind.Prompt, At: "2026-09-27T10:00", Text: "Dentist"))).Job;
|
|
var other = (await h.CreateAsync(new NewJob("Other", JobKind.Prompt, 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.Prompt, 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.Prompt, Cron: "@daily", Text: "a"));
|
|
await h.CreateAsync(new NewJob("b", JobKind.Prompt, Cron: "@daily", Text: "b"));
|
|
|
|
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Prompt, 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.Prompt, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
|
|
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Prompt, 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_shared_file_is_moved_into_each_targets_file_on_start()
|
|
{
|
|
await using var h = new SchedulerHarness();
|
|
await h.StoreFor("222").SaveAsync([Stored("both1", JobKind.Prompt, new JobSchedule(null, "@daily"), target: "222")], CancellationToken.None);
|
|
await h.LegacyStore.SaveAsync([
|
|
Stored("mine1", JobKind.Prompt, new JobSchedule(null, "@daily")),
|
|
Stored("their", JobKind.Prompt, new JobSchedule(null, "@daily"), target: "222"),
|
|
Stored("both1", JobKind.Prompt, new JobSchedule(null, "@daily"), target: "222"), // already moved by an interrupted start
|
|
], CancellationToken.None);
|
|
|
|
await h.StartAsync();
|
|
|
|
Assert.Equal(["mine1"], (await h.Store.LoadAsync(CancellationToken.None)).Select(j => j.Id));
|
|
Assert.Equal(["both1", "their"], (await h.StoreFor("222").LoadAsync(CancellationToken.None)).Select(j => j.Id).Order());
|
|
Assert.Equal(3, h.Scheduler.Count);
|
|
Assert.False(File.Exists(h.Options.LegacyStorePath));
|
|
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(h.Options.LegacyStorePath)!, "jobs.json.migrated-*"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_id_taken_by_another_target_is_renamed_on_start()
|
|
{
|
|
await using var h = new SchedulerHarness();
|
|
await h.Store.SaveAsync([Stored("same1", JobKind.Prompt, new JobSchedule(null, "@daily"))], CancellationToken.None);
|
|
await h.StoreFor("222").SaveAsync([Stored("same1", JobKind.Prompt, new JobSchedule(null, "@daily"), target: "222")], CancellationToken.None);
|
|
|
|
await h.StartAsync();
|
|
|
|
var ids = new[] { "111", "222" }.Select(t => Assert.Single(h.Scheduler.List(t)).Job.Id).ToList();
|
|
Assert.Equal(2, ids.Distinct().Count());
|
|
Assert.Contains("same1", ids);
|
|
var stored = (await h.Store.LoadAsync(CancellationToken.None)).Concat(await h.StoreFor("222").LoadAsync(CancellationToken.None)).Select(j => j.Id);
|
|
Assert.Equal(ids.Order(), stored.Order()); // the new id is saved
|
|
}
|
|
}
|