forked from admin/VolcanicAgenticCore
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
This commit is contained in:
@@ -18,6 +18,7 @@ Claude Code as a model provider without an API key.
|
||||
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
|
||||
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
|
||||
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
|
||||
| Scheduler (reminders, scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
|
||||
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
|
||||
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
|
||||
| Tests | `tests/` | xunit; no test calls a paid API |
|
||||
|
||||
@@ -61,7 +61,10 @@ same chat.
|
||||
- **Scheduled agent prompts are queued as follow-ups, never as steering.** When the chat is idle a prompt job starts a
|
||||
run. When a run is active (a user request, or another job), the prompt goes in with `StreamingBehavior.FollowUp`: the
|
||||
current work finishes first, then the job's prompt is handled in the same run. A clock-driven prompt shouldn't
|
||||
redirect what the user is doing mid-turn. User messages keep steering as they do today.
|
||||
redirect what the user is doing mid-turn. User messages keep steering as they do today. This also holds when a run
|
||||
starts between `PromptAsync`'s idle check and its run gate: the core's `StartRunAsync` gets the caller's
|
||||
`StreamingBehavior`, and a `FollowUp` prompt joins the active run as a follow-up (with a `QueueUpdate`), not as
|
||||
steering.
|
||||
- **Cron parsing uses Cronos** (`Cronos` on NuGet, Hangfire, MIT, no dependencies). It parses standard 5-field
|
||||
expressions and macros (`@daily`, `@hourly`, …). `GetNextOccurrence(DateTimeOffset, TimeZoneInfo)` works in a given
|
||||
zone and has defined DST semantics: a time skipped when clocks go forward fires at the transition, and an ambiguous
|
||||
@@ -72,7 +75,10 @@ same chat.
|
||||
sets a 1-minute floor for watchers.
|
||||
- **Each chat has its own time zone.** It applies to cron, to displayed times, to the session's system prompt, to the
|
||||
local-time header on each prompt, and to the `TZ` of the chat's `bash`, watcher and `claude -p` processes (§8). An `at` without an offset is read in the chat's zone, because
|
||||
models often leave the offset out. An `at` with an offset is used as given. The zone is a chat setting, not a
|
||||
models often leave the offset out. An `at` with an offset is used as given. The date is required and only ISO 8601 is
|
||||
accepted (`yyyy-MM-dd`, or `yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]` with an optional `Z`/offset): a bare `09:00` or a culture
|
||||
format such as `09/10/2026` is rejected with a readable error, because it would be resolved against the machine's
|
||||
clock rather than the chat's. The zone is a chat setting, not a
|
||||
scheduler setting, because the system prompt and `bash` need it with the scheduler disabled too:
|
||||
- **Stored by the host**, in a small per-chat settings store outside every workspace:
|
||||
`<AgentDir>/telegram/chat-settings.json` (§7). Not in `jobs.json` (the scheduler can be off) and not in the session
|
||||
@@ -93,7 +99,9 @@ same chat.
|
||||
fires immediately. If it's more than 60 s late, direct text gets `(due <time>, delivered late)` and an agent prompt
|
||||
gets `This was due at <time>; the bot was offline.` A cron job's next occurrence is computed strictly after "now".
|
||||
Occurrences missed while the bot was down are dropped, which is logged at Information. A one-off is removed only
|
||||
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once).
|
||||
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once). The same holds for
|
||||
a graceful stop: a delivery cut off by shutdown isn't recorded, so the one-off stays in `jobs.json` and fires late on
|
||||
the next start.
|
||||
- **Persistence is one central file:** `<AgentDir>/scheduler/jobs.json` (by default `~/.volcanic/agent/scheduler/`).
|
||||
The workspace is deliberately not used: the agent's `bash` can write anywhere there, and a central file is loaded
|
||||
without scanning every workspace. The format is `{ "version": 1, "jobs": [...] }` in `VolcanicJson` camelCase.
|
||||
@@ -153,7 +161,9 @@ watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error
|
||||
|
||||
`LoadAsync()` returns the jobs. A missing file means an empty list. A corrupt file is renamed to
|
||||
`jobs.json.corrupt-<timestamp>`, logged as an error, and loading starts empty: a bad file must not keep the host down.
|
||||
Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
|
||||
Invalid jobs are dropped with a warning. A stored cron job whose next occurrence can't be computed on start (for
|
||||
example a target the host can't map to a zone) is logged and left unarmed; it never fails `StartAsync`. The Telegram
|
||||
delivery's `TimeZoneFor` returns the default zone for a target that isn't a chat id. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
|
||||
`File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
|
||||
|
||||
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests)
|
||||
@@ -189,6 +199,7 @@ public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner,
|
||||
IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
|
||||
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException
|
||||
Task<bool> CancelAsync(string target, string id, CancellationToken ct); // false: unknown, or another target's
|
||||
Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken ct); // the removed job, in one step (tool, /cancel)
|
||||
TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
|
||||
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply
|
||||
}
|
||||
@@ -386,7 +397,11 @@ and Claude Code's `TZ`:
|
||||
the allow-list, like its pinned `CLAUDE_CODE_*` flags. Anything Claude Code itself adds about the date then agrees
|
||||
with our prompt. It isn't set on Windows (the OS ignores `TZ`, as in the process-environment-hook spec §3), nor for
|
||||
`claude auth login`/`auth status` and the doctor's probes, which have no session. `TZ` stays off the inherited
|
||||
allow-list: the host's own `TZ` never leaks through. This goes through the provider, not `IProcessSpawnPolicy`
|
||||
allow-list: the host's own `TZ` never leaks through. When the session has no configured zone, `claude -p` gets the
|
||||
IANA id of `TimeZoneInfo.Local` (it can't inherit `TZ`) while `bash` inherits the host's `TZ` (its `TimeZoneId` is
|
||||
null); both end up in the host's zone. All three id conversions follow one rule, `TimeZoneIds.ToIana` (mirrored by
|
||||
`ClaudeArgs.IanaZoneId` and the scheduler's `ZoneNames.Iana`, since extensions can't reference the core): null unless
|
||||
the id resolves to a system zone, so an unnamed `Local` sets no `TZ`. This goes through the provider, not `IProcessSpawnPolicy`
|
||||
(process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
|
||||
implementation.
|
||||
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.
|
||||
|
||||
@@ -161,13 +161,17 @@ public static class ClaudeArgs
|
||||
return env;
|
||||
}
|
||||
|
||||
/// <summary>The IANA id for <c>TZ</c>: the zone's own id, a converted Windows id, or null for an unnamed <c>Local</c> zone.</summary>
|
||||
/// <summary>
|
||||
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
|
||||
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
public static string? IanaZoneId(TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
if (zone.HasIanaId) return zone.Id;
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>
|
||||
|
||||
@@ -27,9 +27,6 @@ public static class JobFormat
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
private DateTimeOffset? _wakeAt;
|
||||
private bool _stopped;
|
||||
private Task _pendingTick = Task.CompletedTask;
|
||||
private int _firesStarted;
|
||||
|
||||
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
|
||||
{
|
||||
@@ -53,6 +54,12 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
/// <summary>When the timer fires next (tests).</summary>
|
||||
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
|
||||
|
||||
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
|
||||
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
|
||||
|
||||
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
|
||||
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -69,10 +76,19 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
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);
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
|
||||
entry.Next = null;
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
|
||||
}
|
||||
}
|
||||
_jobs[job.Id] = entry;
|
||||
}
|
||||
@@ -148,19 +164,25 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
|
||||
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
|
||||
|
||||
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
|
||||
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
Fire? running;
|
||||
ScheduledJob removed;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return false;
|
||||
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
|
||||
removed = entry.Job;
|
||||
_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;
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -250,10 +272,13 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
|
||||
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
|
||||
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
|
||||
/// </summary>
|
||||
private void Arm()
|
||||
{
|
||||
ITimer timer;
|
||||
TimeSpan delay;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped || _timer is null) return;
|
||||
@@ -262,16 +287,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
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.
|
||||
try
|
||||
{
|
||||
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Stopped meanwhile.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +311,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
|
||||
_inFlight[job.Id] = new Fire(task, cancel);
|
||||
_firesStarted++;
|
||||
gate.SetResult();
|
||||
}
|
||||
|
||||
@@ -324,12 +348,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
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());
|
||||
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
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());
|
||||
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
break;
|
||||
default:
|
||||
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
|
||||
@@ -338,7 +364,7 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
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>
|
||||
/// <summary>Null when the run or its delivery 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;
|
||||
@@ -359,12 +385,14 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
{
|
||||
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);
|
||||
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, 0, false, null, at)
|
||||
: null;
|
||||
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);
|
||||
if (!IsRepeat(LastResult(job.Id), result)
|
||||
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
|
||||
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
|
||||
return result;
|
||||
default:
|
||||
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
|
||||
@@ -375,17 +403,27 @@ public sealed class JobScheduler : IAsyncDisposable
|
||||
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)
|
||||
/// <summary>
|
||||
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
|
||||
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
|
||||
/// after the next start (at-least-once).
|
||||
/// </summary>
|
||||
private async Task<bool> 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
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
|
||||
return false;
|
||||
}
|
||||
catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,9 +88,8 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
|
||||
{
|
||||
var key = id?.Trim().ToLowerInvariant() ?? "";
|
||||
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
|
||||
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == key)?.Job;
|
||||
if (job is null || !await scheduler.CancelAsync(target, key, cancellationToken).ConfigureAwait(false))
|
||||
throw Error($"No job {key} in this chat.");
|
||||
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
|
||||
?? throw Error($"No job {key} in this chat.");
|
||||
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
|
||||
}
|
||||
|
||||
|
||||
@@ -38,19 +38,36 @@ public static partial class Schedules
|
||||
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
|
||||
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
|
||||
|
||||
/// <summary>ISO 8601. With an offset (or <c>Z</c>) it's used as given; without one it's wall-clock time in <paramref name="zone"/>.</summary>
|
||||
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
|
||||
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
|
||||
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
|
||||
/// they'd be resolved against the machine's clock, not the chat's.
|
||||
/// </summary>
|
||||
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
|
||||
{
|
||||
var trimmed = text?.Trim() ?? "";
|
||||
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
||||
if (HasOffset().IsMatch(trimmed))
|
||||
{
|
||||
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute)
|
||||
? absolute
|
||||
: throw new JobValidationException(hint);
|
||||
}
|
||||
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
|
||||
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
||||
var match = IsoAt().Match(trimmed);
|
||||
if (!match.Success) throw new JobValidationException(hint);
|
||||
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
|
||||
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
|
||||
throw new JobValidationException(hint);
|
||||
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
|
||||
if (match.Groups["offset"] is { Success: true } group)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(wall, ParseOffset(group.Value));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new JobValidationException(hint); // an offset beyond ±14:00
|
||||
}
|
||||
}
|
||||
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
|
||||
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
|
||||
// An ambiguous fall-back time is read as the first of the two (the larger offset).
|
||||
@@ -58,7 +75,19 @@ public static partial class Schedules
|
||||
return new DateTimeOffset(wall, offset);
|
||||
}
|
||||
|
||||
/// <summary>A time of day followed by <c>Z</c> or an offset. A bare date such as <c>2026-09-27</c> must not match (its <c>-27</c> isn't an offset).</summary>
|
||||
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HasOffset();
|
||||
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
|
||||
private static TimeSpan ParseOffset(string text)
|
||||
{
|
||||
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
|
||||
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
|
||||
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
|
||||
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
|
||||
var offset = new TimeSpan(hours, minutes, 0);
|
||||
return text[0] == '-' ? -offset : offset;
|
||||
}
|
||||
|
||||
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
|
||||
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex IsoAt();
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy p
|
||||
Directory.CreateDirectory(spawn.Cwd);
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException)
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>Zone ids and offsets for display and <c>TZ</c> (the extension can't reference the core's <c>TimeZoneIds</c>).</summary>
|
||||
/// <summary>
|
||||
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
internal static class ZoneNames
|
||||
{
|
||||
/// <summary>The IANA id (converting a Windows id), or null for an unnamed <c>Local</c> zone.</summary>
|
||||
/// <summary>
|
||||
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
|
||||
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
|
||||
/// </summary>
|
||||
public static string? Iana(TimeZoneInfo zone)
|
||||
{
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
if (zone.HasIanaId) return zone.Id;
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The IANA id when there is one, else the zone's own id.</summary>
|
||||
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
|
||||
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
|
||||
|
||||
/// <summary><c>UTC+09:00</c>.</summary>
|
||||
public static string Offset(TimeSpan offset) =>
|
||||
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
@@ -150,7 +151,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
|
||||
if (scheduler is { IsStarted: true })
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
|
||||
var zone = zones?.Describe(chat.ChatId) ?? ChatTimeZoneService.Describe(session.TimeZone, DateTimeOffset.UtcNow);
|
||||
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
|
||||
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
|
||||
@@ -212,8 +213,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
var id = arg.Trim().ToLowerInvariant();
|
||||
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
|
||||
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
|
||||
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == id)?.Job;
|
||||
return job is not null && await scheduler.CancelAsync(target, id, cancellationToken).ConfigureAwait(false)
|
||||
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
|
||||
? $"Cancelled {job.Id} ({job.Name})."
|
||||
: $"No job {id} in this chat.";
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class ChatRegistry(
|
||||
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
|
||||
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
|
||||
if (scheduler is not null)
|
||||
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, chatId.ToString(System.Globalization.CultureInfo.InvariantCulture))));
|
||||
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId))));
|
||||
AgentSession session;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
|
||||
@@ -13,8 +14,6 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
/// </summary>
|
||||
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
|
||||
{
|
||||
public TimeZoneInfo TimeZoneFor(long chatId) => store.TimeZoneFor(chatId);
|
||||
|
||||
/// <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)
|
||||
{
|
||||
@@ -28,7 +27,7 @@ public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, Agen
|
||||
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
|
||||
if (scheduler is { IsStarted: true })
|
||||
{
|
||||
var target = chatId.ToString(CultureInfo.InvariantCulture);
|
||||
var target = TelegramJobDelivery.TargetOf(chatId);
|
||||
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
text.Append(JobClause(change, scheduler.List(target), effective));
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ var processEnvironment = new ProcessEnvironmentOptions();
|
||||
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
|
||||
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
|
||||
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
|
||||
var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), VolcanicPaths.DefaultAgentDir);
|
||||
// One agent directory for sessions, chat-settings.json and scheduler/jobs.json.
|
||||
var agentDir = VolcanicPaths.DefaultAgentDir;
|
||||
var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), agentDir);
|
||||
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
@@ -63,6 +65,7 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
|
||||
});
|
||||
builder.Services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = agentDir;
|
||||
o.DefaultModel = options.Model;
|
||||
o.ProcessEnvironment = processEnvironment;
|
||||
o.DefaultTimeZone = options.ConfiguredTimeZone();
|
||||
@@ -75,7 +78,7 @@ builder.Services.AddSingleton<TelegramSender>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ChatRegistry>();
|
||||
builder.Services.AddSingleton(sp => new ChatSettingsStore(
|
||||
ChatSettingsStore.DefaultPath(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().Value.AgentDir),
|
||||
ChatSettingsStore.DefaultPath(agentDir),
|
||||
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
|
||||
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
|
||||
sp.GetRequiredService<TimeProvider>()));
|
||||
|
||||
@@ -20,6 +20,9 @@ public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore se
|
||||
|
||||
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);
|
||||
@@ -33,10 +36,11 @@ public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore se
|
||||
var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
|
||||
try
|
||||
{
|
||||
// Idle: starts a run and returns when it settles. Busy: queued as a follow-up (never steering) and returns at once.
|
||||
// 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);
|
||||
// Queued: the job stays in flight until the run that consumed it settles. Harmless when it already ran.
|
||||
await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -53,7 +57,9 @@ public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore se
|
||||
|
||||
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
|
||||
public bool IsDeliverable(string target) =>
|
||||
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) && options.IsAllowed(chatId);
|
||||
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);
|
||||
|
||||
public TimeZoneInfo TimeZoneFor(string target) => settings.TimeZoneFor(ChatIdOf(target));
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ public sealed partial class AgentSession : IExtensionHost
|
||||
Model? IExtensionHost.Model => _model;
|
||||
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
|
||||
TimeZoneInfo IExtensionHost.TimeZone => TimeZone;
|
||||
string? IExtensionHost.TimeZoneId =>
|
||||
(_timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
|
||||
string? IExtensionHost.TimeZoneId => ConfiguredTimeZone is { } zone ? TimeZoneIds.ToIana(zone) : null;
|
||||
CancellationToken IExtensionHost.Signal
|
||||
{
|
||||
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }
|
||||
|
||||
@@ -12,18 +12,31 @@ namespace Volcanic.AgenticCore.Features.Agent;
|
||||
/// <summary>The agent loop: turns, streaming with retry and overflow recovery, queues, compaction between turns.</summary>
|
||||
public sealed partial class AgentSession
|
||||
{
|
||||
/// <summary>Starts a run if none is active. When a run is already active the messages must have been queued by the caller.</summary>
|
||||
private async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Starts a run if none is active. When a run started meanwhile (after the caller's <see cref="IsStreaming"/> check),
|
||||
/// <paramref name="prompt"/> joins it: as a follow-up when <paramref name="behavior"/> is
|
||||
/// <see cref="StreamingBehavior.FollowUp"/>, else as steering. Either way the call returns when that run settles.
|
||||
/// </summary>
|
||||
internal async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken, StreamingBehavior? behavior = null)
|
||||
{
|
||||
await _runGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Task run;
|
||||
var queuedFollowUp = false;
|
||||
try
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_runTask is { IsCompleted: false })
|
||||
{
|
||||
if (prompt is not null) _steering.Enqueue(prompt);
|
||||
if (prompt is not null)
|
||||
{
|
||||
if (behavior == StreamingBehavior.FollowUp)
|
||||
{
|
||||
_followUp.Enqueue(prompt);
|
||||
queuedFollowUp = true;
|
||||
}
|
||||
else _steering.Enqueue(prompt);
|
||||
}
|
||||
run = _runTask;
|
||||
}
|
||||
else
|
||||
@@ -34,6 +47,7 @@ public sealed partial class AgentSession
|
||||
}
|
||||
}
|
||||
finally { _runGate.Release(); }
|
||||
if (queuedFollowUp) Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
|
||||
await run.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,10 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
public string SessionId => _session.SessionId;
|
||||
public string Cwd => _options.Cwd;
|
||||
/// <summary>The session's time zone: <see cref="SetTimeZone"/>, else the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
|
||||
public TimeZoneInfo TimeZone => ConfiguredTimeZone ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>The zone set for this session (override, option, host default), or null when none is: then children inherit the host's <c>TZ</c>.</summary>
|
||||
private TimeZoneInfo? ConfiguredTimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone;
|
||||
|
||||
/// <summary>
|
||||
/// Change the session's zone (a host's per-chat setting). Tools and process spawns see it at once; the system prompt
|
||||
@@ -195,7 +198,8 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
await StartRunAsync(message, images, cancellationToken).ConfigureAwait(false);
|
||||
// A run may start between the check above and the run gate; behavior then decides how the prompt joins it.
|
||||
await StartRunAsync(message, images, cancellationToken, behavior).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Queue a steering message; delivered after the current turn's tools, before the next model call.</summary>
|
||||
|
||||
@@ -34,10 +34,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
private readonly VolcanicPaths _paths;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly IProcessSpawnPolicy _processes;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory,
|
||||
ISecretRedactor redactor, IProcessSpawnPolicy processes)
|
||||
ISecretRedactor redactor, IProcessSpawnPolicy processes, TimeProvider? time = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
_processes = processes;
|
||||
_services = services;
|
||||
_options = options.Value;
|
||||
@@ -103,8 +105,9 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
|
||||
// The builder is created before the session; the zone is read from the session at every render (SetTimeZone).
|
||||
AgentSession? owner = null;
|
||||
var time = (TimeProvider?)_services.GetService(typeof(TimeProvider)) ?? TimeProvider.System;
|
||||
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, time,
|
||||
// The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it
|
||||
// during activation, and follows the session's own order (AgentSession.TimeZone).
|
||||
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, _time,
|
||||
() => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local);
|
||||
var compactor = new Compactor(_options.Compaction, models);
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ public sealed class JobFormatTests
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -167,8 +167,10 @@ public sealed class JobSchedulerTests
|
||||
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 Task.Delay(100);
|
||||
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();
|
||||
@@ -189,7 +191,8 @@ public sealed class JobSchedulerTests
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt");
|
||||
h.Time.Advance(Minute);
|
||||
await Task.Delay(100);
|
||||
await h.Scheduler.PendingTick;
|
||||
Assert.Equal(1, h.Scheduler.FiresStarted);
|
||||
Assert.Single(h.Delivery.Prompts);
|
||||
|
||||
h.Delivery.HoldPrompts.SetResult();
|
||||
@@ -280,16 +283,60 @@ public sealed class JobSchedulerTests
|
||||
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);
|
||||
await Task.Delay(100);
|
||||
Assert.False(stop.IsCompleted);
|
||||
h.Time.Advance(TimeSpan.FromSeconds(10));
|
||||
await stop.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
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.Store.SaveAsync([
|
||||
Stored("bad11", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"),
|
||||
Stored("good1", JobKind.Reminder, 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", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
|
||||
}
|
||||
|
||||
[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.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
|
||||
h.Delivery.HoldTexts = new TaskCompletionSource(); // the send is in flight when the bot stops
|
||||
await h.StartAsync();
|
||||
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
|
||||
|
||||
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();
|
||||
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", "⏰ Call the dentist"), Assert.Single(delivery.Texts));
|
||||
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
||||
await h.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_zone_change_recomputes_cron_and_leaves_one_offs()
|
||||
{
|
||||
|
||||
@@ -54,6 +54,20 @@ public sealed class ScheduleTests
|
||||
{
|
||||
Assert.Equal(Utc(2026, 9, 27, 4, 0), Schedules.ParseAt("2026-09-27T09:00+05:00", Berlin));
|
||||
Assert.Equal(Utc(2026, 9, 27, 9, 0), Schedules.ParseAt("2026-09-27T09:00Z", Tokyo));
|
||||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00:00.000+0200", Tokyo));
|
||||
Assert.Equal(Utc(2026, 9, 27, 11, 0), Schedules.ParseAt("2026-09-27 09:00-02", Tokyo));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("09:00")] // no date: would take the machine's date, not the chat's
|
||||
[InlineData("09:00+02:00")]
|
||||
[InlineData("09/10/2026")] // culture formats aren't ISO 8601
|
||||
[InlineData("27.09.2026 09:00")]
|
||||
[InlineData("2026-09-27T09:00+15:00")] // beyond ±14:00
|
||||
public void At_without_a_date_or_not_in_ISO_8601_is_rejected(string at)
|
||||
{
|
||||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt(at, Berlin));
|
||||
Assert.Contains("ISO 8601 with the date", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -21,6 +21,8 @@ internal sealed class FakeJobDelivery : IJobDelivery
|
||||
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
|
||||
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
public HashSet<string> Undeliverable { get; } = [];
|
||||
/// <summary>Targets whose <see cref="TimeZoneFor"/> throws (a store entry the host can't map).</summary>
|
||||
public HashSet<string> BadTargets { get; } = [];
|
||||
public TaskCompletionSource? HoldTexts { get; set; }
|
||||
public TaskCompletionSource? HoldPrompts { get; set; }
|
||||
public Exception? FailSends { get; set; }
|
||||
@@ -59,6 +61,7 @@ internal sealed class FakeJobDelivery : IJobDelivery
|
||||
public TimeZoneInfo TimeZoneFor(string target)
|
||||
{
|
||||
lock (_lock) _calls.Add("TimeZoneFor:" + target);
|
||||
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
|
||||
return Zones.GetValueOrDefault(target, DefaultZone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,23 @@ public sealed class ScheduledPromptChatTests
|
||||
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduled_prompt_that_fails_is_reported_to_the_chat_and_the_job_still_completes()
|
||||
{
|
||||
var (host, time) = await SchedulerChatTests.StartedAsync();
|
||||
await using var _ = host;
|
||||
var chat = await host.ChatAsync();
|
||||
await chat.Session.DisposeAsync(); // PromptAsync now throws, as any failure to start the run would
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
|
||||
|
||||
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Contains(host.Texts, t => t.Contains("The scheduled job failed:", StringComparison.Ordinal));
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Empty(host.Scheduler.List(ChatHost.Target)); // the one-off completed (no redelivery queue)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times()
|
||||
{
|
||||
|
||||
@@ -43,10 +43,12 @@ public sealed class SchedulerChatTests
|
||||
{
|
||||
var (host, time) = await StartedAsync();
|
||||
await using var _ = host;
|
||||
var chat = await host.ChatAsync();
|
||||
var entriesBefore = chat.Session.SessionManager.GetEntries().Count;
|
||||
var leafBefore = chat.Session.SessionManager.LeafId;
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T14:05", Text: "Call the **dentist**"), CancellationToken.None);
|
||||
|
||||
await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
|
||||
var chat = await host.ChatAsync();
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var sent = Assert.Single(host.Bot.Sent);
|
||||
@@ -54,6 +56,8 @@ public sealed class SchedulerChatTests
|
||||
Assert.Equal("⏰ Call the <b>dentist</b>", sent.Text);
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Empty(chat.Session.Messages);
|
||||
Assert.Equal(entriesBefore, chat.Session.SessionManager.GetEntries().Count); // no entry of any kind
|
||||
Assert.Equal(leafBefore, chat.Session.SessionManager.LeafId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -81,6 +85,7 @@ public sealed class SchedulerChatTests
|
||||
|
||||
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
|
||||
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
|
||||
Assert.Same(host.Settings.DefaultZone, host.Delivery.TimeZoneFor("not-a-chat")); // a hand-edited store never throws here
|
||||
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
||||
}
|
||||
|
||||
@@ -162,5 +167,10 @@ public sealed class SchedulerChatTests
|
||||
await using var host = new ChatHost();
|
||||
await host.SendAsync("/jobs");
|
||||
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
||||
await host.SendAsync("/cancel abcde");
|
||||
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
||||
await host.SendAsync("/status");
|
||||
Assert.DoesNotContain("Jobs:", host.LastReply());
|
||||
Assert.Contains("Time zone:", host.LastReply());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,29 @@ public class AgentLoopTests
|
||||
Assert.Equal(["start", "steer me", "later"], session.Messages.OfType<UserMessage>().Select(m => m.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_follow_up_prompt_that_loses_the_race_to_a_run_is_queued_as_a_follow_up_not_steering()
|
||||
{
|
||||
var gate = new TaskCompletionSource();
|
||||
await using var host = new TestHost(null, WithTools(Echo("wait", async v => { await gate.Task; return v; })));
|
||||
host.Faux.Enqueue(FauxTurn.Tool("wait", new { value = "x" }), FauxTurn.Say("after tool"), FauxTurn.Say("after scheduled"));
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
|
||||
var run = session.PromptAsync("user");
|
||||
await WaitUntil(() => session.IsStreaming && host.Events.OfType<AgentSessionEvent.ToolExecutionStart>().Any());
|
||||
// What PromptAsync(FollowUp) does when it saw an idle session just before the user's run took the run gate.
|
||||
var scheduled = session.StartRunAsync(UserMessage.From("scheduled", null), null, CancellationToken.None, StreamingBehavior.FollowUp);
|
||||
await WaitUntil(() => host.Events.OfType<AgentSessionEvent.QueueUpdate>().Any());
|
||||
Assert.Contains(host.Events, e => e is AgentSessionEvent.QueueUpdate { Steering.Count: 0, FollowUp.Count: 1 });
|
||||
gate.SetResult();
|
||||
await run;
|
||||
await scheduled;
|
||||
|
||||
Assert.Equal(3, host.Faux.Requests.Count);
|
||||
Assert.IsType<ToolResultMessage>(host.Faux.Requests[1].Messages[^1]); // not steered into the user's turn
|
||||
Assert.Equal("scheduled", host.Faux.Requests[2].Messages[^1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_while_streaming_without_behavior_throws()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user