Files
Playa/docs/superpowers/specs/2026-09-26-scheduler-design.md
EugeneTes 28bbfbc759 feat(scheduler): store jobs per chat and import VolcanicSecretary job files
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the
scheduler loads every chat's file, moves the retired shared
<agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>.
A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted
in place for that chat; the original is kept as .secretary-<ts>.

Also adds the VolcanicSecretary data migration plan.
2026-10-02 07:29:48 +00:00

42 KiB
Raw Blame History

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 file per target (changed 2026-10-02; originally one central <AgentDir>/scheduler/jobs.json): <workspace>/.volcanic/scheduler/jobs.json, found on start through IJobDelivery.KnownTargets. The folder owns the jobs (a stored target is overridden). The agent's bash can write the file, but that's no more than the schedule tool already allows, and the file is only read on start. The old central file (SchedulerOptions.LegacyStorePath) is moved into the targets' files on start and renamed .migrated-<timestamp>. A VolcanicSecretary file (a bare array) in a target's folder is converted for that target. 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)

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)

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)orfailed (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), LegacyStorePath (<AgentDir>/scheduler/jobs.json, the retired shared file), 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 LegacyStorePath 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.