Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Scheduling/TelegramJobDelivery.cs
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

66 lines
3.5 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
namespace Volcanic.AgenticCore.Telegram.Scheduling;
/// <summary>
/// The scheduler's view of Telegram. Targets are chat ids (invariant culture). Direct texts go through the chat's
/// <c>IChatChannel</c> (renderer queue, splitting, HTML with fallback, retries); prompts go straight to the chat's session
/// as follow-ups, never through <c>UpdateRouter</c> or the <c>ChatInbox</c>, so they are never debounced into a user's
/// message and never answer a pending question. <see cref="WorkspaceFor"/> and <see cref="TimeZoneFor"/> never create or
/// look up a session: a silent watcher tick leaves the chat untouched.
/// </summary>
public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore settings, TelegramOptions options, TimeProvider time, ILogger<TelegramJobDelivery> logger) : IJobDelivery
{
public static long ChatIdOf(string target) => long.Parse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
public static string TargetOf(long chatId) => chatId.ToString(CultureInfo.InvariantCulture);
private static bool TryChatIdOf(string target, out long chatId) =>
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out chatId);
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
var chat = await chats.GetAsync(ChatIdOf(target), cancellationToken).ConfigureAwait(false);
await chat.Channel.SendTextAsync(markdown, cancellationToken).ConfigureAwait(false);
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
var chatId = ChatIdOf(target);
var chat = await chats.GetAsync(chatId, cancellationToken).ConfigureAwait(false);
var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
try
{
// Idle: starts (or joins, as a follow-up) a run and returns when it settles. Busy: queued as a follow-up (never
// steering) and returns at once; the job then stays in flight until the run that consumed it settles.
var queued = chat.Session.IsStreaming;
await chat.Session.PromptAsync(text, null, StreamingBehavior.FollowUp, cancellationToken).ConfigureAwait(false);
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Scheduled prompt in chat {ChatId} failed", chatId);
chat.Report($"The scheduled job failed: {ex.Message}");
}
}
public string WorkspaceFor(string target) => chats.WorkspaceFor(ChatIdOf(target));
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
public bool IsDeliverable(string target) =>
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);
/// <summary>An unparsable target (a hand-edited store) gets the default zone rather than an exception.</summary>
public TimeZoneInfo TimeZoneFor(string target) =>
TryChatIdOf(target, out var chatId) ? settings.TimeZoneFor(chatId) : settings.DefaultZone;
}