forked from admin/VolcanicAgenticCore
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
497 lines
40 KiB
Markdown
497 lines
40 KiB
Markdown
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
|
||
|
||
Date: 2026-09-26
|
||
Status: implemented
|
||
Depends on: `2026-09-26-telegram-attachments-design.md` (`IChatChannel`, `ChatContext.Channel`, the renderer's
|
||
`RunInOrderAsync`); `2026-09-26-process-environment-hook-design.md` (`IProcessSpawnPolicy`, which builds the watcher
|
||
scripts' environment, and `ISecretRedactor`, which cleans their output); `2026-09-26-telegram-message-context-design.md`
|
||
(`MessageContext.TimestampHeader`, the local-time header on every prompt).
|
||
|
||
## Problem
|
||
|
||
The Telegram bot only speaks when spoken to. The owner wants it to act on a clock: "remind me at 9 tomorrow to call
|
||
the dentist", "every weekday at 8, summarise what's in `inbox/`", "check this page every 10 minutes and tell me only
|
||
if the price drops below 50". VolcanicSecretary (`/tmp/VolcanicSecretary`, `src/scheduler/*`, `src/tools/schedule.ts`)
|
||
already does this for a single-owner pi bot. This spec ports its behaviour to our multi-chat .NET host: which jobs
|
||
exist, how they fire, where the code lives, and how a scheduled prompt fits with a run that's already active in the
|
||
same chat.
|
||
|
||
## Reference behaviour kept from VolcanicSecretary
|
||
|
||
- A job has an `at` (one-off) or `cron` schedule, and it's either fixed text or a shell command.
|
||
- Watcher convention: exit 0 with empty stdout (after trimming) stays silent. Exit 0 with output delivers the output (here: always to the agent, see Decisions).
|
||
A non-zero exit or a timeout is a failure. It's reported once, straight to the chat and never through the model,
|
||
with the last 20 lines of stderr. While later runs fail the same way nothing more is sent. A success resets that.
|
||
- One-off jobs are removed after they fire, whether that worked or not. On startup overdue one-offs fire right away.
|
||
Cron jobs never catch up.
|
||
- At most one instance of a job runs at a time. An occurrence that comes due while the previous one is still running is
|
||
skipped and logged.
|
||
- One timer is armed for the soonest due job and capped at 1 h, then re-armed on every tick, create and cancel.
|
||
- Captured output is capped: the first 48 KiB and the last 16 KiB of each stream are kept. The script environment
|
||
doesn't contain the bot token (here: nor any other host secret, through the process environment hook).
|
||
- Short ids of 5 characters from `abcdefghjkmnpqrstuvwxyz23456789`. The store is written atomically (temp file + rename)
|
||
after every change or run, and the writes are serialised.
|
||
|
||
## Decisions
|
||
|
||
- **Three explicit kinds instead of the source × delivery matrix.** `reminder` sends fixed text to the chat with no
|
||
model call. `prompt` runs fixed text through the agent. `watcher` runs a script, and when the script reports a match
|
||
its output always goes to the agent. There's no direct-output mode for watchers: the script itself is the condition
|
||
(silent until it matches), so matches are rare and the agent turning raw output into a message is worth one model
|
||
call. The consequence: every match costs one model call, and a match that arrives while a run is active is queued as
|
||
a follow-up like any scheduled prompt. Reminders stay direct because they are fixed text. Failure reports stay
|
||
direct (see Firing) because they must arrive even when the model is unavailable.
|
||
- **A watcher's script runs without the agent. The agent is invoked only when the condition is met.** Every tick runs
|
||
the script and nothing else: no model call, no session activity (no session is created or woken, nothing is queued),
|
||
nothing in the transcript. The condition is met only when the script exits 0 with non-empty stdout (after
|
||
trimming). Then, and only then, the output goes to the agent through `RunPromptAsync`. A silent run (exit 0, empty
|
||
stdout) costs nothing and is invisible except for `LastRunAt`/`LastResult`. A failure is reported directly, never
|
||
through the model.
|
||
- **A new extension project, `extensions/Volcanic.AgenticCore.Extensions.Scheduler`.** It references only
|
||
Abstractions, plus Cronos. It isn't a core slice: slices are composed per `AgentSession`, while the scheduler is
|
||
process-wide, has to fire for chats with no live session, and brings a third-party dependency the core shouldn't
|
||
carry. It follows the Claude Code pattern: one process-wide object (`JobScheduler`), a thin per-session extension
|
||
that registers the `schedule` tool, and a delivery interface the host implements. The scheduler itself adds nothing
|
||
to Abstractions. It uses contracts that other specs add there: `IProcessSpawnPolicy` and `ISecretRedactor`
|
||
(`2026-09-26-process-environment-hook-design.md`) and the session time zone (§8 of this spec).
|
||
- **Jobs are scoped per chat through a `target` string.** On Telegram the target is the chat id (invariant culture).
|
||
The per-session extension is bound to its target by the host through `AgentSessionOptions.ExtraExtensions`, so
|
||
there's no need to add a "scope" to `IExtensionContext`. The tool, `/jobs` and `/cancel` only see the jobs of their
|
||
own target. Scripts run in that target's workspace, `<WorkspaceRoot>/<chatId>`.
|
||
- **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. 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
|
||
time when clocks go back fires once. That matches `croner` in the reference. The alternatives:
|
||
NCrontab has no time zones (we'd have to do the DST arithmetic ourselves). Quartz.NET is a whole scheduler, with its
|
||
own persistence and a 6/7-field syntax the model gets wrong. We only need "next occurrence after t in zone z".
|
||
Seconds (`CronFormat.IncludeSeconds`) and the `H` jitter aren't allowed: minute granularity is enough, and it also
|
||
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 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
|
||
(a `/new` session must keep it). Like `jobs.json` it's out of the workspace, so the agent doesn't browse or edit
|
||
it by accident. `bash` can still reach it as the bot's OS user, like every file under the agent dir.
|
||
- **Default**: `Telegram:DefaultTimeZone` (env `DEFAULT_TIMEZONE`), else `TimeZoneInfo.Local`, which honours `TZ`.
|
||
A chat without a stored zone follows the default, so changing the default moves every such chat.
|
||
- **Set** by `/timezone <IANA id>` in the chat, or by the agent through a `set_timezone` tool when the user says
|
||
where they are. Validated with `TimeZoneInfo.FindSystemTimeZoneById` and stored as an IANA id.
|
||
- The process-wide `Scheduler:TimeZone` is gone. The scheduler asks the host for a target's zone
|
||
(`IJobDelivery.TimeZoneFor`).
|
||
- **Changing a chat's zone re-evaluates cron and leaves one-offs alone.** A cron expression is wall-clock time in the
|
||
chat's zone ("every weekday at 8" means 8 where the user is now), so on a change every cron job of that chat gets its
|
||
next occurrence recomputed in the new zone, strictly after now, with no catch-up. A one-off `at` is an absolute
|
||
instant, fixed when the job was created (an offset-less `at` was resolved in the zone of that moment), so it fires at
|
||
the same instant and is only displayed in the new zone. The confirmation says both. Per-job zones are out of scope.
|
||
- **Restart and missed fires:** one-offs fire late with a note, and cron doesn't catch up. On startup an overdue one-off
|
||
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). 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.
|
||
- **Direct delivery is text, through `IChatChannel`.** The sibling spec `2026-09-26-telegram-attachments-design.md`
|
||
adds `IChatChannel` (Abstractions, `Ui/`) with `SendTextAsync`/`SendFileAsync`. `IJobDelivery` stays, because it
|
||
does what the channel doesn't (target routing, prompt runs, workspaces, deliverability), but the Telegram
|
||
implementation of `IJobDelivery.SendTextAsync` is a thin call to the chat session's `IChatChannel.SendTextAsync`.
|
||
A scheduled `prompt` (and a watcher match) runs as a normal agent run, so it can use that spec's `send` tool. Direct
|
||
reminders and failure alerts stay text.
|
||
- **Direct deliveries are not added to the transcript.** Reminders and failure alerts go to the chat only;
|
||
`IChatChannel` never persists and the scheduler appends no `CustomMessage`. As a consequence the agent doesn't see
|
||
direct reminders (it can't answer "what was that reminder about?" from context). That's accepted: a job that should
|
||
be in the conversation is a `prompt`, and a watcher match always is.
|
||
- **Scheduled prompts carry the same local-time header as user messages.** Every prompt a job sends to the agent (a
|
||
`prompt` job, a watcher match) starts with the message-context spec's timestamp line in the chat's zone, e.g.
|
||
`[Sat 2026-09-26 08:00 Europe/Berlin]`, taken from `TimeProvider` at fire time. The Telegram delivery adds it
|
||
(§6), so the scheduler's own format stays host-neutral. The header is the actual fire time, so a late one-off shows
|
||
both that and its `This was due at …` note.
|
||
- **Watcher output is redacted before the agent sees it.** Watcher stdout isn't a tool result, so the loop's redaction
|
||
doesn't reach it; the scheduler applies `ISecretRedactor` itself (§4).
|
||
- **Time comes only from `TimeProvider`.** No `DateTime.Now` or `Task.Delay`: the timer is `TimeProvider.CreateTimer`.
|
||
|
||
## Components
|
||
|
||
### 1. Job model (`Scheduler/Jobs.cs`)
|
||
|
||
```csharp
|
||
public enum JobKind { Reminder, Prompt, Watcher }
|
||
|
||
public sealed record JobSchedule(DateTimeOffset? At, string? Cron); // exactly one is set; At is an absolute instant
|
||
|
||
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
|
||
|
||
public sealed record ScheduledJob
|
||
{
|
||
public required string Id { get; init; }
|
||
public required string Target { get; init; } // chat id on Telegram
|
||
public required string Name { get; init; }
|
||
public required JobKind Kind { get; init; }
|
||
public required JobSchedule Schedule { get; init; }
|
||
public string? Text { get; init; } // reminder, prompt
|
||
public string? Command { get; init; } // watcher
|
||
public int? TimeoutSeconds { get; init; } // watcher only; default 300, max 3600
|
||
public required DateTimeOffset CreatedAt { get; init; }
|
||
public DateTimeOffset? LastRunAt { get; init; }
|
||
public JobResult? LastResult { get; init; }
|
||
}
|
||
```
|
||
|
||
Validation, shared by the tool and the store loader. The name is required and at most 80 characters.
|
||
Exactly one of `at`/`cron` is set. The cron expression parses with Cronos `CronFormat.Standard`. A one-off `at` must be
|
||
in the future when created (with 60 s of grace). Reminders and prompts need `text` (at most 4000 characters) and no
|
||
`command`. Watchers need `command` and no `text`. `timeoutSeconds` must be between 1 and 3600 and is only allowed on
|
||
watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error is a sentence the model can act on.
|
||
|
||
### 2. `JobStore`
|
||
|
||
`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. 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)
|
||
|
||
`RunAsync(ScriptRequest(command, cwd, timeout, target, jobId, timeZoneId), ct) → ScriptOutcome` (`Silent` |
|
||
`Output(text)` | `Failed(exitCode?, stderrTail, timedOut)`).
|
||
|
||
- The shell matches the built-in `bash` tool, so a script the agent tested with `bash` behaves the same when it's
|
||
scheduled: `/bin/sh -c <command>` on Unix and `cmd.exe /d /s /c` on Windows. It's overridable through
|
||
`SchedulerOptions.Shell`. On Unix the command is passed through `ArgumentList`, not quoted by hand. On Windows
|
||
`cmd.exe` doesn't parse `ArgumentList`'s quoting, so the command goes through `Arguments` as
|
||
`/d /s /c "<command>"`, exactly as the `bash` tool does. A configured `SchedulerOptions.Shell` always uses
|
||
`ArgumentList`.
|
||
- The working directory is the target's workspace (created if missing). Stdin is closed.
|
||
- The command, cwd and environment come from `IProcessSpawnPolicy.Prepare` (DI singleton, registered by
|
||
`AddAgenticCore`) with `Purpose = ScheduledScript`, `Scope = target`, `Origin = job id` and `TimeZoneId` = the
|
||
target's zone. So the script sees the same filtered environment as `bash`, with `TZ` set to the chat's zone, and
|
||
host hooks apply. There's no scheduler-specific scrub list any more (`SchedulerOptions.ScrubbedEnvironment` is
|
||
removed). A policy that throws is `Failed(null, "process environment hook failed: …")`. Session-level
|
||
`ProcessSpawnEvent` handlers don't run: a job fires without a live session.
|
||
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A timeout is `Failed(null, stderrTail, timedOut: true)`;
|
||
a cancellation throws `OperationCanceledException` after the kill. A shell that fails to start (or a workspace that
|
||
can't be created) is `Failed(null, "failed to start <shell>: …")`, never an exception.
|
||
- Output capture as in the reference, then `Trim()` on stdout.
|
||
|
||
### 4. `JobScheduler` (process-wide, the slice's entry type)
|
||
|
||
```csharp
|
||
public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
|
||
{
|
||
Task StartAsync(IJobDelivery delivery, CancellationToken ct); // load, fire overdue one-offs, arm timer
|
||
Task StopAsync(CancellationToken ct); // disarm, kill running scripts, wait ≤ 10 s
|
||
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
|
||
}
|
||
|
||
public interface IJobDelivery
|
||
{
|
||
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
|
||
Task SendTextAsync(string target, string markdown, CancellationToken ct);
|
||
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
|
||
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
|
||
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
|
||
string WorkspaceFor(string target);
|
||
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
|
||
bool IsDeliverable(string target);
|
||
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
|
||
TimeZoneInfo TimeZoneFor(string target);
|
||
}
|
||
```
|
||
|
||
The delivery is passed to `StartAsync` and not to the constructor. That breaks the cycle
|
||
ChatRegistry → session factory → scheduler extension → scheduler → delivery → ChatRegistry.
|
||
|
||
Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the tick):
|
||
|
||
| Kind | Action | Message format |
|
||
|---|---|---|
|
||
| reminder | `SendTextAsync` | `⏰ <text>`; fired more than 60 s late: `⏰ <text> (due <d MMM HH:mm>, delivered late)` |
|
||
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>`; fired more than 60 s late, the note gets its own line after the header: `[Scheduled job "<name>" (<id>)]\nThis was due at <d MMM HH:mm>; the bot was offline.\n<text>` |
|
||
| watcher, matched (exit 0, non-empty stdout) | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout>` |
|
||
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only `LastRunAt`/`LastResult` are updated |
|
||
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n````; with no exit code it reads `failed (timed out)` or `failed (did not start)` |
|
||
|
||
- Watcher matches never get a late note (a watcher only runs when its occurrence comes due; there's no catch-up).
|
||
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `IsDeliverable`, `WorkspaceFor` and `TimeZoneFor`.
|
||
It never calls `RunPromptAsync` or `SendTextAsync`, and the Telegram delivery's `WorkspaceFor`/`TimeZoneFor` don't
|
||
create or look up a chat session (nor does `IsDeliverable`). Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
|
||
`Failed` → at most one `SendTextAsync` per failure streak, `Silent` → nothing.
|
||
- **Redaction.** A watcher's stdout goes through `redactor.Redact` before it's put into the prompt, and the stderr
|
||
tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known
|
||
secret values become `[REDACTED]` (process-environment-hook spec §9). `LastResult.StderrTail` is stored redacted.
|
||
- A failure repeats the previous one when the previous `LastResult` was also a failure with the same `ExitCode` and
|
||
`TimedOut`. A repeat isn't sent.
|
||
- For a prompt (and a watcher match), "in flight" lasts until `RunPromptAsync` completes, i.e. until the run
|
||
that handled it settles. So a 5-minute job over a 20-minute run skips occurrences instead of piling up follow-ups.
|
||
- `LastRunAt`/`LastResult` are updated and persisted after each fire. A one-off is removed after its fire completes.
|
||
- A delivery exception is logged and swallowed. The Telegram channel already retries a send (1 s, 3 s) and then
|
||
throws `ChatDeliveryException`. The job's state still advances: there's no redelivery queue.
|
||
- `CancelAsync` removes the job. If that job's watcher script is running it's killed and its outcome thrown away. A
|
||
prompt run that's in progress isn't aborted: that's what `/stop` is for.
|
||
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances); a skipped one-off
|
||
stays in the store and fires again after the next start.
|
||
- Saves are serialised and each one snapshots the jobs inside that gate, so a save never overwrites newer state.
|
||
- `TimeZoneChangedAsync(target)` recomputes `NextRunAt` for the target's cron jobs in the new zone (strictly after now,
|
||
no catch-up), leaves one-offs untouched, re-arms the timer and returns
|
||
`ZoneChange(int CronJobs, int OneOffs)`. A cron occurrence already in flight isn't affected.
|
||
|
||
### 5. `SchedulerExtension` and the `schedule` tool (per session)
|
||
|
||
`SchedulerExtension(JobScheduler scheduler, string target)` registers `ScheduleTool` (`Tool<ScheduleArgs>`):
|
||
|
||
```
|
||
schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
|
||
at?, cron?, text?, command?, timeoutSeconds?)
|
||
```
|
||
|
||
- `create` returns the job line (see below), its next fire time in the chat's zone, and `Now: <local time> (<zone>)`,
|
||
so the model can see a wrong date or zone straight away. `list` returns the target's jobs plus the same "Now" line.
|
||
`cancel` needs `id`.
|
||
- `PromptSnippet`: `schedule: reminders, recurring prompts and watcher scripts that message this chat`.
|
||
- `PromptGuidelines`:
|
||
- Times are in the chat's zone (shown in the system prompt); `at` is ISO 8601; cron has 5 fields. The zone isn't
|
||
repeated in the guideline, so the tool's prompt text stays the same when the zone changes.
|
||
- Use `reminder` for fixed text and `prompt` when the job needs you to think or use tools.
|
||
- Watcher scripts run with the same shell as `bash`, from the workspace, without you: print nothing and exit 0 while
|
||
the condition isn't met (that costs nothing). Print the facts only when it is met; the output comes back to you
|
||
as a prompt, and you write the message. Exit non-zero only on a real error.
|
||
- Put reusable scripts in `scripts/` and test them with `bash` before scheduling.
|
||
- It isn't registered when the extension is loaded through discovery without a host-registered `JobScheduler`: the
|
||
constructor can't be resolved, and that shows up as an ordinary extension load error.
|
||
|
||
### 6. Telegram host wiring
|
||
|
||
- **Options**: a new `Scheduler` section bound to `SchedulerOptions`: `Enabled` (true),
|
||
`StorePath` (`<AgentDir>/scheduler/jobs.json`), `DefaultScriptTimeout` (300 s), `MaxJobsPerTarget` (50) and
|
||
`Shell`. There's no time zone and no scrub list here: zones are per chat (§7), the environment comes from
|
||
the process environment hook. An empty `StorePath` means `<AgentDir>/scheduler/jobs.json`; `Validate` rejects a
|
||
`DefaultScriptTimeout` outside 1 s–1 h, `MaxJobsPerTarget` < 1 and empty `Shell` entries (exit 2).
|
||
- **`Program.cs`** registers `SchedulerOptions`, `IScriptRunner` → `ScriptRunner`, `TimeProvider.System` (one
|
||
`TryAddSingleton`, shared with the message-context and attachments specs), `JobScheduler`,
|
||
`TelegramJobDelivery`, `ChatSettingsStore`, `ChatTimeZoneService` (§7; registered even when the scheduler is
|
||
disabled) and `SchedulerHostedService`. The hosted service calls `StartAsync(delivery)` and
|
||
`StopAsync`, and is registered after `PollingService`.
|
||
- **`ChatRegistry.CreateAsync`** adds
|
||
`ExtraExtensions = [ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, chatId.ToString(CultureInfo.InvariantCulture)))]`.
|
||
- **`TelegramJobDelivery`** is built on `ChatRegistry` and `TelegramOptions`:
|
||
- `SendTextAsync` → `(await chats.GetAsync(chatId)).Channel.SendTextAsync(markdown, ct)`, i.e. the chat session's
|
||
`IChatChannel` (`TelegramChatChannel`, attachments spec). No bespoke delivery code: the channel goes through the
|
||
renderer queue (`ChatRenderer.RunInOrderAsync`) and the reply path (split at 4096, HTML with a plain fallback,
|
||
retries, status message pushed to the bottom), which keeps a reminder in order with a reply that's being
|
||
rendered.
|
||
- `RunPromptAsync` → prefixes the prompt with `MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target))`
|
||
on its own line (message-context spec), then `session.PromptAsync(prompt, null, StreamingBehavior.FollowUp)`. When it queued rather than
|
||
ran, it then awaits `session.WaitForIdleAsync()`. Exceptions are reported through `chat.Report` as for user
|
||
prompts. It doesn't go through `UpdateRouter` or the chat's `ChatInbox` (message-context spec), so a scheduled
|
||
prompt is never debounced into a user's message and can never answer a pending `InputAsync` question (e.g. the
|
||
owner's Claude Code login code prompt).
|
||
- `IsDeliverable` → the chat id is in `AllowedUserIds` (private chat id = user id).
|
||
- `WorkspaceFor` → `ChatRegistry.WorkspaceFor` (a path computation; no session is created).
|
||
- `TimeZoneFor` → `ChatSettingsStore.TimeZoneFor(chatId)` (§7).
|
||
- **Commands** (`ChatCommands`, added to `Menu` and `/help`):
|
||
- `/jobs` lists this chat's jobs, or `No scheduled jobs.`:
|
||
```
|
||
**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03)
|
||
`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00
|
||
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00
|
||
`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)
|
||
```
|
||
The header and every time are in the chat's zone.
|
||
- `/cancel <id>` → `Cancelled k7m2q (Call the dentist).` `No job k7m2q in this chat.` also covers another chat's id.
|
||
With no id: `Usage: /cancel <id> (see /jobs).`
|
||
- `/status` gains `**Jobs:** <n>`.
|
||
- With the scheduler disabled, `/jobs` and `/cancel` stay in the menu and reply
|
||
`Scheduling is turned off on this bot (Scheduler:Enabled).`; `/status` has no Jobs line.
|
||
- **Security**: scheduled scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that
|
||
every allow-listed user already has, and the same filtered environment. The existing Security section in
|
||
`docs/telegram-bot.md` gains that sentence.
|
||
|
||
### 7. Per-chat time zones (Telegram host)
|
||
|
||
- **`ChatSettingsStore`** (`Chats/ChatSettingsStore.cs`): `<AgentDir>/telegram/chat-settings.json`,
|
||
`{ "version": 1, "chats": { "<chatId>": { "timeZone": "Asia/Tokyo" } } }` in `VolcanicJson` camelCase. Loaded once
|
||
and cached in memory; written atomically (temp file + rename) through one `SemaphoreSlim`, like `JobStore`. A
|
||
corrupt file is set aside and loading starts empty; a stored id that no longer resolves is logged and ignored (the
|
||
chat follows the default). `TimeZoneFor(chatId)` → the stored zone, else the default. It's a host type, not a
|
||
scheduler one: it exists with the scheduler disabled, and it's the place for later per-chat settings.
|
||
- **Default**: `Telegram:DefaultTimeZone` (env shortcut `DEFAULT_TIMEZONE`, empty = unset), else
|
||
`TimeZoneInfo.Local`. An unknown id is a startup validation error (exit 2, like the rest of `TelegramOptions`). It
|
||
also sets `AgenticCoreOptions.DefaultTimeZone` (§8), so headless use of the core in the same process agrees.
|
||
- **Parsing** (`ChatTimeZones.TryParse(string, out TimeZoneInfo, out string error)`), shared by the command and the
|
||
tool: trim, `TimeZoneInfo.FindSystemTimeZoneById`. A Windows id is accepted and converted to IANA
|
||
(`TimeZoneInfo.TryConvertWindowsIdToIanaId`); the IANA id is what's stored. `Local` and bare offsets (`+03:00`,
|
||
`GMT+3`) are rejected with `Unknown time zone "<x>". Use an IANA name such as Europe/Berlin or America/New_York.`
|
||
- **`ChatTimeZoneService.SetAsync(chatId, zone | null)`** is the one path for a change: write the store (null removes
|
||
the entry, back to the default), `session.SetTimeZone(zone)` on the chat's live session if there is one (§8), then
|
||
`scheduler.TimeZoneChangedAsync(target)` when the scheduler is enabled. It returns the text both entry points reply
|
||
with:
|
||
`Time zone set to Asia/Tokyo (UTC+09:00). Now 27 Sep 02:15. 2 recurring jobs now follow this zone; 1 one-off job
|
||
keeps its time (now 27 Sep 16:00 here).` The job clause is left out when the chat has no jobs.
|
||
- **`/timezone`** (`ChatCommands`, added to `Menu` and `/help`): `/timezone Asia/Tokyo` sets, `/timezone reset`
|
||
returns to the default, `/timezone` alone shows `Time zone: Europe/Berlin (UTC+02:00, default). Change it with
|
||
/timezone <IANA name>, e.g. /timezone America/New_York.` It runs as a host command, outside the agent loop.
|
||
- **`set_timezone` tool**: `ChatRegistry.CreateAsync` adds a small host extension
|
||
(`ChatSettingsExtension(chatId, service)`, through `ExtraExtensions` next to `SchedulerExtension`) that registers
|
||
`set_timezone(zone)`. Guideline: `Use set_timezone only when the user tells you their location or time zone
|
||
changed; confirm the new zone in your reply.` Errors from `TryParse` become error results. It runs whether or not the
|
||
scheduler is enabled.
|
||
- `/status` gains `**Time zone:** <id> (<offset>)`.
|
||
- `ChatTimeZoneService` finds the live session through a function (`ChatRegistry.Find`) so DI has no ChatRegistry ↔
|
||
service cycle; with no scheduler, `/timezone` replies without the job clause.
|
||
|
||
### 8. Session time zone (core)
|
||
|
||
The session carries its zone, so the system prompt and `bash` agree with the chat. `AgentSessionOptions.TimeZone`,
|
||
`AgenticCoreOptions.DefaultTimeZone`, `AgentSession.TimeZone` and `IExtensionContext.TimeZone` already exist (added by
|
||
the process-environment-hook spec, together with `TimeZoneIds.ToIana`); this spec adds `SetTimeZone`, the prompt change
|
||
and Claude Code's `TZ`:
|
||
|
||
- `AgentSessionOptions.TimeZone` (`TimeZoneInfo?`) and `AgenticCoreOptions.DefaultTimeZone` (`TimeZoneInfo?`, null =
|
||
`TimeZoneInfo.Local`). The session's zone is the first that is set.
|
||
- `AgentSession.TimeZone` and `AgentSession.SetTimeZone(TimeZoneInfo)`. A change applies from the next run: the system
|
||
prompt is rendered per run (`BeforeAgentStartEvent`), so a run in progress keeps its prompt. It isn't persisted in
|
||
session entries: the host owns persistence (§7), and a headless host passes the option each time.
|
||
- `IExtensionContext.TimeZone` (Abstractions) exposes it to tools and extensions. `schedule` doesn't need it (it asks
|
||
`JobScheduler.TimeZoneFor(target)`), but the calendar and MCP do (their specs).
|
||
- `IExtensionContext.PrepareProcessAsync` passes it as `TimeZoneId`, so `bash`'s `TZ` is the chat's zone and `date`
|
||
prints local time (`2026-09-26-process-environment-hook-design.md` §3–§4).
|
||
- **System prompt.** `SystemPromptBuilder` takes a `TimeProvider` and a `Func<TimeZoneInfo>` instead of
|
||
`Func<DateTimeOffset>`, and the environment section becomes:
|
||
|
||
```
|
||
Current date: 2026-09-26 (Saturday)
|
||
Time zone: Europe/Berlin (UTC+02:00)
|
||
Working directory: /data/workspaces/111111111
|
||
```
|
||
|
||
The date is the date **in the session's zone**. The time of day is deliberately left out: the prompt is rendered
|
||
at every run, and a clock time would change the start of the cached prefix on every run (the Claude Code provider
|
||
replays the whole transcript and relies on the prompt cache). Date and offset change at most daily. The model gets
|
||
the exact time from the local-time header on every user message and scheduled prompt
|
||
(`2026-09-26-telegram-message-context-design.md`; host-side, appended so it doesn't touch the cached prefix), from
|
||
`schedule` (its "Now" line) or from `date` in `bash`.
|
||
- **Claude Code's `TZ`.** The Claude Code provider passes the session's zone to its `claude -p` child explicitly: the
|
||
extension gives `ClaudeCodeApiOptions` a `Func<string?>` that reads `IExtensionContext.TimeZone` per request (so
|
||
`SetTimeZone` applies from the next model call), and `ClaudeArgs.BuildEnvironment` sets `TZ` to that IANA id after
|
||
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. 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.
|
||
|
||
### 9. Docs
|
||
|
||
The implementation updates `docs/telegram-bot.md` (a Scheduling section: examples, the watcher convention and that
|
||
a match always goes to the agent, `/jobs`, `/cancel`, `/timezone`, `Scheduler:*` and `Telegram:DefaultTimeZone`
|
||
settings, the restart behaviour, what a zone change does to jobs), `docs/architecture.md` (the extension list and the
|
||
session time zone) and the command table in `CLAUDE.md`.
|
||
|
||
## Testing
|
||
|
||
xunit 2. No test calls a paid API. Time is `FakeTimeProvider` (`Microsoft.Extensions.TimeProvider.Testing`, in
|
||
`Volcanic.AgenticCore.Extensions.Tests` and `Volcanic.AgenticCore.Telegram.Tests`; shared with the message-context and
|
||
calendar specs). Zones are fixed (`Europe/Berlin`, `Asia/Tokyo`), never the machine's.
|
||
|
||
- **Schedules** (`tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/`): next cron occurrence strictly after `from`,
|
||
in the zone. DST spring-forward and fall-back days give one fire each. An `at` without an offset is read in the zone.
|
||
Invalid cron, seconds fields, `H`, and a past `at` are rejected with readable messages.
|
||
- **Store**: missing file → empty. Round trip leaves no temp file. A corrupt file is set aside and loading starts
|
||
empty. Invalid entries are dropped. Concurrent saves are serialised.
|
||
- **`JobScheduler`**, with a fake `IJobDelivery` (records calls, lets a test hold `RunPromptAsync` open) and a fake
|
||
`IScriptRunner`:
|
||
- An overdue one-off fires on start, with the late note past 60 s, and is removed only after delivery.
|
||
- Cron doesn't catch up after a simulated downtime.
|
||
- Advancing the clock fires, and the 1 h cap re-arms without firing.
|
||
- A reminder is sent as text and a prompt as a prompt.
|
||
- **Watcher invariant**: a watcher every minute whose fake runner returns `Silent` for N = 10 ticks → 10 runner
|
||
calls, zero `RunPromptAsync`, zero `SendTextAsync`, and only `LastRunAt`/`LastResult` changed in the store. The
|
||
11th tick returns `Output("price 48")` → exactly one `RunPromptAsync` with the "watcher script printed" text,
|
||
still zero `SendTextAsync`.
|
||
- A failure is reported once. Same exit code again → silent. A different exit code or a timeout → reported. A
|
||
success followed by a failure → reported.
|
||
- Overlapping occurrences are skipped while a watcher runs, and while a prompt run is held open.
|
||
- Targets are isolated in `List` and `CancelAsync`.
|
||
- Cancel kills a running watcher and drops its output.
|
||
- `IsDeliverable == false` skips the fire and keeps the job.
|
||
- `StopAsync` kills scripts and returns within the grace period.
|
||
- Zone change: a `0 9 * * *` job and a one-off in `Europe/Berlin`; the fake delivery switches the target to
|
||
`Asia/Tokyo` and `TimeZoneChangedAsync` runs → the cron job's next run is 09:00 Tokyo, the one-off's instant is
|
||
unchanged, the timer is re-armed, the counts are (1, 1). Another target's jobs don't move.
|
||
- Redaction: with a fake redactor for `hunter2-secret`, a watcher whose output contains it → the prompt says
|
||
`[REDACTED]`; a failure whose stderr contains it → the alert and the stored `StderrTail` say `[REDACTED]`.
|
||
- **`ScriptRunner`** (real processes, Unix-only like the existing script-based tests, with a real
|
||
`ProcessSpawnPolicy` over an injected environment snapshot): output, silent, a failure with a 20-line stderr tail, a
|
||
timeout kills the process tree, the capture is capped, `TELEGRAM_BOT_TOKEN` and `OPENAI_API_KEY` are absent, `TZ`
|
||
is the target's zone, a throwing host hook gives `Failed` without starting a process, and the cwd is the workspace.
|
||
- **`schedule` tool**: create, list and cancel through `ToolInvocation`, validation errors as error results,
|
||
`list`/`cancel` never see another target's jobs, the "Now" line uses the target's zone, and an offset-less `at` is
|
||
read in the target's zone.
|
||
- **Session zone and prompt** (`tests/Volcanic.AgenticCore.Tests`): with `FakeTimeProvider` at 2026-09-26T23:30Z, a
|
||
session in `Asia/Tokyo` renders `Current date: 2026-09-27 (Sunday)` and `Time zone: Asia/Tokyo (UTC+09:00)`, and one
|
||
in `Europe/Berlin` renders 2026-09-27 too while `America/New_York` renders 2026-09-26. `SetTimeZone` during a run
|
||
changes the next run's prompt, not the current one. Without options the default is `TimeZoneInfo.Local`.
|
||
- **Claude Code `TZ`** (`tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/`): `BuildEnvironment` with a zone id
|
||
sets `TZ`, without one leaves it unset, and a parent `TZ` is never copied; the fake-CLI request test sees the
|
||
session's zone in the child's environment and a new zone after `SetTimeZone`.
|
||
- **Telegram** (`ChatHost` + `FakeBotClient` + `FauxProvider`, with a real `JobScheduler` on a temp store path and
|
||
`FakeTimeProvider`):
|
||
- A reminder is sent to the right chat, the Faux provider saw no request, and the session transcript has no new
|
||
entry.
|
||
- A watcher over a real script that prints nothing, advanced through several ticks: the Faux provider saw no
|
||
request, no session was created for the chat (`ChatRegistry.Find` is null), no message was sent. The script is
|
||
then changed to print a line: the next tick produces exactly one Faux request and the agent's reply is rendered.
|
||
- A prompt job on an idle chat → the agent's scripted reply is rendered, and the faux request's last user message
|
||
starts with the timestamp header at the fire time in the chat's zone.
|
||
- A prompt job while a user run is active: the faux turn is held, then the clock advances. The job's prompt is
|
||
queued as a follow-up (a `QueueUpdate` with one follow-up and no steering). The user's reply is rendered before the
|
||
job's reply.
|
||
- The Faux provider scripts a `schedule` tool call that creates a job for its own chat, and `/jobs` in another chat
|
||
doesn't show it.
|
||
- `/cancel` with its own id, an unknown id, and another chat's id.
|
||
- Restart: dispose the host and start a new `ChatHost` on the same store. The overdue one-off is delivered with the
|
||
late note, and the cron job isn't caught up.
|
||
- While the owner has a pending login code prompt, a scheduled prompt doesn't answer it.
|
||
- `/timezone Asia/Tokyo`: the store has the zone, the reply names it and the job counts, `/jobs` shows Tokyo times,
|
||
and the next faux request's system prompt says `Asia/Tokyo`. `/timezone Mars/Olympus` → the error text, nothing
|
||
stored. `/timezone reset` → the default. A restart keeps the zone. A scripted `set_timezone` call does the same
|
||
as the command. Another chat's zone is untouched.
|
||
|
||
## Out of scope
|
||
|
||
- Chats other than private ones, and per-job time zones (a cron job always follows its chat's current zone).
|
||
- Editing a job in place (cancel and create again), pausing, run history beyond `LastResult`, and catch-up policies
|
||
for cron.
|
||
- Seconds-level cron, jitter, and a heartbeat that wakes the agent without a job.
|
||
- A direct (model-free) delivery mode for watcher output (see Decisions).
|
||
- Sending files from direct jobs (a `prompt` job can use the `send` tool from
|
||
`2026-09-26-telegram-attachments-design.md`), and a sandbox for scripts.
|
||
- Adding direct deliveries to the transcript (see Decisions).
|
||
- Scheduler support in the headless sample. Any host can implement `IJobDelivery`.
|