Files
Playa/docs/superpowers/specs/2026-09-26-scheduler-design.md
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
2026-09-29 09:30:40 +00:00

506 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: implemented; the `reminder` kind was removed on 2026-09-29 (see "Update" below)
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).
## Update (2026-09-29): prompts and watchers only
The `reminder` kind is gone: a reminder is a `prompt` job whose text tells the agent what to remind the user of, so
the agent writes it and it lands in the transcript like any other scheduled prompt. `JobKind` is `Prompt | Watcher`,
the `schedule` tool accepts only `prompt` and `watcher`, and `IJobDelivery.SendTextAsync` is used only for watcher
failure alerts. `JobStore` loads a stored `"kind": "reminder"` entry as a prompt with the text `Remind me: <text>`
(the file is rewritten in the new form on the next save). The rest of this document describes the original
three-kind design.
## 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 28 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`.