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

67 lines
3.8 KiB
C#

using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The one path for a chat's zone change (<c>/timezone</c> and <c>set_timezone</c>): store it, apply it to the live
/// session (from its next run), re-evaluate the chat's cron jobs, and word the reply. <paramref name="liveSession"/>
/// finds the chat's session without creating one; it's a function so DI has no ChatRegistry ↔ service cycle.
/// </summary>
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
{
/// <summary>Set (<paramref name="zone"/>) or reset (null) the chat's zone and return the confirmation.</summary>
public async Task<string> SetAsync(long chatId, TimeZoneInfo? zone, CancellationToken cancellationToken)
{
await store.SetTimeZoneAsync(chatId, zone is null ? null : TimeZoneIds.Name(zone), cancellationToken).ConfigureAwait(false);
var effective = store.TimeZoneFor(chatId);
liveSession(chatId)?.SetTimeZone(effective);
var now = time.GetUtcNow();
var text = new StringBuilder(zone is null
? $"Time zone reset to the default, {Describe(effective, now)}."
: $"Time zone set to {Describe(effective, now)}.");
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
if (scheduler is { IsStarted: true })
{
var target = TelegramJobDelivery.TargetOf(chatId);
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
text.Append(JobClause(change, scheduler.List(target), effective));
}
return text.ToString();
}
/// <summary><c>Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone &lt;IANA name&gt;, e.g. /timezone America/New_York.</c></summary>
public string Show(long chatId)
{
var zone = store.TimeZoneFor(chatId);
var marker = store.HasOwnZone(chatId) ? "" : ", default";
return $"Time zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(time.GetUtcNow()))}{marker}). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.";
}
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>, the chat's zone now (for <c>/status</c>).</summary>
public string Describe(long chatId) => Describe(store.TimeZoneFor(chatId), time.GetUtcNow());
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>.</summary>
public static string Describe(TimeZoneInfo zone, DateTimeOffset at) => $"{TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(at))})";
/// <summary><c> 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).</c> Empty without jobs.</summary>
private static string JobClause(ZoneChange change, IReadOnlyList<JobView> jobs, TimeZoneInfo zone)
{
var parts = new List<string>();
if (change.CronJobs > 0)
parts.Add(change.CronJobs == 1 ? "1 recurring job now follows this zone" : $"{change.CronJobs.ToString(CultureInfo.InvariantCulture)} recurring jobs now follow this zone");
if (change.OneOffs > 0)
{
var times = string.Join(", ", jobs.Where(v => v.Job.Schedule.At is not null).Select(v => JobFormat.DayTime(v.Job.Schedule.At!.Value, zone)));
parts.Add(change.OneOffs == 1
? $"1 one-off job keeps its time (now {times} here)"
: $"{change.OneOffs.ToString(CultureInfo.InvariantCulture)} one-off jobs keep their times (now {times} here)");
}
return parts.Count == 0 ? "" : " " + string.Join("; ", parts) + ".";
}
}