forked from admin/VolcanicAgenticCore
- 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
66 lines
3.5 KiB
C#
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;
|
|
}
|