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:
EugeneTes
2026-09-27 10:56:45 +02:00
parent 126d3afff1
commit fd6b9027b6
25 changed files with 326 additions and 101 deletions

View File

@@ -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 | | 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 | | 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 | | 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 | | 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 | | Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API | | Tests | `tests/` | xunit; no test calls a paid API |

View File

@@ -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 - **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 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 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 - **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 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 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. 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 - **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 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: 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: - **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 `<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 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". 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 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/`). - **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 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. 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 `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. `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`. `File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests) ### 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) IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException 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<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 TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply 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 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 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 `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 (process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
implementation. implementation.
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt. - Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.

View File

@@ -161,13 +161,17 @@ public static class ClaudeArgs
return env; 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) public static string? IanaZoneId(TimeZoneInfo zone)
{ {
ArgumentNullException.ThrowIfNull(zone); ArgumentNullException.ThrowIfNull(zone);
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null; if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id; var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return 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> /// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>

View File

@@ -27,9 +27,6 @@ public static class JobFormat
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone); 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> /// <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 public static string FailureReason(JobResult result) => result switch
{ {

View File

@@ -33,6 +33,7 @@ public sealed class JobScheduler : IAsyncDisposable
private DateTimeOffset? _wakeAt; private DateTimeOffset? _wakeAt;
private bool _stopped; private bool _stopped;
private Task _pendingTick = Task.CompletedTask; private Task _pendingTick = Task.CompletedTask;
private int _firesStarted;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger) 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> /// <summary>When the timer fires next (tests).</summary>
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } } 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> /// <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) 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; if (job.Schedule.At is { } at) entry.Next = at;
else else
{ {
entry.Next = NextCron(job, now); try
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target)); {
if (missed is { } m && m < now) entry.Next = NextCron(job, now);
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next); 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; _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> /// <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; Fire? running;
ScheduledJob removed;
lock (_lock) 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); _jobs.Remove(id);
_inFlight.TryGetValue(id, out running); _inFlight.TryGetValue(id, out running);
} }
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false); if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
await PersistAsync(cancellationToken).ConfigureAwait(false); await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm(); 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> /// <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() private void Arm()
{ {
ITimer timer;
TimeSpan delay;
lock (_lock) lock (_lock)
{ {
if (_stopped || _timer is null) return; 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; var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
if (wake < now) wake = now; if (wake < now) wake = now;
_wakeAt = wake; _wakeAt = wake;
timer = _timer; try
delay = wake - now; {
} _timer.Change(wake - now, Timeout.InfiniteTimeSpan);
try }
{ catch (ObjectDisposedException)
timer.Change(delay, Timeout.InfiniteTimeSpan); // outside the lock: a zero delay may call back synchronously {
} // Stopped meanwhile.
catch (ObjectDisposedException) }
{
// Stopped meanwhile.
} }
} }
@@ -288,6 +311,7 @@ public sealed class JobScheduler : IAsyncDisposable
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel); var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
_inFlight[job.Id] = new Fire(task, cancel); _inFlight[job.Id] = new Fire(task, cancel);
_firesStarted++;
gate.SetResult(); gate.SetResult();
} }
@@ -324,12 +348,14 @@ public sealed class JobScheduler : IAsyncDisposable
switch (job.Kind) switch (job.Kind)
{ {
case JobKind.Reminder: case JobKind.Reminder:
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false); result = 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()); ? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break; break;
case JobKind.Prompt: case JobKind.Prompt:
await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false); result = 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()); ? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break; break;
default: default:
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false); 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); 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) private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{ {
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout; var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
@@ -359,12 +385,14 @@ public sealed class JobScheduler : IAsyncDisposable
{ {
case ScriptOutcome.Output output: case ScriptOutcome.Output output:
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result. // 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 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); ? new JobResult(true, 0, false, null, at)
: null;
case ScriptOutcome.Failed failed: case ScriptOutcome.Failed failed:
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at); var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
if (!IsRepeat(LastResult(job.Id), result)) if (!IsRepeat(LastResult(job.Id), result)
await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false); && !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; return result;
default: default:
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult 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) => private static bool IsRepeat(JobResult? previous, JobResult current) =>
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut; 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> /// <summary>
private async Task DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver) /// 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 try
{ {
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target); _logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
return true;
} }
} }

View File

@@ -88,9 +88,8 @@ public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<S
{ {
var key = id?.Trim().ToLowerInvariant() ?? ""; var key = id?.Trim().ToLowerInvariant() ?? "";
if (key.Length == 0) throw Error("cancel needs id (see action=list)."); if (key.Length == 0) throw Error("cancel needs id (see action=list).");
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == key)?.Job; var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
if (job is null || !await scheduler.CancelAsync(target, key, cancellationToken).ConfigureAwait(false)) ?? throw Error($"No job {key} in this chat.");
throw Error($"No job {key} in this chat.");
return ToolResult.Text($"Cancelled {job.Id} ({job.Name})."); return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
} }

View File

@@ -38,19 +38,36 @@ public static partial class Schedules
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) => public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false); 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) public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
{ {
var trimmed = text?.Trim() ?? ""; 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."; 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.";
if (HasOffset().IsMatch(trimmed)) var match = IsoAt().Match(trimmed);
{ if (!match.Success) throw new JobValidationException(hint);
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var absolute) var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
? absolute if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
: throw new JobValidationException(hint); throw new JobValidationException(hint);
}
if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) throw new JobValidationException(hint);
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified); 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). // 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); if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
// An ambiguous fall-back time is read as the first of the two (the larger offset). // 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); 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> /// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
[GeneratedRegex(@"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\s*(Z|[+-]\d{2}(:?\d{2})?)$", RegexOptions.IgnoreCase)] private static TimeSpan ParseOffset(string text)
private static partial Regex HasOffset(); {
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();
} }

View File

@@ -75,7 +75,8 @@ public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy p
Directory.CreateDirectory(spawn.Cwd); Directory.CreateDirectory(spawn.Cwd);
process.Start(); 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); return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
} }

View File

@@ -1,22 +1,22 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler; 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 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) public static string? Iana(TimeZoneInfo zone)
{ {
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null; if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
if (zone.HasIanaId) return zone.Id; var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return 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; 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);
} }

View File

@@ -3,6 +3,7 @@ using System.Text;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Providers; using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling; 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}%)" : "")}"); 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 }) if (scheduler is { IsStarted: true })
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}"); 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, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`"); sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle"); 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(); var id = arg.Trim().ToLowerInvariant();
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs)."; if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
var target = TelegramJobDelivery.TargetOf(chat.ChatId); var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == id)?.Job; return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
return job is not null && await scheduler.CancelAsync(target, id, cancellationToken).ConfigureAwait(false)
? $"Cancelled {job.Id} ({job.Name})." ? $"Cancelled {job.Id} ({job.Name})."
: $"No job {id} in this chat."; : $"No job {id} in this chat.";
} }

View File

@@ -117,7 +117,7 @@ public sealed class ChatRegistry(
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar)); if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones))); if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
if (scheduler is not null) 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; AgentSession session;
try try
{ {

View File

@@ -3,6 +3,7 @@ using System.Text;
using Volcanic.AgenticCore.Extensions.Scheduler; using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent; using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats; namespace Volcanic.AgenticCore.Telegram.Chats;
@@ -13,8 +14,6 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// </summary> /// </summary>
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null) 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> /// <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) 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('.'); text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
if (scheduler is { IsStarted: true }) 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); var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
text.Append(JobClause(change, scheduler.List(target), effective)); text.Append(JobClause(change, scheduler.List(target), effective));
} }

View File

@@ -34,7 +34,9 @@ var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar); TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions(); var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable); 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(); var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList();
if (errors.Count > 0) if (errors.Count > 0)
{ {
@@ -63,6 +65,7 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
}); });
builder.Services.AddAgenticCore(o => builder.Services.AddAgenticCore(o =>
{ {
o.AgentDir = agentDir;
o.DefaultModel = options.Model; o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment; o.ProcessEnvironment = processEnvironment;
o.DefaultTimeZone = options.ConfiguredTimeZone(); o.DefaultTimeZone = options.ConfiguredTimeZone();
@@ -75,7 +78,7 @@ builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ChatRegistry>(); builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore( builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().Value.AgentDir), ChatSettingsStore.DefaultPath(agentDir),
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local, options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
sp.GetRequiredService<ILogger<ChatSettingsStore>>(), sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
sp.GetRequiredService<TimeProvider>())); sp.GetRequiredService<TimeProvider>()));

View File

@@ -20,6 +20,9 @@ public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore se
public static string TargetOf(long chatId) => chatId.ToString(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) public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{ {
var chat = await chats.GetAsync(ChatIdOf(target), cancellationToken).ConfigureAwait(false); 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; var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
try 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); 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. if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) 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> /// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
public bool IsDeliverable(string target) => 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;
} }

View File

@@ -22,8 +22,7 @@ public sealed partial class AgentSession : IExtensionHost
Model? IExtensionHost.Model => _model; Model? IExtensionHost.Model => _model;
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel; ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
TimeZoneInfo IExtensionHost.TimeZone => TimeZone; TimeZoneInfo IExtensionHost.TimeZone => TimeZone;
string? IExtensionHost.TimeZoneId => string? IExtensionHost.TimeZoneId => ConfiguredTimeZone is { } zone ? TimeZoneIds.ToIana(zone) : null;
(_timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
CancellationToken IExtensionHost.Signal CancellationToken IExtensionHost.Signal
{ {
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; } get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }

View File

@@ -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> /// <summary>The agent loop: turns, streaming with retry and overflow recovery, queues, compaction between turns.</summary>
public sealed partial class AgentSession 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> /// <summary>
private async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken) /// 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); await _runGate.WaitAsync(cancellationToken).ConfigureAwait(false);
Task run; Task run;
var queuedFollowUp = false;
try try
{ {
lock (_stateLock) lock (_stateLock)
{ {
if (_runTask is { IsCompleted: false }) 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; run = _runTask;
} }
else else
@@ -34,6 +47,7 @@ public sealed partial class AgentSession
} }
} }
finally { _runGate.Release(); } finally { _runGate.Release(); }
if (queuedFollowUp) Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
await run.ConfigureAwait(false); await run.ConfigureAwait(false);
} }

View File

@@ -102,7 +102,10 @@ public sealed partial class AgentSession : IAsyncDisposable
public string SessionId => _session.SessionId; public string SessionId => _session.SessionId;
public string Cwd => _options.Cwd; 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> /// <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> /// <summary>
/// Change the session's zone (a host's per-chat setting). Tools and process spawns see it at once; the system prompt /// 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> /// <summary>Queue a steering message; delivered after the current turn's tools, before the next model call.</summary>

View File

@@ -34,10 +34,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
private readonly VolcanicPaths _paths; private readonly VolcanicPaths _paths;
private readonly ISecretRedactor _redactor; private readonly ISecretRedactor _redactor;
private readonly IProcessSpawnPolicy _processes; private readonly IProcessSpawnPolicy _processes;
private readonly TimeProvider _time;
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory, 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; _processes = processes;
_services = services; _services = services;
_options = options.Value; _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). // The builder is created before the session; the zone is read from the session at every render (SetTimeZone).
AgentSession? owner = null; AgentSession? owner = null;
var time = (TimeProvider?)_services.GetService(typeof(TimeProvider)) ?? TimeProvider.System; // The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, time, // 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); () => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local);
var compactor = new Compactor(_options.Compaction, models); var compactor = new Compactor(_options.Compaction, models);

View File

@@ -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("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("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("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("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))); Assert.Equal("did not start", JobFormat.FailureReason(new JobResult(false, null, false, "failed to start", Now)));
} }

View File

@@ -167,8 +167,10 @@ public sealed class JobSchedulerTests
h.Time.Advance(Minute); h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the first run"); await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the first run");
h.Time.Advance(Minute); h.Time.Advance(Minute);
await h.Scheduler.PendingTick;
h.Time.Advance(Minute); 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); Assert.Single(h.Runner.Requests);
h.Runner.Hold.SetResult(); h.Runner.Hold.SetResult();
@@ -189,7 +191,8 @@ public sealed class JobSchedulerTests
h.Time.Advance(Minute); h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt"); await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt");
h.Time.Advance(Minute); h.Time.Advance(Minute);
await Task.Delay(100); await h.Scheduler.PendingTick;
Assert.Equal(1, h.Scheduler.FiresStarted);
Assert.Single(h.Delivery.Prompts); Assert.Single(h.Delivery.Prompts);
h.Delivery.HoldPrompts.SetResult(); h.Delivery.HoldPrompts.SetResult();
@@ -280,16 +283,60 @@ public sealed class JobSchedulerTests
h.Time.Advance(Minute); h.Time.Advance(Minute);
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run"); await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
var stoppedAt = h.Time.GetUtcNow();
var stop = h.Scheduler.StopAsync(CancellationToken.None); var stop = h.Scheduler.StopAsync(CancellationToken.None);
await Task.Delay(100); Assert.False(stop.IsCompleted); // waiting on the fake clock's grace period
Assert.False(stop.IsCompleted); // Second by second: the grace timer may be created after the first advances.
h.Time.Advance(TimeSpan.FromSeconds(10)); await Eventually.UntilAsync(() => { h.Time.Advance(TimeSpan.FromSeconds(1)); return stop.IsCompleted; }, "the stop after the grace period");
await stop.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(h.Time.GetUtcNow() - stoppedAt >= JobScheduler.StopGrace);
await stop;
h.Runner.Hold.SetResult(); h.Runner.Hold.SetResult();
await h.DisposeAsync(); 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] [Fact]
public async Task A_zone_change_recomputes_cron_and_leaves_one_offs() public async Task A_zone_change_recomputes_cron_and_leaves_one_offs()
{ {

View File

@@ -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, 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, 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] [Theory]

View File

@@ -21,6 +21,8 @@ internal sealed class FakeJobDelivery : IJobDelivery
public Dictionary<string, TimeZoneInfo> Zones { get; } = new(); public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public HashSet<string> Undeliverable { get; } = []; 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? HoldTexts { get; set; }
public TaskCompletionSource? HoldPrompts { get; set; } public TaskCompletionSource? HoldPrompts { get; set; }
public Exception? FailSends { get; set; } public Exception? FailSends { get; set; }
@@ -59,6 +61,7 @@ internal sealed class FakeJobDelivery : IJobDelivery
public TimeZoneInfo TimeZoneFor(string target) public TimeZoneInfo TimeZoneFor(string target)
{ {
lock (_lock) _calls.Add("TimeZoneFor:" + target); lock (_lock) _calls.Add("TimeZoneFor:" + target);
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
return Zones.GetValueOrDefault(target, DefaultZone); return Zones.GetValueOrDefault(target, DefaultZone);
} }
} }

View File

@@ -163,6 +163,23 @@ public sealed class ScheduledPromptChatTests
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5))); 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] [Fact]
public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times() public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times()
{ {

View File

@@ -43,10 +43,12 @@ public sealed class SchedulerChatTests
{ {
var (host, time) = await StartedAsync(); var (host, time) = await StartedAsync();
await using var _ = host; 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 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)); await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
var chat = await host.ChatAsync();
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5)); await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
var sent = Assert.Single(host.Bot.Sent); 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.Equal("⏰ Call the <b>dentist</b>", sent.Text);
Assert.Empty(host.Faux.Requests); Assert.Empty(host.Faux.Requests);
Assert.Empty(chat.Session.Messages); 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] [Fact]
@@ -81,6 +85,7 @@ public sealed class SchedulerChatTests
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target)); Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id); 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)); Assert.Null(host.Chats.Find(Updates.AllowedUser));
} }
@@ -162,5 +167,10 @@ public sealed class SchedulerChatTests
await using var host = new ChatHost(); await using var host = new ChatHost();
await host.SendAsync("/jobs"); await host.SendAsync("/jobs");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply()); 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());
} }
} }

View File

@@ -142,6 +142,29 @@ public class AgentLoopTests
Assert.Equal(["start", "steer me", "later"], session.Messages.OfType<UserMessage>().Select(m => m.Text)); 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] [Fact]
public async Task Prompt_while_streaming_without_behavior_throws() public async Task Prompt_while_streaming_without_behavior_throws()
{ {