docs(scheduler): scheduling, per-chat time zones and the session zone

This commit is contained in:
EugeneTes
2026-09-27 10:40:43 +02:00
parent 709b2d2513
commit 126d3afff1
6 changed files with 92 additions and 18 deletions

View File

@@ -56,6 +56,8 @@ Read `docs/architecture.md` first. Key points:
- **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are
**instantiated per session** and each session has its own `ModelRegistry`, so provider registrations and extension
fields are per conversation. Process-wide state has to be explicit (e.g. `ClaudeLoginCoordinator.Shared`).
`AgentSession.SetTimeZone` changes the session's zone (system prompt from the next run); see "Time zone" in
`docs/architecture.md`.
- **Two event systems.** `AgentSessionEvent`s go to hosts via `session.Subscribe` and are invoked synchronously on the
loop. `ExtensionEvent`s go to extensions via `api.On<T>`: they are mutable event args (`Block`, `Cancel`,
`MarkHandled`, set `Text`/`Message`/...), run sequentially in load order, and some short-circuit.
@@ -80,9 +82,11 @@ Read `docs/architecture.md` first. Key points:
Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`); and the `GoogleCalendar` feature extension (`calendar` tool,
preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens under
`<agentDir>/google-calendar/`; `docs/google-calendar.md`). The Claude Code provider is the most intricate piece
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`):
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
@@ -108,6 +112,11 @@ with a local-time header. Files, audio and voice are saved to `<workspace>/inbox
(`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through
`ChatRenderer.RunInOrderAsync`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry.
Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/telegram/chat-settings.json`, default
`Telegram:DefaultTimeZone`) and is changed through `ChatTimeZoneService` (`/timezone`, the `set_timezone` tool); it
feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts
through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox)
and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs.
Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
## Conventions

View File

@@ -5,7 +5,7 @@
```
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
extensions/* provider and feature extensions (MCP client, Google Calendar), built on the public API only
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only
samples/* reference host
tests/* xunit
```
@@ -21,6 +21,10 @@ directories.
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices
The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features`
@@ -55,6 +59,15 @@ is the composition root of one conversation; a host never touches the other slic
Extensions are instantiated per session, so extension fields are per conversation. Provider registrations
also live per session (a `ModelRegistry` per session), which keeps sessions independent.
## Time zone
A session's zone is the first that is set of `SetTimeZone`, `AgentSessionOptions.TimeZone`,
`AgenticCoreOptions.DefaultTimeZone` and `TimeZoneInfo.Local`. It is exposed as `IExtensionContext.TimeZone`, becomes
`TZ` for the session's processes (`PrepareProcessAsync`) and for Claude Code's `claude -p`, and fills the system
prompt's `Current date` / `Time zone` lines. The prompt has the date only and is rendered per run, so a change applies
from the next run and the cached prompt prefix changes at most daily. The session doesn't persist its zone; a host
that lets users change it keeps it (the Telegram host in `chat-settings.json`).
## The agent loop
`PromptAsync(text)`:
@@ -113,9 +126,9 @@ Two independent event systems exist:
## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and, with
its spec, scheduler scripts) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through
`AgenticCoreOptions.ProcessEnvironment`:
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and
scheduler watcher scripts, through the DI `IProcessSpawnPolicy` since jobs fire without a session) gets its command,
cwd and environment from one `IProcessSpawnPolicy`, configured through `AgenticCoreOptions.ProcessEnvironment`:
```
inherit the host environment → filter (Deny, Allow, SecretValues) → explicit request.Environment (never filtered)
@@ -136,7 +149,7 @@ dumpable.
results, progress updates and error messages (see the loop above), so every tool is covered in one place.
The Claude Code provider's own `claude` processes are outside the policy on purpose: they run the model and already
get an allow-listed environment. None of this is a sandbox: the agent still runs as the host's OS user and can read
get an allow-listed environment (plus the session's `TZ`, set by the provider). None of this is a sandbox: the agent still runs as the host's OS user and can read
the files that user can read.
## Persistence

View File

@@ -1,7 +1,7 @@
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
Date: 2026-09-26
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md; the scheduler `ScriptRunner` comes with its own spec)
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md, the scheduler `ScriptRunner` with 2026-09-26-scheduler-design.md)
Used by: `2026-09-26-scheduler-design.md` (watcher scripts), `2026-09-26-mcp-client-playwright-design.md` (MCP
server processes), `2026-09-26-telegram-attachments-design.md` (`OPENAI_API_KEY`), `2026-09-26-google-calendar-design.md`
(`GOOGLE_CLIENT_SECRET`), `2026-09-26-docker-deployment-design.md` (the secrets set in Coolify).
@@ -231,6 +231,7 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery)
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
*The two MCP rows are implemented in `extensions/Volcanic.AgenticCore.Extensions.Mcp` (`McpServerConnection`, `McpCatalogCache`).*
*The scheduler row is implemented in `extensions/Volcanic.AgenticCore.Extensions.Scheduler` (`ScriptRunner`).*
A spawner whose `Prepare` throws reports it the way it reports a failed start: `bash` returns an error result
`process environment hook failed: <message>`, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the

View File

@@ -1,7 +1,7 @@
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: draft
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`
@@ -346,6 +346,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
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)

View File

@@ -4,9 +4,8 @@ Date: 2026-09-26
Status: implemented
Depends on: `2026-09-26-telegram-attachments-design.md`, which plugs its content steps (files, speech) into
`InboundComposer` and uses the renderer's `RunInOrderAsync` for notices and the `🎤` echo.
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; if this spec
lands first, the header uses the session's zone (`AgentSession.TimeZone`, which falls back to `TimeZoneInfo.Local`)
through `ChatRegistry.TimeZoneFor` until the store exists).
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; the
header's zone comes from `ChatRegistry.TimeZoneFor`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`).
## Problem
@@ -224,8 +223,7 @@ on `/new`, `/stop` and a `/resume` that names exactly one conversation, before i
`TimeProvider.System`; `Program.cs` registers `TimeProvider.System` with `TryAddSingleton`), and an
`onFlush` that:
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
(`ChatRegistry.TimeZoneFor(chatId)`, which returns the chat session's `TimeZone` until the scheduler spec's
`ChatSettingsStore` exists and then delegates to `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
(`ChatRegistry.TimeZoneFor(chatId)`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
a `/timezone` change applies to the next batch). An image-only batch still gets the header line as its text;
2. if `session.IsStreaming`, calls `sender.ReactAsync(chatId, id, "👀")` for each part's message id, fire-and-forget;
3. calls `chat.PromptInBackground(text, images)`.

View File

@@ -23,7 +23,7 @@ Open the bot in Telegram and say hello.
## Configuration
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google
settings use the `GoogleCalendar` section):
settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section):
| Setting | Default | Notes |
|---|---|---|
@@ -34,6 +34,12 @@ settings use the `GoogleCalendar` section):
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
| `DEFAULT_TIMEZONE` / `Telegram:DefaultTimeZone` | — (the machine's zone) | IANA zone for chats that haven't set one with `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup. Also the default for the core (`AgenticCoreOptions.DefaultTimeZone`) |
| `Scheduler:Enabled` | `true` | Reminders, scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
| `Scheduler:StorePath` | `<agent dir>/scheduler/jobs.json` | One file for every chat's jobs, outside the workspaces |
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
| `Scheduler:Shell` | `/bin/sh -c` (`cmd.exe /d /s /c` on Windows) | The watcher shell as a list, command appended; same as `bash` by default |
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription on. Hidden from `bash` and redacted from tool output |
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
@@ -49,7 +55,8 @@ settings use the `GoogleCalendar` section):
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
its most recent conversation.
its most recent conversation. Each chat's time zone is kept in `<agent dir>/telegram/chat-settings.json`, so it
survives `/new` and restarts.
## Using it
@@ -73,7 +80,7 @@ Along with your text, the agent sees:
- **Replies and quotes.** Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets
a line such as `[Reply to your message: "Which plant do you mean?"]` before your text.
- **Forwards**, labelled with their source: `[Forwarded from Daily News (Anna K.)]`.
- **The local time** you sent the message, in the chat's time zone (for now, the zone of the machine running the bot):
- **The local time** you sent the message, in the chat's time zone (`/timezone`; by default `Telegram:DefaultTimeZone`, else the zone of the machine running the bot):
`[Sat 2026-09-26 14:03 Europe/Berlin]`, once per request.
Messages sent within about 0.6 s of each other (`Telegram:InputDebounce`), such as a forward with your comment or
@@ -88,10 +95,13 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and
|---|---|
| `/new` | Start a new conversation |
| `/stop` | Stop the current run |
| `/status` | Model, thinking level, session, context usage, workspace |
| `/status` | Model, thinking level, session, context usage, time zone, number of scheduled jobs, workspace |
| `/sessions`, `/resume <id>` | List and resume earlier conversations (an id prefix is enough) |
| `/compact` | Summarise the conversation to free context |
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/timezone [name\|reset]` | Show or set this chat's time zone (IANA name such as `Asia/Tokyo`; `reset` returns to the default) |
| `/jobs` | This chat's scheduled jobs with their next run |
| `/cancel <id>` | Cancel one of them |
| `/help` | Short help |
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
| `/calendar_status` | The connected account and time zone, or the pending code |
@@ -135,6 +145,44 @@ as photos (up to 10 MB), other files as documents (up to 50 MB), with an optiona
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
chat's workspace and delivers only to that chat.
### Scheduling
Ask the agent in plain words. It has a `schedule` tool with three kinds of job:
- "Remind me at 9 tomorrow to call the dentist" is a **reminder**: at 9 the bot sends the text as is,
`⏰ Call the dentist`, with no model call.
- "Every weekday at 8, summarise what's in `inbox/`" is a **prompt**: when it fires, the agent handles it like a
message from you, with the same time header.
- "Check this page every 10 minutes and tell me only if the price drops below 50" is a **watcher**.
For a watcher the agent writes a script (usually in `scripts/`, tested with `bash` first). Every run the bot runs the
script itself, **without the agent**:
- exit 0 with no output means "nothing yet" and costs nothing: no model call, nothing in the conversation;
- exit 0 with output means the condition is met: the output always goes to the agent, which writes you the message
(one model call per match);
- a non-zero exit or a timeout is reported once, straight to the chat, with the last 20 lines of the script's errors.
Later identical failures stay quiet until the script succeeds again.
Scripts run in the chat's workspace with the same filtered environment as `bash` and `TZ` set to the chat's zone.
Known secrets are redacted from their output.
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never
interrupts. Occurrences that come due while the same job is still running are skipped.
Times are in the chat's zone. `/timezone Asia/Tokyo` (or telling the agent where you are; it has a `set_timezone`
tool) changes it for the date in the agent's instructions, `bash`, scheduled scripts, message timestamps and Claude
Code. Recurring jobs follow the new zone ("every day at 9" means 9 where you are now); one-off jobs keep their moment
and are just shown in the new zone. The reply says how many of each.
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, marked
`(due …, delivered late)` (for prompts: "This was due at …; the bot was offline."). Recurring jobs don't catch up:
missed occurrences are skipped.
Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Direct reminders and failure alerts aren't part
of the conversation, so the agent can't see them later; use a prompt job for things that should be. With
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
### Google Calendar
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
@@ -148,6 +196,9 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
container, and treat the workspace root as writable by anyone on the allow-list.
Scheduled watcher 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.
`send` refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails
visibly (it is no barrier against `bash cp`). The speech API key is kept out of the agent's processes like the bot
token.