forked from admin/VolcanicAgenticCore
Merge branch 'feat/scheduler'
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ Claude Code as a model provider without an API key.
|
||||
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
|
||||
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
|
||||
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
|
||||
| Scheduler (reminders, scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
|
||||
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
|
||||
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
|
||||
| Tests | `tests/` | xunit; no test calls a paid API |
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/hosts/">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,9 @@ conversation is replayed each time.
|
||||
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
|
||||
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
|
||||
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
|
||||
defeat prompt caching). Only an allow-list of environment variables is passed through, and compaction
|
||||
defeat prompt caching). Only an allow-list of environment variables is passed through, plus `TZ` set to
|
||||
the session's time zone (the chat's zone on Telegram, read per call so a `/timezone` change applies from
|
||||
the next model call; never the host's own `TZ`, and not on Windows, which ignores it), and compaction
|
||||
inside Claude is disabled: the core owns compaction. `claude` and its login/doctor processes don't go
|
||||
through the host's process spawn policy (`AgenticCoreOptions.ProcessEnvironment`): they already get a
|
||||
replaced, allow-listed environment. The Bridge inherits `claude`'s environment, and the core tools it
|
||||
|
||||
4689
docs/superpowers/plans/2026-09-26-scheduler.md
Normal file
4689
docs/superpowers/plans/2026-09-26-scheduler.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -61,7 +61,10 @@ same chat.
|
||||
- **Scheduled agent prompts are queued as follow-ups, never as steering.** When the chat is idle a prompt job starts a
|
||||
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.
|
||||
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
|
||||
@@ -72,7 +75,10 @@ same chat.
|
||||
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 zone is a chat setting, not a
|
||||
models often leave the offset out. An `at` with an offset is used as given. The date is required and only ISO 8601 is
|
||||
accepted (`yyyy-MM-dd`, or `yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]` with an optional `Z`/offset): a bare `09:00` or a culture
|
||||
format such as `09/10/2026` is rejected with a readable error, because it would be resolved against the machine's
|
||||
clock rather than the chat's. The zone is a chat setting, not a
|
||||
scheduler setting, because the system prompt and `bash` need it with the scheduler disabled too:
|
||||
- **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
|
||||
@@ -93,7 +99,9 @@ same chat.
|
||||
fires immediately. If it's more than 60 s late, direct text gets `(due <time>, delivered late)` and an agent prompt
|
||||
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).
|
||||
**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.
|
||||
@@ -153,7 +161,9 @@ watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error
|
||||
|
||||
`LoadAsync()` returns the jobs. A missing file means an empty list. A corrupt file is renamed to
|
||||
`jobs.json.corrupt-<timestamp>`, logged as an error, and loading starts empty: a bad file must not keep the host down.
|
||||
Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
|
||||
Invalid jobs are dropped with a warning. A stored cron job whose next occurrence can't be computed on start (for
|
||||
example a target the host can't map to a zone) is logged and left unarmed; it never fails `StartAsync`. The Telegram
|
||||
delivery's `TimeZoneFor` returns the default zone for a target that isn't a chat id. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
|
||||
`File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
|
||||
|
||||
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests)
|
||||
@@ -163,16 +173,20 @@ Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pi
|
||||
|
||||
- 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`. The command is passed through `ArgumentList`, not quoted by hand.
|
||||
- The working directory is the target's workspace. Stdin is closed.
|
||||
`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 shell that fails to start is `Failed(null, "failed to start <shell>: …")`,
|
||||
never an exception.
|
||||
- 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)
|
||||
@@ -185,6 +199,7 @@ public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner,
|
||||
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
|
||||
}
|
||||
@@ -195,11 +210,11 @@ public interface IJobDelivery
|
||||
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.</summary>
|
||||
/// <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.</summary>
|
||||
/// <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.</summary>
|
||||
/// <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);
|
||||
}
|
||||
```
|
||||
@@ -211,15 +226,16 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
|
||||
|
||||
| Kind | Action | Message format |
|
||||
|---|---|---|
|
||||
| reminder | `SendTextAsync` | `⏰ <text>` |
|
||||
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>` |
|
||||
| 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```` |
|
||||
| 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 invariant.** Running a watcher's script touches only `IScriptRunner`, `WorkspaceFor` and `TimeZoneFor`.
|
||||
- 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. Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
|
||||
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
|
||||
@@ -233,7 +249,9 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
|
||||
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).
|
||||
- `!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.
|
||||
@@ -267,7 +285,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
|
||||
- **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.
|
||||
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
|
||||
@@ -295,13 +314,15 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
|
||||
```
|
||||
**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03)
|
||||
`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00
|
||||
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00
|
||||
`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.
|
||||
@@ -336,6 +357,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)
|
||||
|
||||
@@ -374,7 +397,11 @@ and Claude Code's `TZ`:
|
||||
the allow-list, like its pinned `CLAUDE_CODE_*` flags. Anything Claude Code itself adds about the date then agrees
|
||||
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. This goes through the provider, not `IProcessSpawnPolicy`
|
||||
allow-list: the host's own `TZ` never leaks through. When the session has no configured zone, `claude -p` gets the
|
||||
IANA id of `TimeZoneInfo.Local` (it can't inherit `TZ`) while `bash` inherits the host's `TZ` (its `TimeZoneId` is
|
||||
null); both end up in the host's zone. All three id conversions follow one rule, `TimeZoneIds.ToIana` (mirrored by
|
||||
`ClaudeArgs.IanaZoneId` and the scheduler's `ZoneNames.Iana`, since extensions can't reference the core): null unless
|
||||
the id resolves to a system zone, so an unnamed `Local` sets no `TZ`. This goes through the provider, not `IProcessSpawnPolicy`
|
||||
(process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
|
||||
implementation.
|
||||
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -40,6 +40,9 @@ public static class ClaudeArgs
|
||||
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
|
||||
public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
|
||||
|
||||
/// <summary>The session's zone for the child (never inherited from the parent: <c>TZ</c> isn't on the allow-list).</summary>
|
||||
public const string TimeZoneVariable = "TZ";
|
||||
|
||||
private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
|
||||
/// <summary>Variables forwarded from the parent environment (compared case-insensitively). <c>LC_*</c> is forwarded as a prefix.</summary>
|
||||
@@ -143,16 +146,32 @@ public static class ClaudeArgs
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
|
||||
/// The complete environment a Claude child receives: the allow-listed parent variables, the pinned ones, and
|
||||
/// <c>TZ</c> = <paramref name="timeZoneId"/> (the session's zone; not on Windows, which ignores it).
|
||||
/// Throws <c>auth</c> when the parent process carries API-key or third-party-provider authentication.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens)
|
||||
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens, string? timeZoneId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parentEnv);
|
||||
if (FindDeniedVariable(parentEnv) is { } denied)
|
||||
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
|
||||
$"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
|
||||
return AllowListed(parentEnv, maxOutputTokens);
|
||||
var env = new Dictionary<string, string>(AllowListed(parentEnv, maxOutputTokens), StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(timeZoneId) && !OperatingSystem.IsWindows()) env[TimeZoneVariable] = timeZoneId;
|
||||
return env;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
|
||||
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
public static string? IanaZoneId(TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>
|
||||
|
||||
@@ -28,6 +28,13 @@ public sealed record ClaudeCodeApiOptions
|
||||
/// <summary>The environment the allow-list is applied to; the process environment when null.</summary>
|
||||
public IReadOnlyDictionary<string, string?>? ParentEnvironment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The IANA zone for the child's <c>TZ</c>, read per request (the extension reads the session's
|
||||
/// <c>IExtensionContext.TimeZone</c>, so <c>SetTimeZone</c> applies from the next model call). Null or a null result
|
||||
/// leaves <c>TZ</c> unset.
|
||||
/// </summary>
|
||||
public Func<string?>? TimeZoneId { get; init; }
|
||||
|
||||
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__<name>__<alias></c>).</summary>
|
||||
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
|
||||
|
||||
@@ -250,7 +257,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
|
||||
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
|
||||
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName));
|
||||
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
|
||||
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke());
|
||||
|
||||
if (streamOptions.OnPayload is not null)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
|
||||
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
|
||||
private string? _ownSessionId;
|
||||
private IExtensionContext? _context;
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
@@ -60,6 +61,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
LoginUi = _options.LoginUi,
|
||||
Coordinator = _options.Coordinator,
|
||||
LoginOptions = _options.LoginOptions,
|
||||
TimeZoneId = CurrentZoneId,
|
||||
};
|
||||
var claude = new ClaudeCodeApi(registry, apiOptions);
|
||||
Api = claude;
|
||||
@@ -83,6 +85,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
// so the second registration does not orphan the first.
|
||||
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
|
||||
_ownSessionId = ctx.SessionId;
|
||||
_context = ctx;
|
||||
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
|
||||
if (apiOptions.ClaudePath is null && ctx.HasUi)
|
||||
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
|
||||
@@ -92,7 +95,11 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
api.On<SessionShutdownEvent>((_, ctx, _) =>
|
||||
{
|
||||
registry.Remove(ctx.SessionId);
|
||||
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
|
||||
if (_ownSessionId == ctx.SessionId)
|
||||
{
|
||||
_ownSessionId = null;
|
||||
_context = null;
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
|
||||
@@ -109,4 +116,17 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>The session's zone as an IANA id, or null without a live session (e.g. a context gone stale after dispose).</summary>
|
||||
private string? CurrentZoneId()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _context is { } ctx ? ClaudeArgs.IanaZoneId(ctx.TimeZone) : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>What the host does for the scheduler. Passed to <see cref="JobScheduler.StartAsync"/>, not the constructor, to break the host's DI cycle.</summary>
|
||||
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 cancellationToken);
|
||||
|
||||
/// <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 cancellationToken);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
|
||||
public static class JobFormat
|
||||
{
|
||||
public static string Icon(JobKind kind) => kind switch { JobKind.Reminder => "⏰", JobKind.Prompt => "💬", _ => "🔍" };
|
||||
|
||||
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
|
||||
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
|
||||
TimeZoneInfo.ConvertTime(at, zone).ToString("d MMM HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary><c>14:10</c> when on the same local day as <paramref name="now"/>, else <see cref="DayTime"/>.</summary>
|
||||
public static string When(DateTimeOffset at, TimeZoneInfo zone, DateTimeOffset now)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTime(at, zone);
|
||||
return local.Date == TimeZoneInfo.ConvertTime(now, zone).Date
|
||||
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
|
||||
: DayTime(at, zone);
|
||||
}
|
||||
|
||||
/// <summary><c>Sat 2026-09-26 14:03</c> (the model-facing form: weekday and full date).</summary>
|
||||
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
|
||||
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
|
||||
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
|
||||
|
||||
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
|
||||
public static string FailureReason(JobResult result) => result switch
|
||||
{
|
||||
{ ExitCode: { } code, TimedOut: true } => $"exit {code.ToString(CultureInfo.InvariantCulture)}, timed out",
|
||||
{ ExitCode: { } code } => $"exit {code.ToString(CultureInfo.InvariantCulture)}",
|
||||
{ TimedOut: true } => "timed out",
|
||||
_ => "did not start",
|
||||
};
|
||||
|
||||
/// <summary><c>`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
|
||||
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
|
||||
{
|
||||
var job = view.Job;
|
||||
var sb = new StringBuilder().Append('`').Append(job.Id).Append("` ").Append(Icon(job.Kind)).Append(' ').Append(job.Name);
|
||||
if (job.Schedule.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
|
||||
else
|
||||
{
|
||||
sb.Append(" · `").Append(job.Schedule.Cron).Append('`');
|
||||
if (view.NextRunAt is { } next) sb.Append(" · next ").Append(When(next, zone, now));
|
||||
}
|
||||
if (job.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>The texts a fire sends (spec §4 table).</summary>
|
||||
internal static class JobMessages
|
||||
{
|
||||
public static string Reminder(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
|
||||
$"⏰ {job.Text}" + (lateDue is { } due ? $" (due {JobFormat.DayTime(due, zone)}, delivered late)" : "");
|
||||
|
||||
public static string Prompt(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
|
||||
$"{Header(job)}\n" + (lateDue is { } due ? $"This was due at {JobFormat.DayTime(due, zone)}; the bot was offline.\n" : "") + job.Text;
|
||||
|
||||
public static string WatcherMatch(ScheduledJob job, string output) => $"{Header(job)} The watcher script printed:\n{output}";
|
||||
|
||||
public static string Failure(ScheduledJob job, JobResult result) =>
|
||||
$"⚠️ Job \"{job.Name}\" ({job.Id}) failed ({JobFormat.FailureReason(result)}):\n```\n{(string.IsNullOrWhiteSpace(result.StderrTail) ? "(no output on stderr)" : result.StderrTail)}\n```";
|
||||
|
||||
private static string Header(ScheduledJob job) => $"[Scheduled job \"{job.Name}\" ({job.Id})]";
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide scheduler: loads <c>jobs.json</c>, arms one <see cref="TimeProvider"/> timer for the soonest job
|
||||
/// (capped at 1 h, re-armed on every tick, create, cancel, fire and zone change), and fires jobs through the host's
|
||||
/// <see cref="IJobDelivery"/>. A fire is tracked in flight and never awaited by the tick; an occurrence that comes due
|
||||
/// while the job is still in flight is skipped. A watcher's script run touches only the runner, <c>IsDeliverable</c>,
|
||||
/// <c>WorkspaceFor</c> and <c>TimeZoneFor</c>: a silent run makes no delivery call at all.
|
||||
/// </summary>
|
||||
public sealed class JobScheduler : IAsyncDisposable
|
||||
{
|
||||
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
|
||||
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
|
||||
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
|
||||
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
private readonly SchedulerOptions _options;
|
||||
private readonly IScriptRunner _runner;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger _logger;
|
||||
private readonly JobStore _store;
|
||||
private readonly SemaphoreSlim _saveGate = new(1, 1);
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, Entry> _jobs = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, Fire> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _stopping = new();
|
||||
private IJobDelivery? _delivery;
|
||||
private ITimer? _timer;
|
||||
private DateTimeOffset? _wakeAt;
|
||||
private bool _stopped;
|
||||
private Task _pendingTick = Task.CompletedTask;
|
||||
private int _firesStarted;
|
||||
|
||||
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.StorePath)) throw new ArgumentException("SchedulerOptions.StorePath is required.", nameof(options));
|
||||
_options = options;
|
||||
_runner = runner;
|
||||
_redactor = redactor;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
_store = new JobStore(options.StorePath, time, logger);
|
||||
}
|
||||
|
||||
public bool IsStarted { get { lock (_lock) return _delivery is not null && !_stopped; } }
|
||||
public DateTimeOffset Now => _time.GetUtcNow();
|
||||
public string StorePath => _store.Path;
|
||||
|
||||
/// <summary>When the timer fires next (tests).</summary>
|
||||
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
|
||||
|
||||
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
|
||||
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
|
||||
|
||||
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
|
||||
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
|
||||
|
||||
/// <summary>Load the store, fire overdue one-offs (late), compute cron occurrences strictly after now (no catch-up), arm the timer.</summary>
|
||||
public async Task StartAsync(IJobDelivery delivery, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var jobs = await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_lock)
|
||||
{
|
||||
if (_delivery is not null || _stopped) throw new InvalidOperationException("The scheduler was already started.");
|
||||
_delivery = delivery;
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var entry = new Entry(job);
|
||||
if (job.Schedule.At is { } at) entry.Next = at;
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
entry.Next = NextCron(job, now);
|
||||
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
|
||||
if (missed is { } m && m < now)
|
||||
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
|
||||
entry.Next = null;
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
|
||||
}
|
||||
}
|
||||
_jobs[job.Id] = entry;
|
||||
}
|
||||
_timer = _time.CreateTimer(_ => OnTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
Tick(); // overdue one-offs fire now
|
||||
}
|
||||
|
||||
/// <summary>Disarm, kill running scripts, and wait at most <see cref="StopGrace"/> for in-flight fires.</summary>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Fire[] fires;
|
||||
ITimer? timer;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped) return;
|
||||
_stopped = true;
|
||||
fires = [.. _inFlight.Values];
|
||||
timer = _timer;
|
||||
}
|
||||
timer?.Dispose();
|
||||
await _stopping.CancelAsync().ConfigureAwait(false); // every fire's token is linked to this one
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(fires.Select(f => f.Task)).WaitAsync(StopGrace, _time, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning("Scheduler stop: {Count} job(s) still running after {Grace}", fires.Count(f => !f.Task.IsCompleted), StopGrace);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => new(StopAsync(CancellationToken.None));
|
||||
|
||||
public IReadOnlyList<JobView> List(string target)
|
||||
{
|
||||
lock (_lock)
|
||||
return _jobs.Values.Where(e => e.Job.Target == target)
|
||||
.Select(e => new JobView(e.Job, e.Next))
|
||||
.OrderBy(v => v.NextRunAt ?? DateTimeOffset.MaxValue).ThenBy(v => v.Job.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public TimeZoneInfo TimeZoneFor(string target) => Volatile.Read(ref _delivery)?.TimeZoneFor(target) ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>Validate (an offset-less <c>at</c> is read in the target's zone), persist, arm. Throws <see cref="JobValidationException"/>.</summary>
|
||||
public async Task<JobView> CreateAsync(string target, NewJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
if (!IsStarted) throw new InvalidOperationException("The scheduler isn't running.");
|
||||
var zone = TimeZoneFor(target);
|
||||
var now = _time.GetUtcNow();
|
||||
var schedule = JobValidation.ValidateNew(job, now, zone);
|
||||
ScheduledJob created;
|
||||
DateTimeOffset? next;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_jobs.Values.Count(e => e.Job.Target == target) >= _options.MaxJobsPerTarget)
|
||||
throw new JobValidationException($"This chat already has {_options.MaxJobsPerTarget} scheduled jobs, the maximum. Cancel one first.");
|
||||
created = new ScheduledJob
|
||||
{
|
||||
Id = NewId(), Target = target, Name = job.Name.Trim(), Kind = job.Kind, Schedule = schedule,
|
||||
Text = job.Kind == JobKind.Watcher ? null : job.Text, Command = job.Kind == JobKind.Watcher ? job.Command : null,
|
||||
TimeoutSeconds = job.TimeoutSeconds, CreatedAt = now,
|
||||
};
|
||||
next = schedule.At ?? Schedules.NextCron(schedule.Cron!, now, zone);
|
||||
_jobs[created.Id] = new Entry(created) { Next = next };
|
||||
}
|
||||
await PersistAsync(cancellationToken).ConfigureAwait(false);
|
||||
Arm();
|
||||
return new JobView(created, next);
|
||||
}
|
||||
|
||||
/// <summary>Remove a job of <paramref name="target"/>. A running watcher is killed and its outcome dropped; a prompt run in progress isn't aborted. False: unknown, or another target's.</summary>
|
||||
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
|
||||
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
|
||||
|
||||
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
|
||||
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
Fire? running;
|
||||
ScheduledJob removed;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
|
||||
removed = entry.Job;
|
||||
_jobs.Remove(id);
|
||||
_inFlight.TryGetValue(id, out running);
|
||||
}
|
||||
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
|
||||
await PersistAsync(cancellationToken).ConfigureAwait(false);
|
||||
Arm();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>The target's zone changed: cron jobs get their next occurrence in the new zone (strictly after now), one-offs keep their instant.</summary>
|
||||
public Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var zone = TimeZoneFor(target);
|
||||
int cron = 0, oneOffs = 0;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var entry in _jobs.Values.Where(e => e.Job.Target == target))
|
||||
{
|
||||
if (entry.Job.Schedule.Cron is { } expression)
|
||||
{
|
||||
cron++;
|
||||
entry.Next = Schedules.NextCron(expression, now, zone);
|
||||
}
|
||||
else oneOffs++;
|
||||
}
|
||||
}
|
||||
Arm();
|
||||
return Task.FromResult(new ZoneChange(cron, oneOffs));
|
||||
}
|
||||
|
||||
/// <summary>Wait for the pending tick and every fire in flight (tests).</summary>
|
||||
internal async Task IdleAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task tick;
|
||||
Task[] fires;
|
||||
lock (_lock)
|
||||
{
|
||||
tick = _pendingTick;
|
||||
fires = _inFlight.Values.Select(f => f.Task).ToArray();
|
||||
}
|
||||
if (tick.IsCompleted && fires.All(f => f.IsCompleted)) return;
|
||||
await Task.WhenAll(fires.Append(tick)).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Timer and ticks ----
|
||||
|
||||
/// <summary>The timer callback: queues a tick behind the previous one (it may run synchronously inside <c>ITimer.Change</c>).</summary>
|
||||
private void OnTimer()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped) return;
|
||||
_pendingTick = _pendingTick.ContinueWith(_ => Tick(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts every due job that isn't in flight, advances each due job's next occurrence, re-arms. Never awaits a fire.</summary>
|
||||
private void Tick()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped || _delivery is null) return;
|
||||
foreach (var entry in _jobs.Values)
|
||||
{
|
||||
if (entry.Next is not { } next || next > now) continue;
|
||||
if (_inFlight.ContainsKey(entry.Job.Id))
|
||||
_logger.LogInformation("Job {Id} ({Name}) is still running; skipped the occurrence due {Due}", entry.Job.Id, entry.Job.Name, next);
|
||||
else StartFire(entry.Job, next, now);
|
||||
// Cron: the next occurrence strictly after now. One-off: none (it's removed once its fire completes).
|
||||
entry.Next = null;
|
||||
if (entry.Job.Schedule.Cron is null) continue;
|
||||
try
|
||||
{
|
||||
entry.Next = NextCron(entry.Job, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Left unarmed rather than due forever (a zero-delay timer loop); a restart or zone change recomputes it.
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence failed", entry.Job.Id, entry.Job.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Arm();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
|
||||
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
|
||||
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
|
||||
/// </summary>
|
||||
private void Arm()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_stopped || _timer is null) return;
|
||||
var now = _time.GetUtcNow();
|
||||
var soonest = _jobs.Values.Select(e => e.Next).Where(n => n is not null).DefaultIfEmpty(null).Min();
|
||||
var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
|
||||
if (wake < now) wake = now;
|
||||
_wakeAt = wake;
|
||||
try
|
||||
{
|
||||
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Stopped meanwhile.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Under the lock. The next cron occurrence strictly after <paramref name="after"/> in the target's current zone.</summary>
|
||||
private DateTimeOffset? NextCron(ScheduledJob job, DateTimeOffset after) =>
|
||||
Schedules.NextCron(job.Schedule.Cron!, after, _delivery!.TimeZoneFor(job.Target));
|
||||
|
||||
// ---- Firing ----
|
||||
|
||||
/// <summary>Called under the lock: registers the fire in flight before it can complete.</summary>
|
||||
private void StartFire(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now)
|
||||
{
|
||||
var cancel = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
|
||||
_inFlight[job.Id] = new Fire(task, cancel);
|
||||
_firesStarted++;
|
||||
gate.SetResult();
|
||||
}
|
||||
|
||||
private async Task RunFireAsync(Task gate, ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationTokenSource cancel)
|
||||
{
|
||||
await gate.ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await FireAsync(job, dueAt, now, cancel.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job {Id} ({Name}) failed to fire", job.Id, job.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock) _inFlight.Remove(job.Id);
|
||||
cancel.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FireAsync(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationToken scriptToken)
|
||||
{
|
||||
var delivery = _delivery!;
|
||||
if (!delivery.IsDeliverable(job.Target))
|
||||
{
|
||||
// Kept: a cron job has already advanced; a one-off fires again after the next start.
|
||||
_logger.LogWarning("Job {Id} ({Name}): target {Target} can't be served any more; skipped", job.Id, job.Name, job.Target);
|
||||
return;
|
||||
}
|
||||
var zone = delivery.TimeZoneFor(job.Target);
|
||||
DateTimeOffset? lateDue = now - dueAt > LateThreshold ? dueAt : null;
|
||||
JobResult? result;
|
||||
switch (job.Kind)
|
||||
{
|
||||
case JobKind.Reminder:
|
||||
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
break;
|
||||
case JobKind.Prompt:
|
||||
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, null, false, null, _time.GetUtcNow())
|
||||
: null;
|
||||
break;
|
||||
default:
|
||||
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
if (result is not null) await RecordAsync(job.Id, result).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Null when the run or its delivery was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
|
||||
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
|
||||
{
|
||||
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
|
||||
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone));
|
||||
ScriptOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await _runner.RunAsync(request, scriptToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (scriptToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (scriptToken.IsCancellationRequested || !Exists(job.Id)) return null;
|
||||
|
||||
var at = _time.GetUtcNow();
|
||||
switch (outcome)
|
||||
{
|
||||
case ScriptOutcome.Output output:
|
||||
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
|
||||
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
|
||||
? new JobResult(true, 0, false, null, at)
|
||||
: null;
|
||||
case ScriptOutcome.Failed failed:
|
||||
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
|
||||
if (!IsRepeat(LastResult(job.Id), result)
|
||||
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
|
||||
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
|
||||
return result;
|
||||
default:
|
||||
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The previous run failed the same way (exit code and timeout): the alert isn't sent again.</summary>
|
||||
private static bool IsRepeat(JobResult? previous, JobResult current) =>
|
||||
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;
|
||||
|
||||
/// <summary>
|
||||
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
|
||||
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
|
||||
/// after the next start (at-least-once).
|
||||
/// </summary>
|
||||
private async Task<bool> DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
|
||||
{
|
||||
try
|
||||
{
|
||||
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Update and persist the run fields; a one-off is removed now that its fire has completed.</summary>
|
||||
private async Task RecordAsync(string id, JobResult result)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobs.TryGetValue(id, out var entry)) return; // cancelled meanwhile
|
||||
if (entry.Job.Schedule.At is not null) _jobs.Remove(id);
|
||||
else entry.Job = entry.Job with { LastRunAt = result.At, LastResult = result };
|
||||
}
|
||||
try
|
||||
{
|
||||
await PersistAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogError(ex, "Saving the job store failed");
|
||||
}
|
||||
Arm();
|
||||
}
|
||||
|
||||
/// <summary>Saves the current jobs. The snapshot is taken inside the gate, so a later save never writes older state.</summary>
|
||||
private async Task PersistAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _saveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
List<ScheduledJob> snapshot;
|
||||
lock (_lock) snapshot = [.. _jobs.Values.Select(e => e.Job).OrderBy(j => j.CreatedAt).ThenBy(j => j.Id, StringComparer.Ordinal)];
|
||||
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryCancelAsync(CancellationTokenSource cancel)
|
||||
{
|
||||
try
|
||||
{
|
||||
await cancel.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The fire completed meanwhile.
|
||||
}
|
||||
}
|
||||
|
||||
private bool Exists(string id) { lock (_lock) return _jobs.ContainsKey(id); }
|
||||
|
||||
private JobResult? LastResult(string id) { lock (_lock) return _jobs.TryGetValue(id, out var e) ? e.Job.LastResult : null; }
|
||||
|
||||
/// <summary>Under the lock. Not reused while a cancelled job with that id is still in flight.</summary>
|
||||
private string NewId()
|
||||
{
|
||||
string id;
|
||||
do id = new string(Random.Shared.GetItems(IdAlphabet.AsSpan(), 5));
|
||||
while (_jobs.ContainsKey(id) || _inFlight.ContainsKey(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private sealed class Entry(ScheduledJob job)
|
||||
{
|
||||
public ScheduledJob Job { get; set; } = job;
|
||||
public DateTimeOffset? Next { get; set; }
|
||||
}
|
||||
|
||||
private sealed record Fire(Task Task, CancellationTokenSource Cancel);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
|
||||
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
|
||||
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning.
|
||||
/// </summary>
|
||||
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
|
||||
{
|
||||
public const int Version = 1;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
|
||||
public string Path => path;
|
||||
|
||||
public async Task<IReadOnlyList<ScheduledJob>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
StoredFile? file;
|
||||
try
|
||||
{
|
||||
file = VolcanicJson.Deserialize<StoredFile>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
file = null;
|
||||
}
|
||||
if (file is not { Version: Version, Jobs: not null })
|
||||
{
|
||||
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
|
||||
File.Move(path, aside, overwrite: true);
|
||||
logger.LogError("The job store {Path} could not be read; it was moved to {Aside} and the scheduler starts empty", path, aside);
|
||||
return [];
|
||||
}
|
||||
|
||||
var jobs = new List<ScheduledJob>();
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < file.Jobs.Count; i++)
|
||||
{
|
||||
var (job, problem) = Read(file.Jobs[i]);
|
||||
problem ??= ids.Add(job!.Id) ? null : "duplicate id";
|
||||
if (problem is not null)
|
||||
{
|
||||
logger.LogWarning("Dropped scheduled job #{Index} ({Id}) from {Path}: {Problem}", i, job?.Id, path, problem);
|
||||
continue;
|
||||
}
|
||||
jobs.Add(job!);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IReadOnlyList<ScheduledJob> jobs, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!);
|
||||
var json = JsonSerializer.Serialize(new JobFile(Version, [.. jobs]), VolcanicJson.Indented);
|
||||
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One entry of the file: the job, or why it's dropped. An entry that doesn't deserialise only loses itself.</summary>
|
||||
private static (ScheduledJob? Job, string? Problem) Read(JsonElement entry)
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Object) return (null, "not a JSON object");
|
||||
ScheduledJob? job;
|
||||
try
|
||||
{
|
||||
job = VolcanicJson.Deserialize<ScheduledJob>(entry);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return (null, ex.Message);
|
||||
}
|
||||
if (job is null) return (null, "empty entry");
|
||||
return (job, JobValidation.CheckStored(job));
|
||||
}
|
||||
|
||||
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
|
||||
|
||||
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>Rules shared by the <c>schedule</c> tool (new jobs) and the store loader (stored jobs).</summary>
|
||||
internal static class JobValidation
|
||||
{
|
||||
public const int MaxNameLength = 80;
|
||||
public const int MaxTextLength = 4000;
|
||||
public const int MaxTimeoutSeconds = 3600;
|
||||
public static readonly TimeSpan PastGrace = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Validates a new job and returns its schedule (<c>at</c> resolved in <paramref name="zone"/>). Throws <see cref="JobValidationException"/>.</summary>
|
||||
public static JobSchedule ValidateNew(NewJob job, DateTimeOffset now, TimeZoneInfo zone)
|
||||
{
|
||||
var name = job.Name?.Trim() ?? "";
|
||||
if (name.Length == 0) throw new JobValidationException("name is required (a short label such as \"Call the dentist\").");
|
||||
if (name.Length > MaxNameLength) throw new JobValidationException($"name is {Count(name.Length)} characters; the limit is {Count(MaxNameLength)}.");
|
||||
var hasAt = !string.IsNullOrWhiteSpace(job.At);
|
||||
var hasCron = !string.IsNullOrWhiteSpace(job.Cron);
|
||||
if (hasAt == hasCron) throw new JobValidationException("Set exactly one of at (a one-off time) or cron (a recurring schedule).");
|
||||
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
|
||||
|
||||
if (hasCron)
|
||||
{
|
||||
Schedules.ParseCron(job.Cron!);
|
||||
return new JobSchedule(null, job.Cron!.Trim());
|
||||
}
|
||||
var at = Schedules.ParseAt(job.At!, zone);
|
||||
if (at < now - PastGrace)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTime(at, zone);
|
||||
var localNow = TimeZoneInfo.ConvertTime(now, zone);
|
||||
throw new JobValidationException(string.Create(CultureInfo.InvariantCulture,
|
||||
$"at {local:yyyy-MM-dd HH:mm} ({ZoneNames.Name(zone)}) is in the past; now is {localNow:yyyy-MM-dd HH:mm}. Pick a future time."));
|
||||
}
|
||||
return new JobSchedule(at, null);
|
||||
}
|
||||
|
||||
/// <summary>Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late).</summary>
|
||||
public static string? CheckStored(ScheduledJob job)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Id) || string.IsNullOrWhiteSpace(job.Target)) return "missing id or target";
|
||||
if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > MaxNameLength) return "missing or too long name";
|
||||
if (job.Schedule is null || (job.Schedule.At is null) == (job.Schedule.Cron is null)) return "needs exactly one of at and cron";
|
||||
if (job.Schedule.Cron is { } cron) Schedules.ParseCron(cron);
|
||||
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
|
||||
return null;
|
||||
}
|
||||
catch (JobValidationException ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds)
|
||||
{
|
||||
var hasText = !string.IsNullOrWhiteSpace(text);
|
||||
var hasCommand = !string.IsNullOrWhiteSpace(command);
|
||||
switch (kind)
|
||||
{
|
||||
case JobKind.Reminder when !hasText: throw new JobValidationException("A reminder needs text (the message to send).");
|
||||
case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent).");
|
||||
case JobKind.Reminder or JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers.");
|
||||
case JobKind.Watcher when !hasCommand: throw new JobValidationException("A watcher needs command (the shell command to run).");
|
||||
case JobKind.Watcher when hasText: throw new JobValidationException("A watcher takes no text: its script's output becomes the prompt.");
|
||||
case not (JobKind.Reminder or JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be reminder, prompt or watcher.");
|
||||
}
|
||||
if (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}.");
|
||||
if (timeoutSeconds is { } seconds)
|
||||
{
|
||||
if (kind != JobKind.Watcher) throw new JobValidationException("timeoutSeconds is only for watchers.");
|
||||
if (seconds is < 1 or > MaxTimeoutSeconds) throw new JobValidationException("timeoutSeconds must be between 1 and 3600.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
42
extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs
Normal file
42
extensions/Volcanic.AgenticCore.Extensions.Scheduler/Jobs.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
|
||||
public enum JobKind { Reminder, Prompt, Watcher }
|
||||
|
||||
/// <summary>Exactly one is set. <see cref="At"/> is an absolute instant; <see cref="Cron"/> is 5-field wall-clock time in the target's zone.</summary>
|
||||
public sealed record JobSchedule(DateTimeOffset? At, string? Cron);
|
||||
|
||||
/// <summary>The outcome of a job's last fire. <see cref="StderrTail"/> is stored redacted.</summary>
|
||||
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
|
||||
|
||||
/// <summary>A persisted job (<c>jobs.json</c>).</summary>
|
||||
public sealed record ScheduledJob
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
/// <summary>Who the job belongs to: the chat id (invariant culture) on Telegram.</summary>
|
||||
public required string Target { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required JobKind Kind { get; init; }
|
||||
public required JobSchedule Schedule { get; init; }
|
||||
/// <summary>Reminder and prompt only.</summary>
|
||||
public string? Text { get; init; }
|
||||
/// <summary>Watcher only.</summary>
|
||||
public string? Command { get; init; }
|
||||
/// <summary>Watcher only; default <see cref="SchedulerOptions.DefaultScriptTimeout"/>, at most 3600.</summary>
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastRunAt { get; init; }
|
||||
public JobResult? LastResult { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>What the <c>schedule</c> tool asks for. <see cref="At"/> is ISO 8601 text; without an offset it's read in the target's zone.</summary>
|
||||
public sealed record NewJob(string Name, JobKind Kind, string? At = null, string? Cron = null, string? Text = null, string? Command = null, int? TimeoutSeconds = null);
|
||||
|
||||
/// <summary>A job and its next fire time (null: never, e.g. a one-off already firing).</summary>
|
||||
public sealed record JobView(ScheduledJob Job, DateTimeOffset? NextRunAt);
|
||||
|
||||
/// <summary>What a zone change did to one target's jobs.</summary>
|
||||
public sealed record ZoneChange(int CronJobs, int OneOffs);
|
||||
|
||||
/// <summary>A job the model (or a stored file) got wrong. The message is a sentence the model can act on.</summary>
|
||||
public sealed class JobValidationException(string message) : Exception(message);
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
public sealed record ScheduleArgs(
|
||||
[property: Description("create, list or cancel")] string Action,
|
||||
[property: Description("cancel: the job id (see list)")] string? Id = null,
|
||||
[property: Description("create: a short label, e.g. \"Call the dentist\"")] string? Name = null,
|
||||
[property: Description("create: reminder (fixed text sent as is), prompt (text you handle when it fires) or watcher (a script; you get its output when it prints something)")] string? Kind = null,
|
||||
[property: Description("create, one-off: ISO 8601 date-time; without an offset it's the chat's local time")] string? At = null,
|
||||
[property: Description("create, recurring: 5-field cron in the chat's zone (minute hour day-of-month month day-of-week), e.g. \"0 8 * * 1-5\"")] string? Cron = null,
|
||||
[property: Description("create, reminder/prompt: the text (at most 4000 characters)")] string? Text = null,
|
||||
[property: Description("create, watcher: shell command run from the workspace")] string? Command = null,
|
||||
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = null);
|
||||
|
||||
/// <summary>The model's view of the scheduler, bound to one target (chat). It never sees another target's jobs.</summary>
|
||||
public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<ScheduleArgs>
|
||||
{
|
||||
public const string ToolName = "schedule";
|
||||
|
||||
public override string Name => ToolName;
|
||||
|
||||
public override string Description =>
|
||||
"Create, list or cancel this chat's scheduled jobs: one-off or recurring reminders (fixed text), prompts (you handle them when they fire) and watcher scripts (run without you; their output comes to you only when they print something).";
|
||||
|
||||
public override string? PromptSnippet => "schedule: reminders, recurring prompts and watcher scripts that message this chat";
|
||||
|
||||
public override IReadOnlyList<string> PromptGuidelines =>
|
||||
[
|
||||
"schedule: times are in the chat's time zone (see the environment section); at is ISO 8601, cron has 5 fields (minute hour day-of-month month day-of-week).",
|
||||
"schedule: use kind reminder for fixed text and prompt when the job needs you to think or use tools.",
|
||||
"schedule: a watcher's command runs with the same shell as bash, from the workspace, without you. It should print nothing and exit 0 while the condition isn't met (that costs nothing), and 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.",
|
||||
"schedule: put reusable scripts in scripts/ and test them with bash before scheduling.",
|
||||
];
|
||||
|
||||
protected override async Task<ToolResult> ExecuteAsync(ScheduleArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (args.Action ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"create" => await CreateAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
"list" => ToolResult.Text(List()),
|
||||
"cancel" => await CancelAsync(args.Id, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw Error("action must be create, list or cancel."),
|
||||
};
|
||||
}
|
||||
catch (JobValidationException ex)
|
||||
{
|
||||
throw Error(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw Error(ex.Message); // e.g. the scheduler isn't running
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> CreateAsync(ScheduleArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"reminder" => JobKind.Reminder,
|
||||
"prompt" => JobKind.Prompt,
|
||||
"watcher" => JobKind.Watcher,
|
||||
_ => throw new JobValidationException("kind must be reminder, prompt or watcher."),
|
||||
};
|
||||
var view = await scheduler.CreateAsync(target, new NewJob(args.Name ?? "", kind, args.At, args.Cron, args.Text, args.Command, args.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
|
||||
var zone = scheduler.TimeZoneFor(target);
|
||||
var now = scheduler.Now;
|
||||
var sb = new StringBuilder("Created: ").Append(JobFormat.Line(view, zone, now));
|
||||
if (view.NextRunAt is { } next) sb.Append("\nNext run: ").Append(JobFormat.Stamp(next, zone)).Append(" (").Append(JobFormat.ZoneName(zone)).Append(')');
|
||||
sb.Append('\n').Append(NowLine(zone, now));
|
||||
return ToolResult.Text(sb.ToString());
|
||||
}
|
||||
|
||||
private string List()
|
||||
{
|
||||
var zone = scheduler.TimeZoneFor(target);
|
||||
var now = scheduler.Now;
|
||||
var jobs = scheduler.List(target);
|
||||
var body = jobs.Count == 0 ? "No scheduled jobs." : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
|
||||
return body + "\n" + NowLine(zone, now);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> CancelAsync(string? id, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = id?.Trim().ToLowerInvariant() ?? "";
|
||||
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
|
||||
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
|
||||
?? throw Error($"No job {key} in this chat.");
|
||||
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
|
||||
}
|
||||
|
||||
private static string NowLine(TimeZoneInfo zone, DateTimeOffset now) => $"Now: {JobFormat.Stamp(now, zone)} ({JobFormat.ZoneName(zone)})";
|
||||
|
||||
private static ToolExecutionException Error(string message) => new(ToolResult.Text(message));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Per session: registers the <c>schedule</c> tool bound to the host's <paramref name="target"/> (a Telegram chat id).
|
||||
/// The host adds it through <c>AgentSessionOptions.ExtraExtensions</c>. Loaded through discovery without a
|
||||
/// host-registered <see cref="JobScheduler"/>, its constructor can't be resolved: an ordinary extension load error.
|
||||
/// </summary>
|
||||
public sealed class SchedulerExtension(JobScheduler scheduler, string target) : IExtension
|
||||
{
|
||||
public const string ExtensionName = "scheduler";
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
api.RegisterTool(new ScheduleTool(scheduler, target));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>The <c>Scheduler</c> configuration section. There is no zone here: zones are per target (the host's <c>IJobDelivery.TimeZoneFor</c>).</summary>
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
public const string SectionName = "Scheduler";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary><c>jobs.json</c>. Empty: the host fills <see cref="DefaultStorePath"/> under its agent directory.</summary>
|
||||
public string? StorePath { get; set; }
|
||||
|
||||
/// <summary>Watcher timeout when the job sets none.</summary>
|
||||
public TimeSpan DefaultScriptTimeout { get; set; } = TimeSpan.FromSeconds(300);
|
||||
|
||||
public int MaxJobsPerTarget { get; set; } = 50;
|
||||
|
||||
/// <summary>Executable and leading arguments; the command is appended. Empty: <c>/bin/sh -c</c>, or <c>cmd.exe /d /s /c</c> on Windows (the <c>bash</c> tool's shell).</summary>
|
||||
public List<string> Shell { get; set; } = [];
|
||||
|
||||
/// <summary><c><agentDir>/scheduler/jobs.json</c>.</summary>
|
||||
public static string DefaultStorePath(string agentDir) => Path.Combine(agentDir, "scheduler", "jobs.json");
|
||||
|
||||
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (DefaultScriptTimeout < TimeSpan.FromSeconds(1) || DefaultScriptTimeout > TimeSpan.FromSeconds(JobValidation.MaxTimeoutSeconds))
|
||||
errors.Add("Scheduler:DefaultScriptTimeout must be between 1 second and 1 hour.");
|
||||
if (MaxJobsPerTarget < 1) errors.Add("Scheduler:MaxJobsPerTarget must be at least 1.");
|
||||
if (Shell.Any(string.IsNullOrWhiteSpace)) errors.Add("Scheduler:Shell entries must not be empty.");
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cronos;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
|
||||
public static partial class Schedules
|
||||
{
|
||||
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
|
||||
|
||||
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
|
||||
public static CronExpression ParseCron(string expression)
|
||||
{
|
||||
var text = expression?.Trim() ?? "";
|
||||
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
|
||||
if (!text.StartsWith('@'))
|
||||
{
|
||||
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
|
||||
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
|
||||
if (fields.Length == 6)
|
||||
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
|
||||
if (fields.Length != 5)
|
||||
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
|
||||
}
|
||||
try
|
||||
{
|
||||
return CronExpression.Parse(text, CronFormat.Standard);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
|
||||
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
|
||||
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
|
||||
|
||||
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
|
||||
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
|
||||
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
|
||||
/// they'd be resolved against the machine's clock, not the chat's.
|
||||
/// </summary>
|
||||
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
|
||||
{
|
||||
var trimmed = text?.Trim() ?? "";
|
||||
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
|
||||
var match = IsoAt().Match(trimmed);
|
||||
if (!match.Success) throw new JobValidationException(hint);
|
||||
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
|
||||
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
|
||||
throw new JobValidationException(hint);
|
||||
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
|
||||
if (match.Groups["offset"] is { Success: true } group)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(wall, ParseOffset(group.Value));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new JobValidationException(hint); // an offset beyond ±14:00
|
||||
}
|
||||
}
|
||||
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
|
||||
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
|
||||
// An ambiguous fall-back time is read as the first of the two (the larger offset).
|
||||
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
|
||||
return new DateTimeOffset(wall, offset);
|
||||
}
|
||||
|
||||
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
|
||||
private static TimeSpan ParseOffset(string text)
|
||||
{
|
||||
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
|
||||
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
|
||||
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
|
||||
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
|
||||
var offset = new TimeSpan(hours, minutes, 0);
|
||||
return text[0] == '-' ? -offset : offset;
|
||||
}
|
||||
|
||||
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
|
||||
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex IsoAt();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone).</summary>
|
||||
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId);
|
||||
|
||||
/// <summary>What a watcher run means for the job.</summary>
|
||||
public abstract record ScriptOutcome
|
||||
{
|
||||
private ScriptOutcome() { }
|
||||
|
||||
/// <summary>Exit 0, empty stdout after trimming: the condition isn't met. Costs nothing.</summary>
|
||||
public sealed record Silent : ScriptOutcome;
|
||||
|
||||
/// <summary>Exit 0 with output: the condition is met; <see cref="Text"/> (trimmed) goes to the agent.</summary>
|
||||
public sealed record Output(string Text) : ScriptOutcome;
|
||||
|
||||
/// <summary>Non-zero exit, timeout, a hook that threw, or a shell that didn't start.</summary>
|
||||
public sealed record Failed(int? ExitCode, string StderrTail, bool TimedOut) : ScriptOutcome;
|
||||
}
|
||||
|
||||
/// <summary>Runs watcher scripts. Faked in scheduler tests.</summary>
|
||||
public interface IScriptRunner
|
||||
{
|
||||
/// <summary>Never throws for a script problem (that's <see cref="ScriptOutcome.Failed"/>); throws <see cref="OperationCanceledException"/> after killing the script when <paramref name="cancellationToken"/> is cancelled.</summary>
|
||||
Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a watcher with the <c>bash</c> tool's shell in the target's workspace, stdin closed, with the command, cwd and
|
||||
/// environment from the host's <see cref="IProcessSpawnPolicy"/> (<see cref="ProcessPurpose.ScheduledScript"/>): the same
|
||||
/// filtered environment as <c>bash</c>, <c>TZ</c> = the target's zone, host hooks applied. No session-level
|
||||
/// <see cref="ProcessSpawnEvent"/> runs: a job fires without a live session.
|
||||
/// </summary>
|
||||
public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy policy, ILogger<ScriptRunner> logger, TimeProvider? time = null) : IScriptRunner
|
||||
{
|
||||
public const int StderrTailLines = 20;
|
||||
private static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(5);
|
||||
private readonly TimeProvider _time = time ?? TimeProvider.System;
|
||||
|
||||
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ProcessSpawnContext spawn;
|
||||
try
|
||||
{
|
||||
spawn = policy.Prepare(new ProcessSpawnRequest
|
||||
{
|
||||
Purpose = ProcessPurpose.ScheduledScript,
|
||||
Command = request.Command,
|
||||
Cwd = request.Cwd,
|
||||
Scope = request.Target,
|
||||
Origin = request.JobId,
|
||||
TimeZoneId = request.TimeZoneId,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"process environment hook failed: {ex.Message}", false);
|
||||
}
|
||||
|
||||
var psi = StartInfo(spawn.Command);
|
||||
spawn.ApplyTo(psi); // cwd and exactly the prepared environment
|
||||
using var process = new Process { StartInfo = psi };
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(spawn.Cwd);
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
|
||||
{
|
||||
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
|
||||
}
|
||||
process.StandardInput.Close();
|
||||
var stdout = new OutputCapture();
|
||||
var stderr = new OutputCapture();
|
||||
var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr));
|
||||
|
||||
using var timeout = new CancellationTokenSource(request.Timeout, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
var timedOut = false;
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Kill(process, request);
|
||||
timedOut = !cancellationToken.IsCancellationRequested;
|
||||
}
|
||||
await DrainAsync(pumps, request).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (timedOut) return new ScriptOutcome.Failed(null, Tail(stderr.ToString()), true);
|
||||
if (process.ExitCode != 0) return new ScriptOutcome.Failed(process.ExitCode, Tail(stderr.ToString()), false);
|
||||
var text = stdout.ToString().Trim();
|
||||
return text.Length == 0 ? new ScriptOutcome.Silent() : new ScriptOutcome.Output(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>bash</c> tool's shell: <c>/bin/sh -c <command></c> through <see cref="ProcessStartInfo.ArgumentList"/>. On
|
||||
/// Windows <c>cmd.exe</c> doesn't parse the MSVC quoting <c>ArgumentList</c> produces, so the command goes through
|
||||
/// <see cref="ProcessStartInfo.Arguments"/> as <c>/d /s /c "<command>"</c>, exactly as <c>bash</c> does.
|
||||
/// A configured <see cref="SchedulerOptions.Shell"/> always uses <c>ArgumentList</c>.
|
||||
/// </summary>
|
||||
internal ProcessStartInfo StartInfo(string command)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
if (options.Shell.Count > 0)
|
||||
{
|
||||
psi.FileName = options.Shell[0];
|
||||
foreach (var argument in options.Shell.Skip(1)) psi.ArgumentList.Add(argument);
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
psi.FileName = "cmd.exe";
|
||||
psi.Arguments = $"/d /s /c \"{command}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
psi.FileName = "/bin/sh";
|
||||
psi.ArgumentList.Add("-c");
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
return psi;
|
||||
}
|
||||
|
||||
private void Kill(Process process, ScriptRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(PipeGrace);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException)
|
||||
{
|
||||
logger.LogDebug(ex, "Job {JobId}: killing the script failed (it may have exited)", request.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for both output pipes to close, at most <see cref="PipeGrace"/> (a surviving grandchild may hold them).</summary>
|
||||
private async Task DrainAsync(Task pumps, ScriptRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
logger.LogWarning("Job {JobId}: the script's output pipes stayed open after it ended", request.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(StreamReader reader, OutputCapture capture)
|
||||
{
|
||||
var buffer = new char[4096];
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0)
|
||||
capture.Append(buffer.AsSpan(0, read));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
// The pipe broke or the process was disposed: keep what was read.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The last <see cref="StderrTailLines"/> lines.</summary>
|
||||
internal static string Tail(string text)
|
||||
{
|
||||
var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n');
|
||||
return string.Join('\n', lines.TakeLast(StderrTailLines));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keeps the first <see cref="HeadChars"/> and the last <see cref="TailChars"/> characters of a stream.</summary>
|
||||
internal sealed class OutputCapture
|
||||
{
|
||||
public const int HeadChars = 48 * 1024;
|
||||
public const int TailChars = 16 * 1024;
|
||||
private readonly StringBuilder _head = new();
|
||||
private readonly char[] _tail = new char[TailChars];
|
||||
private readonly Lock _lock = new();
|
||||
private int _tailStart;
|
||||
private int _tailLength;
|
||||
private long _dropped;
|
||||
|
||||
public void Append(string text) => Append(text.AsSpan());
|
||||
|
||||
public void Append(ReadOnlySpan<char> text)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var toHead = Math.Min(text.Length, HeadChars - _head.Length);
|
||||
if (toHead > 0) _head.Append(text[..toHead]);
|
||||
foreach (var c in text[Math.Max(toHead, 0)..])
|
||||
{
|
||||
if (_tailLength == TailChars)
|
||||
{
|
||||
_tail[_tailStart] = c;
|
||||
_tailStart = (_tailStart + 1) % TailChars;
|
||||
_dropped++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_tail[(_tailStart + _tailLength) % TailChars] = c;
|
||||
_tailLength++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sb = new StringBuilder(_head.Length + _tailLength + 64).Append(_head);
|
||||
if (_dropped > 0) sb.Append(CultureInfo.InvariantCulture, $"\n[… {_dropped} characters omitted …]\n");
|
||||
for (var i = 0; i < _tailLength; i++) sb.Append(_tail[(_tailStart + i) % TailChars]);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Description>Scheduler extension: one-off and cron reminders, scheduled agent prompts and watcher scripts.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
|
||||
<PackageReference Include="Cronos" Version="0.13.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
|
||||
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
|
||||
/// references only the Abstractions); keep the two in step.
|
||||
/// </summary>
|
||||
internal static class ZoneNames
|
||||
{
|
||||
/// <summary>
|
||||
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
|
||||
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
|
||||
/// </summary>
|
||||
public static string? Iana(TimeZoneInfo zone)
|
||||
{
|
||||
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
|
||||
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
|
||||
}
|
||||
@@ -2,12 +2,15 @@ using System.Globalization;
|
||||
using System.Text;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
|
||||
public sealed class ChatCommands(TelegramSender sender)
|
||||
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null)
|
||||
{
|
||||
public static readonly IReadOnlyList<BotCommand> Menu =
|
||||
[
|
||||
@@ -18,6 +21,8 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
|
||||
new() { Command = "compact", Description = "Summarise the conversation to free context" },
|
||||
new() { Command = "thinking", Description = "Show or set the thinking level" },
|
||||
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
|
||||
.. SchedulerSetup.Menu,
|
||||
new() { Command = "help", Description = "What this bot does" },
|
||||
];
|
||||
|
||||
@@ -97,6 +102,18 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "timezone":
|
||||
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "jobs":
|
||||
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "cancel":
|
||||
await ReplyAsync(chat, await CancelJobAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -119,9 +136,10 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
/sessions, /resume <id> – earlier conversations
|
||||
/compact – summarise the conversation to free context
|
||||
/thinking [level] – show or set the thinking level
|
||||
""" + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
|
||||
/timezone [IANA name|reset] – this chat's time zone
|
||||
""" + SchedulerSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
|
||||
|
||||
private static string Status(ChatContext chat)
|
||||
private string Status(ChatContext chat)
|
||||
{
|
||||
var session = chat.Session;
|
||||
var sb = new StringBuilder();
|
||||
@@ -131,6 +149,10 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
|
||||
if (session.GetContextUsage() is { } usage)
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
|
||||
if (scheduler is { IsStarted: true })
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
|
||||
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
|
||||
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
|
||||
return sb.ToString();
|
||||
@@ -163,6 +185,39 @@ public sealed class ChatCommands(TelegramSender sender)
|
||||
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
|
||||
}
|
||||
|
||||
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
|
||||
{
|
||||
if (zones is null) return "Time zones aren't configured on this bot.";
|
||||
if (arg.Length == 0) return zones.Show(chat.ChatId);
|
||||
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
|
||||
return ChatTimeZones.TryParse(arg, out var zone, out var error)
|
||||
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
|
||||
: error;
|
||||
}
|
||||
|
||||
private string Jobs(ChatContext chat)
|
||||
{
|
||||
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
|
||||
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
|
||||
var jobs = scheduler.List(target);
|
||||
if (jobs.Count == 0) return "No scheduled jobs.";
|
||||
var zone = scheduler.TimeZoneFor(target);
|
||||
var now = scheduler.Now;
|
||||
return $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
|
||||
string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
|
||||
}
|
||||
|
||||
private async Task<string> CancelJobAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
|
||||
{
|
||||
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
|
||||
var id = arg.Trim().ToLowerInvariant();
|
||||
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
|
||||
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
|
||||
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
|
||||
? $"Cancelled {job.Id} ({job.Name})."
|
||||
: $"No job {id} in this chat.";
|
||||
}
|
||||
|
||||
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = chat.Session;
|
||||
|
||||
@@ -5,7 +5,9 @@ using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
@@ -69,7 +71,10 @@ public sealed class ChatRegistry(
|
||||
TelegramOptions options,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider? time = null,
|
||||
GoogleCalendarOptions? calendar = null) : IAsyncDisposable
|
||||
GoogleCalendarOptions? calendar = null,
|
||||
ChatSettingsStore? settings = null,
|
||||
ChatTimeZoneService? zones = null,
|
||||
JobScheduler? scheduler = null) : IAsyncDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
|
||||
@@ -108,6 +113,11 @@ public sealed class ChatRegistry(
|
||||
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
|
||||
var ui = new TelegramUi(chatId, sender, renderer);
|
||||
var channel = new TelegramChatChannel(chatId, sender, renderer);
|
||||
var extras = new List<ExtensionRegistration>();
|
||||
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
|
||||
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
|
||||
if (scheduler is not null)
|
||||
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId))));
|
||||
AgentSession session;
|
||||
try
|
||||
{
|
||||
@@ -119,7 +129,8 @@ public sealed class ChatRegistry(
|
||||
Model = options.Model,
|
||||
Ui = ui,
|
||||
Channel = channel,
|
||||
ExtraExtensions = calendar is null ? null : [GoogleCalendarSetup.Registration(calendar)],
|
||||
TimeZone = settings?.TimeZoneFor(chatId),
|
||||
ExtraExtensions = extras,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
@@ -139,11 +150,11 @@ public sealed class ChatRegistry(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zone for a chat's timestamp headers: the chat session's zone, else the machine's. This is the one place that
|
||||
/// switches to the per-chat setting (<c>ChatSettingsStore.TimeZoneFor</c>, scheduler spec) when that lands.
|
||||
/// Read at flush time, so a zone change applies to the next batch.
|
||||
/// The zone for a chat's timestamp headers: the chat's setting (<see cref="ChatSettingsStore.TimeZoneFor"/>), which also
|
||||
/// feeds the session's zone. Without a store (bare test setups) the session's zone, else the machine's. Read at flush
|
||||
/// time, so a <c>/timezone</c> change applies to the next batch. Never creates a session.
|
||||
/// </summary>
|
||||
public TimeZoneInfo TimeZoneFor(long chatId) => Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
|
||||
public TimeZoneInfo TimeZoneFor(long chatId) => settings?.TimeZoneFor(chatId) ?? Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>A flushed batch becomes one prompt; while a run is active it steers it and each message gets a 👀 reaction.</summary>
|
||||
internal Task FlushAsync(ChatContext chat, IReadOnlyList<InboundPart> parts)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>Arguments of <c>set_timezone</c>.</summary>
|
||||
public sealed record SetTimeZoneArgs([property: Description("IANA time zone, e.g. Europe/Berlin or America/New_York")] string Zone);
|
||||
|
||||
/// <summary>Host extension per chat: the <c>set_timezone</c> tool, whether or not the scheduler is enabled.</summary>
|
||||
public sealed class ChatSettingsExtension(long chatId, ChatTimeZoneService zones) : IExtension
|
||||
{
|
||||
public const string ExtensionName = "telegram-chat-settings";
|
||||
public const string ToolName = "set_timezone";
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
api.RegisterTool(new DelegateTool<SetTimeZoneArgs>(
|
||||
ToolName,
|
||||
"Set this chat's time zone (an IANA name). It applies to the date in your instructions, bash, scheduled jobs and message timestamps.",
|
||||
async (args, _, ct) =>
|
||||
{
|
||||
if (!ChatTimeZones.TryParse(args.Zone, out var zone, out var error)) throw new ToolExecutionException(ToolResult.Text(error));
|
||||
return ToolResult.Text(await zones.SetAsync(chatId, zone, ct).ConfigureAwait(false));
|
||||
},
|
||||
promptSnippet: "set_timezone: change this chat's time zone",
|
||||
promptGuidelines: ["Use set_timezone only when the user tells you their location or time zone changed; confirm the new zone in your reply."],
|
||||
executionMode: ToolExecutionMode.Sequential));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
106
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatSettingsStore.cs
Normal file
106
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatSettingsStore.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>
|
||||
/// Per-chat settings outside every workspace: <c><AgentDir>/telegram/chat-settings.json</c>,
|
||||
/// <c>{ "version": 1, "chats": { "<chatId>": { "timeZone": "Asia/Tokyo" } } }</c>. Loaded once and cached; written
|
||||
/// atomically (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty; a stored
|
||||
/// zone that no longer resolves is logged and ignored (the chat follows the default). Exists with the scheduler disabled.
|
||||
/// </summary>
|
||||
public sealed class ChatSettingsStore
|
||||
{
|
||||
public const int Version = 1;
|
||||
private readonly string _path;
|
||||
private readonly ILogger _logger;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<long, TimeZoneInfo> _zones = [];
|
||||
|
||||
public ChatSettingsStore(string path, TimeZoneInfo defaultZone, ILogger<ChatSettingsStore> logger, TimeProvider? time = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(defaultZone);
|
||||
_path = path;
|
||||
_logger = logger;
|
||||
DefaultZone = defaultZone;
|
||||
Load(time ?? TimeProvider.System);
|
||||
}
|
||||
|
||||
public static string DefaultPath(string agentDir) => Path.Combine(agentDir, "telegram", "chat-settings.json");
|
||||
|
||||
/// <summary><c>Telegram:DefaultTimeZone</c>, else the machine's zone. Chats without their own zone follow it.</summary>
|
||||
public TimeZoneInfo DefaultZone { get; }
|
||||
|
||||
public TimeZoneInfo TimeZoneFor(long chatId)
|
||||
{
|
||||
lock (_lock) return _zones.GetValueOrDefault(chatId) ?? DefaultZone;
|
||||
}
|
||||
|
||||
public bool HasOwnZone(long chatId)
|
||||
{
|
||||
lock (_lock) return _zones.ContainsKey(chatId);
|
||||
}
|
||||
|
||||
/// <summary>Stores <paramref name="ianaId"/> for the chat; null removes the entry (back to the default).</summary>
|
||||
public async Task SetTimeZoneAsync(long chatId, string? ianaId, CancellationToken cancellationToken)
|
||||
{
|
||||
var zone = ianaId is null ? null : TimeZoneInfo.FindSystemTimeZoneById(ianaId); // throws before anything changes
|
||||
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Under the write gate, so the snapshot written last is also the newest one.
|
||||
SettingsFile snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
if (zone is null) _zones.Remove(chatId);
|
||||
else _zones[chatId] = zone;
|
||||
snapshot = new SettingsFile(Version, _zones.ToDictionary(
|
||||
kv => kv.Key.ToString(CultureInfo.InvariantCulture),
|
||||
kv => new ChatSettings(TimeZoneIds.Name(kv.Value))));
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
var temp = $"{_path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
|
||||
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(snapshot, VolcanicJson.Indented), CancellationToken.None).ConfigureAwait(false);
|
||||
File.Move(temp, _path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void Load(TimeProvider time)
|
||||
{
|
||||
if (!File.Exists(_path)) return;
|
||||
SettingsFile? file;
|
||||
try
|
||||
{
|
||||
file = VolcanicJson.Deserialize<SettingsFile>(File.ReadAllText(_path));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
file = null;
|
||||
}
|
||||
if (file is not { Version: Version, Chats: not null })
|
||||
{
|
||||
var aside = $"{_path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
|
||||
File.Move(_path, aside, overwrite: true);
|
||||
_logger.LogError("Chat settings {Path} could not be read; moved to {Aside}, every chat follows the default zone", _path, aside);
|
||||
return;
|
||||
}
|
||||
foreach (var (key, settings) in file.Chats)
|
||||
{
|
||||
if (!long.TryParse(key, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) || settings?.TimeZone is not { } id) continue;
|
||||
if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone)) _zones[chatId] = zone;
|
||||
else _logger.LogWarning("Chat {ChatId}: stored time zone {Zone} is unknown here; the chat follows the default", chatId, id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SettingsFile(int Version, Dictionary<string, ChatSettings>? Chats);
|
||||
|
||||
private sealed record ChatSettings(string? TimeZone);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>
|
||||
/// The one path for a chat's zone change (<c>/timezone</c> and <c>set_timezone</c>): store it, apply it to the live
|
||||
/// session (from its next run), re-evaluate the chat's cron jobs, and word the reply. <paramref name="liveSession"/>
|
||||
/// finds the chat's session without creating one; it's a function so DI has no ChatRegistry ↔ service cycle.
|
||||
/// </summary>
|
||||
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
|
||||
{
|
||||
/// <summary>Set (<paramref name="zone"/>) or reset (null) the chat's zone and return the confirmation.</summary>
|
||||
public async Task<string> SetAsync(long chatId, TimeZoneInfo? zone, CancellationToken cancellationToken)
|
||||
{
|
||||
await store.SetTimeZoneAsync(chatId, zone is null ? null : TimeZoneIds.Name(zone), cancellationToken).ConfigureAwait(false);
|
||||
var effective = store.TimeZoneFor(chatId);
|
||||
liveSession(chatId)?.SetTimeZone(effective);
|
||||
var now = time.GetUtcNow();
|
||||
var text = new StringBuilder(zone is null
|
||||
? $"Time zone reset to the default, {Describe(effective, now)}."
|
||||
: $"Time zone set to {Describe(effective, now)}.");
|
||||
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
|
||||
if (scheduler is { IsStarted: true })
|
||||
{
|
||||
var target = TelegramJobDelivery.TargetOf(chatId);
|
||||
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
text.Append(JobClause(change, scheduler.List(target), effective));
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary><c>Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.</c></summary>
|
||||
public string Show(long chatId)
|
||||
{
|
||||
var zone = store.TimeZoneFor(chatId);
|
||||
var marker = store.HasOwnZone(chatId) ? "" : ", default";
|
||||
return $"Time zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(time.GetUtcNow()))}{marker}). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.";
|
||||
}
|
||||
|
||||
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>, the chat's zone now (for <c>/status</c>).</summary>
|
||||
public string Describe(long chatId) => Describe(store.TimeZoneFor(chatId), time.GetUtcNow());
|
||||
|
||||
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>.</summary>
|
||||
public static string Describe(TimeZoneInfo zone, DateTimeOffset at) => $"{TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(at))})";
|
||||
|
||||
/// <summary><c> 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).</c> Empty without jobs.</summary>
|
||||
private static string JobClause(ZoneChange change, IReadOnlyList<JobView> jobs, TimeZoneInfo zone)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (change.CronJobs > 0)
|
||||
parts.Add(change.CronJobs == 1 ? "1 recurring job now follows this zone" : $"{change.CronJobs.ToString(CultureInfo.InvariantCulture)} recurring jobs now follow this zone");
|
||||
if (change.OneOffs > 0)
|
||||
{
|
||||
var times = string.Join(", ", jobs.Where(v => v.Job.Schedule.At is not null).Select(v => JobFormat.DayTime(v.Job.Schedule.At!.Value, zone)));
|
||||
parts.Add(change.OneOffs == 1
|
||||
? $"1 one-off job keeps its time (now {times} here)"
|
||||
: $"{change.OneOffs.ToString(CultureInfo.InvariantCulture)} one-off jobs keep their times (now {times} here)");
|
||||
}
|
||||
return parts.Count == 0 ? "" : " " + string.Join("; ", parts) + ".";
|
||||
}
|
||||
}
|
||||
25
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatTimeZones.cs
Normal file
25
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatTimeZones.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>Parses what a user (<c>/timezone</c>) or the agent (<c>set_timezone</c>) calls a zone. The stored form is the IANA id.</summary>
|
||||
public static partial class ChatTimeZones
|
||||
{
|
||||
/// <summary>Accepts IANA ids and Windows ids (converted); rejects <c>Local</c> and bare offsets such as <c>+03:00</c> or <c>GMT+3</c>.</summary>
|
||||
public static bool TryParse(string? input, out TimeZoneInfo zone, out string error)
|
||||
{
|
||||
var text = input?.Trim() ?? "";
|
||||
zone = TimeZoneInfo.Utc;
|
||||
error = $"Unknown time zone \"{text}\". Use an IANA name such as Europe/Berlin or America/New_York.";
|
||||
if (text.Length == 0 || text.Equals("Local", StringComparison.OrdinalIgnoreCase) || BareOffset().IsMatch(text)) return false;
|
||||
if (!TimeZoneInfo.TryFindSystemTimeZoneById(text, out var found)) return false;
|
||||
if (TimeZoneIds.ToIana(found) is not { } iana || !TimeZoneInfo.TryFindSystemTimeZoneById(iana, out var resolved)) return false;
|
||||
zone = resolved;
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(utc|gmt)?\s*[+-]\s*\d", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BareOffset();
|
||||
}
|
||||
@@ -7,9 +7,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Telegram.Bot;
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
using Volcanic.AgenticCore.Extensions.Mcp;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram;
|
||||
@@ -17,6 +19,7 @@ using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Polling;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Telegram.Speech;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
@@ -31,7 +34,10 @@ var processEnvironment = new ProcessEnvironmentOptions();
|
||||
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
|
||||
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
|
||||
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
|
||||
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).ToList();
|
||||
// One agent directory for sessions, chat-settings.json and scheduler/jobs.json.
|
||||
var agentDir = VolcanicPaths.DefaultAgentDir;
|
||||
var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), agentDir);
|
||||
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine(error);
|
||||
@@ -40,6 +46,7 @@ if (errors.Count > 0)
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton(mcp);
|
||||
builder.Services.AddSingleton(scheduling);
|
||||
builder.Services.AddSingleton(options.Speech);
|
||||
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
|
||||
// InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its
|
||||
@@ -58,8 +65,10 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
|
||||
});
|
||||
builder.Services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = agentDir;
|
||||
o.DefaultModel = options.Model;
|
||||
o.ProcessEnvironment = processEnvironment;
|
||||
o.DefaultTimeZone = options.ConfiguredTimeZone();
|
||||
o.AddExtension<ClaudeCodeProviderExtension>();
|
||||
o.AddExtension<McpExtension>();
|
||||
});
|
||||
@@ -68,12 +77,24 @@ builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay,
|
||||
builder.Services.AddSingleton<TelegramSender>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ChatRegistry>();
|
||||
builder.Services.AddSingleton(sp => new ChatSettingsStore(
|
||||
ChatSettingsStore.DefaultPath(agentDir),
|
||||
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
|
||||
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
|
||||
sp.GetRequiredService<TimeProvider>()));
|
||||
// Finds a chat's live session lazily (at call time), so ChatRegistry <-> ChatTimeZoneService is no construction cycle.
|
||||
builder.Services.AddSingleton(sp => new ChatTimeZoneService(
|
||||
sp.GetRequiredService<ChatSettingsStore>(),
|
||||
chatId => sp.GetRequiredService<ChatRegistry>().Find(chatId)?.Session,
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetService<JobScheduler>()));
|
||||
builder.Services.AddSingleton<ChatCommands>();
|
||||
builder.Services.AddSingleton<InboundMedia>();
|
||||
builder.Services.AddSingleton<InboundComposer>();
|
||||
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
|
||||
builder.Services.AddSingleton<PollingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
|
||||
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService
|
||||
builder.Services.AddHostedService<BotSetupService>();
|
||||
|
||||
using var host = builder.Build();
|
||||
@@ -88,6 +109,8 @@ if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
|
||||
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
|
||||
else
|
||||
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
|
||||
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? scheduling.StorePath : "off");
|
||||
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
|
||||
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
|
||||
|
||||
var polling = host.Services.GetRequiredService<PollingService>();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
/// <summary>Starts the scheduler with the Telegram delivery (registered after <c>PollingService</c>) and stops it on shutdown.</summary>
|
||||
public sealed class SchedulerHostedService(JobScheduler scheduler, TelegramJobDelivery delivery, ILogger<SchedulerHostedService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await scheduler.StartAsync(delivery, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scheduler started; jobs in {Path}", scheduler.StorePath);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => scheduler.StopAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
/// <summary>Everything the Telegram host does for the scheduler, kept out of the shared host files.</summary>
|
||||
public static class SchedulerSetup
|
||||
{
|
||||
public const string DisabledReply = "Scheduling is turned off on this bot (Scheduler:Enabled).";
|
||||
|
||||
public const string HelpLines = "\n/jobs – this chat's scheduled reminders, prompts and watchers\n/cancel <id> – cancel one of them";
|
||||
|
||||
public static readonly IReadOnlyList<BotCommand> Menu =
|
||||
[
|
||||
new() { Command = "jobs", Description = "Scheduled reminders, prompts and watchers" },
|
||||
new() { Command = "cancel", Description = "Cancel a scheduled job: /cancel <id>" },
|
||||
];
|
||||
|
||||
/// <summary>The <c>Scheduler</c> section; an empty <c>StorePath</c> becomes <c><agentDir>/scheduler/jobs.json</c>.</summary>
|
||||
public static SchedulerOptions Options(IConfiguration section, string agentDir)
|
||||
{
|
||||
var options = section.Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.StorePath)) options.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>Runner, scheduler, delivery and the hosted service (register after <c>PollingService</c>). Only when enabled.</summary>
|
||||
public static void Register(IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IScriptRunner, ScriptRunner>();
|
||||
services.AddSingleton<JobScheduler>();
|
||||
services.AddSingleton<TelegramJobDelivery>();
|
||||
services.AddHostedService<SchedulerHostedService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduler's view of Telegram. Targets are chat ids (invariant culture). Direct texts go through the chat's
|
||||
/// <c>IChatChannel</c> (renderer queue, splitting, HTML with fallback, retries); prompts go straight to the chat's session
|
||||
/// as follow-ups, never through <c>UpdateRouter</c> or the <c>ChatInbox</c>, so they are never debounced into a user's
|
||||
/// message and never answer a pending question. <see cref="WorkspaceFor"/> and <see cref="TimeZoneFor"/> never create or
|
||||
/// look up a session: a silent watcher tick leaves the chat untouched.
|
||||
/// </summary>
|
||||
public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore settings, TelegramOptions options, TimeProvider time, ILogger<TelegramJobDelivery> logger) : IJobDelivery
|
||||
{
|
||||
public static long ChatIdOf(string target) => long.Parse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture);
|
||||
|
||||
public static string TargetOf(long chatId) => chatId.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static bool TryChatIdOf(string target, out long chatId) =>
|
||||
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out chatId);
|
||||
|
||||
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await chats.GetAsync(ChatIdOf(target), cancellationToken).ConfigureAwait(false);
|
||||
await chat.Channel.SendTextAsync(markdown, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
var chatId = ChatIdOf(target);
|
||||
var chat = await chats.GetAsync(chatId, cancellationToken).ConfigureAwait(false);
|
||||
var text = MessageContext.TimestampHeader(time.GetUtcNow(), TimeZoneFor(target)) + "\n" + prompt;
|
||||
try
|
||||
{
|
||||
// Idle: starts (or joins, as a follow-up) a run and returns when it settles. Busy: queued as a follow-up (never
|
||||
// steering) and returns at once; the job then stays in flight until the run that consumed it settles.
|
||||
var queued = chat.Session.IsStreaming;
|
||||
await chat.Session.PromptAsync(text, null, StreamingBehavior.FollowUp, cancellationToken).ConfigureAwait(false);
|
||||
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scheduled prompt in chat {ChatId} failed", chatId);
|
||||
chat.Report($"The scheduled job failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public string WorkspaceFor(string target) => chats.WorkspaceFor(ChatIdOf(target));
|
||||
|
||||
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
|
||||
public bool IsDeliverable(string target) =>
|
||||
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);
|
||||
|
||||
/// <summary>An unparsable target (a hand-edited store) gets the default zone rather than an exception.</summary>
|
||||
public TimeZoneInfo TimeZoneFor(string target) =>
|
||||
TryChatIdOf(target, out var chatId) ? settings.TimeZoneFor(chatId) : settings.DefaultZone;
|
||||
}
|
||||
@@ -44,6 +44,13 @@ public sealed class TelegramOptions
|
||||
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
|
||||
public SpeechOptions Speech { get; set; } = new();
|
||||
|
||||
/// <summary>IANA zone for chats that haven't set one with <c>/timezone</c>; empty = the machine's zone. Also <c>DEFAULT_TIMEZONE</c>.</summary>
|
||||
public string? DefaultTimeZone { get; set; }
|
||||
|
||||
/// <summary>The configured default zone, or null when unset (then <see cref="TimeZoneInfo.Local"/> applies).</summary>
|
||||
public TimeZoneInfo? ConfiguredTimeZone() =>
|
||||
!string.IsNullOrWhiteSpace(DefaultTimeZone) && Chats.ChatTimeZones.TryParse(DefaultTimeZone, out var zone, out _) ? zone : null;
|
||||
|
||||
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
|
||||
public void ApplyEnvironment(Func<string, string?> getEnv)
|
||||
{
|
||||
@@ -60,6 +67,8 @@ public sealed class TelegramOptions
|
||||
Speech.ApiKey = key.Trim();
|
||||
if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model))
|
||||
Speech.Model = model.Trim();
|
||||
if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz))
|
||||
DefaultTimeZone = tz.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
|
||||
@@ -81,6 +90,8 @@ public sealed class TelegramOptions
|
||||
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
|
||||
if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
|
||||
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
|
||||
if (!string.IsNullOrWhiteSpace(DefaultTimeZone) && !Chats.ChatTimeZones.TryParse(DefaultTimeZone, out _, out var zoneError))
|
||||
errors.Add("Telegram:DefaultTimeZone: " + zoneError);
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
|
||||
|
||||
@@ -22,8 +22,7 @@ public sealed partial class AgentSession : IExtensionHost
|
||||
Model? IExtensionHost.Model => _model;
|
||||
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
|
||||
TimeZoneInfo IExtensionHost.TimeZone => TimeZone;
|
||||
string? IExtensionHost.TimeZoneId =>
|
||||
(_options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
|
||||
string? IExtensionHost.TimeZoneId => ConfiguredTimeZone is { } zone ? TimeZoneIds.ToIana(zone) : null;
|
||||
CancellationToken IExtensionHost.Signal
|
||||
{
|
||||
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }
|
||||
|
||||
@@ -12,18 +12,31 @@ namespace Volcanic.AgenticCore.Features.Agent;
|
||||
/// <summary>The agent loop: turns, streaming with retry and overflow recovery, queues, compaction between turns.</summary>
|
||||
public sealed partial class AgentSession
|
||||
{
|
||||
/// <summary>Starts a run if none is active. When a run is already active the messages must have been queued by the caller.</summary>
|
||||
private async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Starts a run if none is active. When a run started meanwhile (after the caller's <see cref="IsStreaming"/> check),
|
||||
/// <paramref name="prompt"/> joins it: as a follow-up when <paramref name="behavior"/> is
|
||||
/// <see cref="StreamingBehavior.FollowUp"/>, else as steering. Either way the call returns when that run settles.
|
||||
/// </summary>
|
||||
internal async Task StartRunAsync(UserMessage? prompt, IReadOnlyList<ImageContent>? images, CancellationToken cancellationToken, StreamingBehavior? behavior = null)
|
||||
{
|
||||
await _runGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Task run;
|
||||
var queuedFollowUp = false;
|
||||
try
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_runTask is { IsCompleted: false })
|
||||
{
|
||||
if (prompt is not null) _steering.Enqueue(prompt);
|
||||
if (prompt is not null)
|
||||
{
|
||||
if (behavior == StreamingBehavior.FollowUp)
|
||||
{
|
||||
_followUp.Enqueue(prompt);
|
||||
queuedFollowUp = true;
|
||||
}
|
||||
else _steering.Enqueue(prompt);
|
||||
}
|
||||
run = _runTask;
|
||||
}
|
||||
else
|
||||
@@ -34,6 +47,7 @@ public sealed partial class AgentSession
|
||||
}
|
||||
}
|
||||
finally { _runGate.Release(); }
|
||||
if (queuedFollowUp) Emit(new AgentSessionEvent.QueueUpdate(_steering.Texts(), _followUp.Texts()));
|
||||
await run.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
private bool _isCompacting;
|
||||
private CompactOptions? _pendingCompaction;
|
||||
private string? _systemPromptForRun;
|
||||
private volatile TimeZoneInfo? _timeZoneOverride;
|
||||
private bool _disposed;
|
||||
|
||||
internal AgentSession(
|
||||
@@ -100,8 +101,22 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
|
||||
public string SessionId => _session.SessionId;
|
||||
public string Cwd => _options.Cwd;
|
||||
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
|
||||
/// <summary>The session's time zone: <see cref="SetTimeZone"/>, else the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => ConfiguredTimeZone ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>The zone set for this session (override, option, host default), or null when none is: then children inherit the host's <c>TZ</c>.</summary>
|
||||
private TimeZoneInfo? ConfiguredTimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone;
|
||||
|
||||
/// <summary>
|
||||
/// Change the session's zone (a host's per-chat setting). Tools and process spawns see it at once; the system prompt
|
||||
/// is rendered per run, so a run in progress keeps its prompt and the next run shows the new date and zone. Not
|
||||
/// persisted in session entries: the host owns that setting and passes <see cref="AgentSessionOptions.TimeZone"/>.
|
||||
/// </summary>
|
||||
public void SetTimeZone(TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
_timeZoneOverride = zone;
|
||||
}
|
||||
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
|
||||
public IChatChannel? Channel => _options.Channel;
|
||||
public Model? Model => _model;
|
||||
@@ -183,7 +198,8 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
await StartRunAsync(message, images, cancellationToken).ConfigureAwait(false);
|
||||
// A run may start between the check above and the run gate; behavior then decides how the prompt joins it.
|
||||
await StartRunAsync(message, images, cancellationToken, behavior).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Queue a steering message; delivered after the current turn's tools, before the next model call.</summary>
|
||||
|
||||
@@ -34,10 +34,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
private readonly VolcanicPaths _paths;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly IProcessSpawnPolicy _processes;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory,
|
||||
ISecretRedactor redactor, IProcessSpawnPolicy processes)
|
||||
ISecretRedactor redactor, IProcessSpawnPolicy processes, TimeProvider? time = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
_processes = processes;
|
||||
_services = services;
|
||||
_options = options.Value;
|
||||
@@ -101,7 +103,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
}
|
||||
if (options.ActiveTools is not null) tools.SetActive(options.ActiveTools);
|
||||
|
||||
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools);
|
||||
// The builder is created before the session; the zone is read from the session at every render (SetTimeZone).
|
||||
AgentSession? owner = null;
|
||||
// The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it
|
||||
// during activation, and follows the session's own order (AgentSession.TimeZone).
|
||||
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, _time,
|
||||
() => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local);
|
||||
var compactor = new Compactor(_options.Compaction, models);
|
||||
|
||||
Model? model = null;
|
||||
@@ -114,6 +121,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel;
|
||||
|
||||
var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger, _redactor);
|
||||
owner = agentSession;
|
||||
runner.Bind(agentSession);
|
||||
if (resumed) agentSession.RestoreBranchSettings();
|
||||
else if (model is not null) await session.AppendModelChangeAsync(model, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.SystemPrompt;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
|
||||
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
|
||||
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date and cwd. File names match case-insensitively.
|
||||
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date, time zone and cwd. File names match case-insensitively.
|
||||
/// </summary>
|
||||
public sealed class SystemPromptBuilder
|
||||
{
|
||||
@@ -15,13 +17,19 @@ public sealed class SystemPromptBuilder
|
||||
|
||||
private readonly string _cwd;
|
||||
private readonly Func<IReadOnlyList<ITool>> _activeTools;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Func<TimeZoneInfo> _zone;
|
||||
|
||||
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, Func<DateTimeOffset>? now = null)
|
||||
/// <param name="cwd">The session's working directory.</param>
|
||||
/// <param name="activeTools">The session's active tools, read at every render.</param>
|
||||
/// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param>
|
||||
/// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param>
|
||||
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? zone = null)
|
||||
{
|
||||
_cwd = cwd;
|
||||
_activeTools = activeTools;
|
||||
_now = now ?? (() => DateTimeOffset.Now);
|
||||
_time = time ?? TimeProvider.System;
|
||||
_zone = zone ?? (() => TimeZoneInfo.Local);
|
||||
}
|
||||
|
||||
public SystemPromptOptions DefaultOptions(string? basePrompt)
|
||||
@@ -62,10 +70,21 @@ public sealed class SystemPromptBuilder
|
||||
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
|
||||
}
|
||||
|
||||
AppendSection(sb, "environment", $"Current date: {_now():yyyy-MM-dd}\nWorking directory: {_cwd}");
|
||||
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Date (in <paramref name="zone"/>), zone and working directory. No time of day: the prompt is rendered per run and
|
||||
/// a clock time would change the cached prefix every run; the model gets the time from the timestamp header instead.
|
||||
/// </summary>
|
||||
public static string EnvironmentSection(DateTimeOffset utcNow, TimeZoneInfo zone, string cwd)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTime(utcNow, zone);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"Current date: {local:yyyy-MM-dd} ({local:dddd})\nTime zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(local.Offset)})\nWorking directory: {cwd}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
|
||||
/// <see cref="DefaultPrompts.ProjectContextFiles"/> present. Size capped.
|
||||
|
||||
@@ -15,4 +15,11 @@ public static class TimeZoneIds
|
||||
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
||||
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>The zone's IANA id when it has one (<see cref="ToIana"/>), else its own id. For display.</summary>
|
||||
public static string Name(TimeZoneInfo zone) => ToIana(zone) ?? zone.Id;
|
||||
|
||||
/// <summary><c>UTC+09:00</c>, <c>UTC-04:00</c>, <c>UTC+00:00</c>.</summary>
|
||||
public static string FormatOffset(TimeSpan offset) =>
|
||||
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -128,4 +128,37 @@ public class ClaudeArgsTests
|
||||
Assert.Contains(name, ex.Message);
|
||||
Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEnvironment_sets_TZ_from_the_zone_id()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return; // Windows ignores TZ; see BuildEnvironment_never_sets_TZ_on_Windows
|
||||
var env = ClaudeArgs.BuildEnvironment(new Dictionary<string, string?> { ["PATH"] = "/usr/bin" }, 1000, "Asia/Tokyo");
|
||||
Assert.Equal("Asia/Tokyo", env["TZ"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEnvironment_without_a_zone_leaves_TZ_unset_and_never_copies_the_parents()
|
||||
{
|
||||
var parent = new Dictionary<string, string?> { ["PATH"] = "/usr/bin", ["TZ"] = "America/Chicago" };
|
||||
Assert.False(ClaudeArgs.BuildEnvironment(parent, 1000).ContainsKey("TZ"));
|
||||
Assert.False(ClaudeArgs.AllowListed(parent, null).ContainsKey("TZ"));
|
||||
var withZone = ClaudeArgs.BuildEnvironment(parent, 1000, "Asia/Tokyo");
|
||||
Assert.NotEqual("America/Chicago", withZone.GetValueOrDefault("TZ"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEnvironment_never_sets_TZ_on_Windows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
Assert.False(ClaudeArgs.BuildEnvironment(new Dictionary<string, string?>(), 1000, "Asia/Tokyo").ContainsKey("TZ"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IanaZoneId_converts_and_rejects_Local()
|
||||
{
|
||||
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")));
|
||||
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time")));
|
||||
Assert.Null(ClaudeArgs.IanaZoneId(TimeZoneInfo.CreateCustomTimeZone("Local", TimeSpan.FromHours(1), "Local", "Local")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Sessions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
|
||||
|
||||
/// <summary>The session's zone reaches <c>claude -p</c> as <c>TZ</c>, per request. No test spawns the real CLI.</summary>
|
||||
[Collection(FakeScripts.Collection)]
|
||||
public sealed class ClaudeCodeTimeZoneTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("claude-tz-test-").FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Appends $TZ to tz.log, drains stdin and prints a complete text-only turn.</summary>
|
||||
private string FakeClaude()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
Fixtures.Init().GetRawText(),
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}}",
|
||||
"{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}",
|
||||
"{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":1}}",
|
||||
};
|
||||
var fixture = Path.Combine(_root, "text.jsonl");
|
||||
File.WriteAllLines(fixture, lines);
|
||||
var script = Path.Combine(_root, "fake-claude.sh");
|
||||
File.WriteAllText(script, "#!/bin/sh\n" +
|
||||
$"printf '%s\\n' \"${{TZ-unset}}\" >> \"{Path.Combine(_root, "tz.log")}\"\n" +
|
||||
"cat > /dev/null\n" +
|
||||
$"cat \"{fixture}\"\n");
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
return script;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_claude_child_gets_the_session_zone_and_a_new_one_after_SetTimeZone()
|
||||
{
|
||||
if (!Fixtures.IsPosix) return;
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new ClaudeCodeProviderOptions
|
||||
{
|
||||
ClaudePath = FakeClaude(),
|
||||
GetEnv = _ => null,
|
||||
Registry = new SessionRegistry(),
|
||||
Coordinator = new ClaudeLoginCoordinator(),
|
||||
ServerName = Fixtures.ServerName, // the name the recorded init fixtures use, as in ClaudeCodeApiTests
|
||||
});
|
||||
services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = Path.Combine(_root, "agent");
|
||||
o.DiscoverExtensionAssemblies = false;
|
||||
o.IncludeBuiltInTools = false; // no tools: the fake needs no proposal bridge
|
||||
o.DefaultModel = "claude-code/sonnet";
|
||||
o.SessionStoreFactory = _ => new InMemorySessionStore();
|
||||
o.ProcessEnvironment.HardenHostProcess = false;
|
||||
o.AddExtension<ClaudeCodeProviderExtension>();
|
||||
});
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var cwd = Directory.CreateDirectory(Path.Combine(_root, "work")).FullName;
|
||||
await using var session = await provider.GetRequiredService<IAgentSessionFactory>().CreateAsync(new AgentSessionOptions
|
||||
{
|
||||
Cwd = cwd,
|
||||
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"),
|
||||
});
|
||||
|
||||
await session.PromptAsync("hi");
|
||||
session.SetTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"));
|
||||
await session.PromptAsync("again");
|
||||
|
||||
Assert.Equal(["Asia/Tokyo", "Europe/Berlin"], File.ReadAllLines(Path.Combine(_root, "tz.log")));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ public class ProviderExtensionTests
|
||||
{
|
||||
public string Cwd => cwd;
|
||||
public string SessionId => sessionId;
|
||||
public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
|
||||
public bool HasUi => ui is not null;
|
||||
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
|
||||
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
|
||||
@@ -189,4 +190,24 @@ public class ProviderExtensionTests
|
||||
var notice = Assert.Single(ui.Notices);
|
||||
Assert.Equal(NotifyKind.Warning, notice.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Time_zone_id_follows_the_session_context_per_request()
|
||||
{
|
||||
var api = new FakeExtensionApi();
|
||||
var extension = new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, Registry = new SessionRegistry(), GetEnv = _ => null });
|
||||
await extension.ActivateAsync(api, CancellationToken.None);
|
||||
var zoneId = extension.Api!.Options.TimeZoneId!;
|
||||
Assert.Null(zoneId()); // no session yet
|
||||
|
||||
var ctx = new FakeContext(Path.GetTempPath(), "session-1") { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
|
||||
await api.Raise(new SessionStartEvent(SessionStartReason.Startup), ctx);
|
||||
Assert.Equal("Asia/Tokyo", zoneId());
|
||||
|
||||
ctx.TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); // SetTimeZone on the session
|
||||
Assert.Equal("Europe/Berlin", zoneId());
|
||||
|
||||
await api.Raise(new SessionShutdownEvent(SessionShutdownReason.Quit), ctx);
|
||||
Assert.Null(zoneId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class JobFormatTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = SchedulerHarness.Start; // Sat 26 Sep 14:03 Berlin
|
||||
private static readonly TimeZoneInfo Berlin = SchedulerHarness.Berlin;
|
||||
|
||||
private static ScheduledJob Job(string id, string name, JobKind kind, JobSchedule schedule) => new()
|
||||
{
|
||||
Id = id, Target = "111", Name = name, Kind = kind, Schedule = schedule, CreatedAt = Now,
|
||||
Text = kind == JobKind.Watcher ? null : name, Command = kind == JobKind.Watcher ? "check" : null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Job_lines_show_kind_schedule_next_and_last_failure()
|
||||
{
|
||||
var once = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
|
||||
Assert.Equal("`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00",
|
||||
JobFormat.Line(new JobView(Job("k7m2q", "Call the dentist", JobKind.Reminder, new JobSchedule(once, null)), once), Berlin, Now));
|
||||
Assert.Equal("`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 28 Sep 08:00",
|
||||
JobFormat.Line(new JobView(Job("p3xz9", "Inbox summary", JobKind.Prompt, new JobSchedule(null, "0 8 * * 1-5")), new DateTimeOffset(2026, 9, 28, 6, 0, 0, TimeSpan.Zero)), Berlin, Now));
|
||||
var failing = Job("w8r4t", "Price check", JobKind.Watcher, new JobSchedule(null, "*/10 * * * *")) with { LastResult = new JobResult(false, 1, false, "x", Now) };
|
||||
Assert.Equal("`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)",
|
||||
JobFormat.Line(new JobView(failing, new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero)), Berlin, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_omits_the_date_today()
|
||||
{
|
||||
Assert.Equal("14:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Berlin, Now));
|
||||
Assert.Equal("27 Sep 00:10", JobFormat.When(new DateTimeOffset(2026, 9, 26, 22, 10, 0, TimeSpan.Zero), Berlin, Now));
|
||||
Assert.Equal("Sat 2026-09-26 14:03", JobFormat.Stamp(Now, Berlin));
|
||||
Assert.Equal("exit 1, timed out", JobFormat.FailureReason(new JobResult(false, 1, true, null, Now)));
|
||||
Assert.Equal("did not start", JobFormat.FailureReason(new JobResult(false, null, false, "failed to start", Now)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class JobSchedulerTests
|
||||
{
|
||||
private static readonly TimeSpan Minute = TimeSpan.FromMinutes(1);
|
||||
private static readonly DateTimeOffset Start = SchedulerHarness.Start; // 12:03Z = Sat 14:03 Berlin
|
||||
|
||||
private static ScheduledJob Stored(string id, JobKind kind, JobSchedule schedule, string? text = "Call the dentist", string target = "111") => new()
|
||||
{
|
||||
Id = id, Target = target, Name = text ?? "Watch", Kind = kind, Schedule = schedule, CreatedAt = Start.AddDays(-1),
|
||||
Text = kind == JobKind.Watcher ? null : text, Command = kind == JobKind.Watcher ? "scripts/check.sh" : null,
|
||||
};
|
||||
|
||||
private static NewJob Watcher(string cron = "* * * * *") => new("Price check", JobKind.Watcher, Cron: cron, Command: "scripts/price.sh");
|
||||
|
||||
[Fact]
|
||||
public async Task An_overdue_one_off_fires_on_start_with_the_late_note_and_is_removed_after_delivery()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddMinutes(-5), null))], CancellationToken.None);
|
||||
h.Delivery.HoldTexts = new TaskCompletionSource();
|
||||
|
||||
await h.StartAsync();
|
||||
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
|
||||
Assert.Single(await h.Store.LoadAsync(CancellationToken.None)); // not removed while the delivery is in progress
|
||||
|
||||
h.Delivery.HoldTexts.SetResult();
|
||||
await h.Scheduler.IdleAsync();
|
||||
|
||||
Assert.Equal(("111", "⏰ Call the dentist (due 26 Sep 13:58, delivered late)"), Assert.Single(h.Delivery.Texts));
|
||||
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
||||
Assert.Empty(h.Scheduler.List("111"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_overdue_one_off_within_60_s_has_no_late_note()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
|
||||
await h.StartAsync();
|
||||
await h.Scheduler.IdleAsync();
|
||||
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_overdue_prompt_gets_the_offline_note()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.Store.SaveAsync([Stored("p3xz9", JobKind.Prompt, new JobSchedule(Start.AddHours(-1), null), text: "Summarise inbox/")], CancellationToken.None);
|
||||
await h.StartAsync();
|
||||
await h.Scheduler.IdleAsync();
|
||||
Assert.Equal("[Scheduled job \"Summarise inbox/\" (p3xz9)]\nThis was due at 26 Sep 13:03; the bot was offline.\nSummarise inbox/",
|
||||
Assert.Single(h.Delivery.Prompts).Prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cron_does_not_catch_up_after_downtime()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
var job = Stored("c1c1c", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")) with { LastRunAt = Start.AddHours(-3) };
|
||||
await h.Store.SaveAsync([job], CancellationToken.None);
|
||||
|
||||
await h.StartAsync();
|
||||
await h.Scheduler.IdleAsync();
|
||||
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Advancing_the_clock_fires_and_the_1_h_cap_rearms_without_firing()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("Later", JobKind.Reminder, At: "2026-09-26T17:03", Text: "Later")); // 15:03Z, 3 h away
|
||||
|
||||
Assert.Equal(Start.AddHours(1), h.Scheduler.NextWakeAt);
|
||||
await h.AdvanceAsync(TimeSpan.FromHours(1));
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
Assert.Equal(Start.AddHours(2), h.Scheduler.NextWakeAt);
|
||||
await h.AdvanceAsync(TimeSpan.FromHours(1));
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
Assert.Equal(Start.AddHours(3), h.Scheduler.NextWakeAt);
|
||||
await h.AdvanceAsync(TimeSpan.FromHours(1));
|
||||
Assert.Equal(("111", "⏰ Later"), Assert.Single(h.Delivery.Texts));
|
||||
Assert.Empty(h.Scheduler.List("111"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reminder_is_sent_as_text_and_a_prompt_as_a_prompt()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("Stretch", JobKind.Reminder, At: "2026-09-26T14:04", Text: "Stretch"));
|
||||
var prompt = await h.CreateAsync(new NewJob("Summary", JobKind.Prompt, At: "2026-09-26T14:04", Text: "Summarise inbox/"));
|
||||
|
||||
await h.AdvanceAsync(Minute);
|
||||
|
||||
Assert.Equal(("111", "⏰ Stretch"), Assert.Single(h.Delivery.Texts));
|
||||
Assert.Equal(("111", $"[Scheduled job \"Summary\" ({prompt.Job.Id})]\nSummarise inbox/"), Assert.Single(h.Delivery.Prompts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_silent_watcher_makes_no_delivery_calls_and_only_updates_its_run_fields()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
var created = (await h.CreateAsync(Watcher())).Job;
|
||||
h.Runner.Then(Enumerable.Repeat<ScriptOutcome>(new ScriptOutcome.Silent(), 10).Append(new ScriptOutcome.Output("price 48")).ToArray());
|
||||
|
||||
for (var i = 0; i < 10; i++) await h.AdvanceAsync(Minute);
|
||||
|
||||
Assert.Equal(10, h.Runner.Requests.Count);
|
||||
Assert.Empty(h.Delivery.Prompts);
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
Assert.DoesNotContain(h.Delivery.Calls, c => c.StartsWith("RunPrompt", StringComparison.Ordinal) || c.StartsWith("SendText", StringComparison.Ordinal));
|
||||
var stored = Assert.Single(await h.Store.LoadAsync(CancellationToken.None));
|
||||
Assert.Equal(created, stored with { LastRunAt = null, LastResult = null }); // nothing but the run fields changed
|
||||
Assert.Equal(Start.AddMinutes(10), stored.LastRunAt);
|
||||
Assert.True(stored.LastResult!.Ok);
|
||||
|
||||
await h.AdvanceAsync(Minute);
|
||||
|
||||
Assert.Equal(("111", $"[Scheduled job \"Price check\" ({created.Id})] The watcher script printed:\nprice 48"), Assert.Single(h.Delivery.Prompts));
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failure_is_reported_once_per_streak()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
var id = (await h.CreateAsync(Watcher())).Job.Id;
|
||||
h.Runner.Then(
|
||||
new ScriptOutcome.Failed(1, "boom", false),
|
||||
new ScriptOutcome.Failed(1, "boom again", false),
|
||||
new ScriptOutcome.Failed(2, "other", false),
|
||||
new ScriptOutcome.Failed(null, "slow", true),
|
||||
new ScriptOutcome.Silent(),
|
||||
new ScriptOutcome.Failed(2, "back", false));
|
||||
|
||||
var counts = new List<int>();
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
await h.AdvanceAsync(Minute);
|
||||
counts.Add(h.Delivery.Texts.Count);
|
||||
}
|
||||
|
||||
Assert.Equal([1, 1, 2, 3, 3, 4], counts);
|
||||
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (exit 1):\n```\nboom\n```", h.Delivery.Texts[0].Text);
|
||||
Assert.Equal($"⚠️ Job \"Price check\" ({id}) failed (timed out):\n```\nslow\n```", h.Delivery.Texts[2].Text);
|
||||
Assert.Empty(h.Delivery.Prompts); // failures never go through the model
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overlapping_occurrences_are_skipped_while_a_watcher_runs()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(Watcher());
|
||||
h.Runner.Hold = new TaskCompletionSource();
|
||||
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the first run");
|
||||
h.Time.Advance(Minute);
|
||||
await h.Scheduler.PendingTick;
|
||||
h.Time.Advance(Minute);
|
||||
await h.Scheduler.PendingTick;
|
||||
Assert.Equal(1, h.Scheduler.FiresStarted); // both occurrences were skipped by their ticks
|
||||
Assert.Single(h.Runner.Requests);
|
||||
|
||||
h.Runner.Hold.SetResult();
|
||||
h.Runner.Hold = null;
|
||||
await h.Scheduler.IdleAsync();
|
||||
await h.AdvanceAsync(Minute);
|
||||
Assert.Equal(2, h.Runner.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Overlapping_occurrences_are_skipped_while_a_prompt_run_is_held_open()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("Every minute", JobKind.Prompt, Cron: "* * * * *", Text: "tick"));
|
||||
h.Delivery.HoldPrompts = new TaskCompletionSource();
|
||||
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Delivery.Prompts.Count == 1, "the first prompt");
|
||||
h.Time.Advance(Minute);
|
||||
await h.Scheduler.PendingTick;
|
||||
Assert.Equal(1, h.Scheduler.FiresStarted);
|
||||
Assert.Single(h.Delivery.Prompts);
|
||||
|
||||
h.Delivery.HoldPrompts.SetResult();
|
||||
h.Delivery.HoldPrompts = null;
|
||||
await h.Scheduler.IdleAsync();
|
||||
await h.AdvanceAsync(Minute);
|
||||
Assert.Equal(2, h.Delivery.Prompts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Targets_are_isolated_in_List_and_CancelAsync()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
var mine = (await h.CreateAsync(new NewJob("Mine", JobKind.Reminder, Cron: "@daily", Text: "a"), "111")).Job;
|
||||
var theirs = (await h.CreateAsync(new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "b"), "222")).Job;
|
||||
|
||||
Assert.Equal([mine.Id], h.Scheduler.List("111").Select(v => v.Job.Id));
|
||||
Assert.False(await h.Scheduler.CancelAsync("222", mine.Id, CancellationToken.None));
|
||||
Assert.True(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
|
||||
Assert.False(await h.Scheduler.CancelAsync("111", mine.Id, CancellationToken.None));
|
||||
Assert.Equal([theirs.Id], (await h.Store.LoadAsync(CancellationToken.None)).Select(j => j.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_kills_a_running_watcher_and_drops_its_output()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
var id = (await h.CreateAsync(Watcher())).Job.Id;
|
||||
h.Runner.Fallback = new ScriptOutcome.Output("late output");
|
||||
h.Runner.IgnoreCancellation = true; // the outcome still arrives after the cancel; it must be thrown away
|
||||
h.Runner.Hold = new TaskCompletionSource();
|
||||
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
|
||||
Assert.True(await h.Scheduler.CancelAsync("111", id, CancellationToken.None));
|
||||
Assert.Equal(1, h.Runner.CancellationsSeen);
|
||||
|
||||
h.Runner.Hold.SetResult();
|
||||
await h.Scheduler.IdleAsync();
|
||||
Assert.Empty(h.Delivery.Prompts);
|
||||
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_undeliverable_target_is_skipped_and_its_job_kept()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "ping"));
|
||||
await h.CreateAsync(new NewJob("Once", JobKind.Reminder, At: "2026-09-26T14:30", Text: "once"));
|
||||
h.Delivery.Undeliverable.Add("111");
|
||||
|
||||
await h.AdvanceAsync(TimeSpan.FromMinutes(57)); // past 14:30 and 15:00 Berlin
|
||||
|
||||
Assert.Empty(h.Delivery.Texts);
|
||||
var jobs = h.Scheduler.List("111");
|
||||
Assert.Equal(2, jobs.Count);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 14, 0, 0, TimeSpan.Zero), jobs.Single(j => j.Job.Name == "Hourly").NextRunAt); // advanced
|
||||
Assert.Equal(2, (await h.Store.LoadAsync(CancellationToken.None)).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_kills_scripts_and_returns()
|
||||
{
|
||||
var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(Watcher());
|
||||
h.Runner.Hold = new TaskCompletionSource();
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
|
||||
|
||||
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(1, h.Runner.CancellationsSeen);
|
||||
await h.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_gives_up_after_the_grace_period()
|
||||
{
|
||||
var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(Watcher());
|
||||
h.Runner.IgnoreCancellation = true;
|
||||
h.Runner.Hold = new TaskCompletionSource();
|
||||
h.Time.Advance(Minute);
|
||||
await Eventually.UntilAsync(() => h.Runner.Requests.Count == 1, "the run");
|
||||
|
||||
var stoppedAt = h.Time.GetUtcNow();
|
||||
var stop = h.Scheduler.StopAsync(CancellationToken.None);
|
||||
Assert.False(stop.IsCompleted); // waiting on the fake clock's grace period
|
||||
// Second by second: the grace timer may be created after the first advances.
|
||||
await Eventually.UntilAsync(() => { h.Time.Advance(TimeSpan.FromSeconds(1)); return stop.IsCompleted; }, "the stop after the grace period");
|
||||
Assert.True(h.Time.GetUtcNow() - stoppedAt >= JobScheduler.StopGrace);
|
||||
await stop;
|
||||
|
||||
h.Runner.Hold.SetResult();
|
||||
await h.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stored_job_whose_zone_lookup_throws_does_not_stop_the_start()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.Store.SaveAsync([
|
||||
Stored("bad11", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *"), target: "not-a-chat"),
|
||||
Stored("good1", JobKind.Reminder, new JobSchedule(null, "*/10 * * * *")),
|
||||
], CancellationToken.None);
|
||||
h.Delivery.BadTargets.Add("not-a-chat");
|
||||
|
||||
await h.StartAsync();
|
||||
|
||||
Assert.True(h.Scheduler.IsStarted);
|
||||
Assert.Null(Assert.Single(h.Scheduler.List("not-a-chat")).NextRunAt); // kept, unarmed
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 10, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("111")).NextRunAt);
|
||||
await h.AdvanceAsync(TimeSpan.FromMinutes(7));
|
||||
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(h.Delivery.Texts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_one_off_whose_delivery_is_cut_off_by_shutdown_is_kept_and_fires_after_the_next_start()
|
||||
{
|
||||
var h = new SchedulerHarness();
|
||||
await h.Store.SaveAsync([Stored("k7m2q", JobKind.Reminder, new JobSchedule(Start.AddSeconds(-30), null))], CancellationToken.None);
|
||||
h.Delivery.HoldTexts = new TaskCompletionSource(); // the send is in flight when the bot stops
|
||||
await h.StartAsync();
|
||||
await Eventually.UntilAsync(() => h.Delivery.Texts.Count == 1, "the reminder send");
|
||||
|
||||
await h.Scheduler.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal("k7m2q", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).Id);
|
||||
var delivery = new FakeJobDelivery();
|
||||
await using (var restarted = new JobScheduler(h.Options, h.Runner, h.Redactor, h.Time, Microsoft.Extensions.Logging.Abstractions.NullLogger<JobScheduler>.Instance))
|
||||
{
|
||||
await restarted.StartAsync(delivery, CancellationToken.None);
|
||||
await restarted.IdleAsync();
|
||||
}
|
||||
Assert.Equal(("111", "⏰ Call the dentist"), Assert.Single(delivery.Texts));
|
||||
Assert.Empty(await h.Store.LoadAsync(CancellationToken.None));
|
||||
await h.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_zone_change_recomputes_cron_and_leaves_one_offs()
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 9, 26, 23, 30, 0, TimeSpan.Zero); // Sun 01:30 Berlin, Sun 08:30 Tokyo
|
||||
await using var h = new SchedulerHarness(start);
|
||||
await h.StartAsync();
|
||||
var cron = (await h.CreateAsync(new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"))).Job;
|
||||
var once = (await h.CreateAsync(new NewJob("Dentist", JobKind.Reminder, At: "2026-09-27T10:00", Text: "Dentist"))).Job;
|
||||
var other = (await h.CreateAsync(new NewJob("Other", JobKind.Reminder, Cron: "0 9 * * *", Text: "x"), "222")).Job;
|
||||
var berlinNine = new DateTimeOffset(2026, 9, 27, 7, 0, 0, TimeSpan.Zero);
|
||||
Assert.Equal(start.AddHours(1), h.Scheduler.NextWakeAt); // capped
|
||||
|
||||
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
|
||||
var change = await h.Scheduler.TimeZoneChangedAsync("111", CancellationToken.None);
|
||||
|
||||
Assert.Equal(new ZoneChange(1, 1), change);
|
||||
var views = h.Scheduler.List("111").ToDictionary(v => v.Job.Id);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), views[cron.Id].NextRunAt); // 09:00 Tokyo
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 27, 8, 0, 0, TimeSpan.Zero), views[once.Id].NextRunAt); // unchanged instant
|
||||
Assert.Equal(berlinNine, Assert.Single(h.Scheduler.List("222")).NextRunAt);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), h.Scheduler.NextWakeAt); // re-armed
|
||||
Assert.Equal(other.Id, h.Scheduler.List("222")[0].Job.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watcher_output_and_failure_tails_are_redacted()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(Watcher());
|
||||
h.Runner.Then(new ScriptOutcome.Output("token=hunter2-secret"), new ScriptOutcome.Failed(1, "auth hunter2-secret rejected", false));
|
||||
|
||||
await h.AdvanceAsync(Minute);
|
||||
await h.AdvanceAsync(Minute);
|
||||
|
||||
var prompt = Assert.Single(h.Delivery.Prompts).Prompt;
|
||||
Assert.EndsWith("token=[REDACTED]", prompt);
|
||||
var alert = Assert.Single(h.Delivery.Texts).Text;
|
||||
Assert.Contains("auth [REDACTED] rejected", alert);
|
||||
Assert.DoesNotContain("hunter2", prompt + alert);
|
||||
Assert.Equal("auth [REDACTED] rejected", Assert.Single(await h.Store.LoadAsync(CancellationToken.None)).LastResult!.StderrTail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delivery_failures_are_logged_and_the_job_still_advances()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("Tick", JobKind.Reminder, Cron: "* * * * *", Text: "tick"));
|
||||
h.Delivery.FailSends = new InvalidOperationException("Telegram is down");
|
||||
|
||||
await h.AdvanceAsync(Minute);
|
||||
|
||||
var view = Assert.Single(h.Scheduler.List("111"));
|
||||
Assert.Equal(Start.AddMinutes(1), view.Job.LastRunAt);
|
||||
Assert.Equal(Start.AddMinutes(2), view.NextRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_validates_and_caps_jobs_per_target()
|
||||
{
|
||||
await using var h = new SchedulerHarness(configure: o => o.MaxJobsPerTarget = 2);
|
||||
await h.StartAsync();
|
||||
await h.CreateAsync(new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "a"));
|
||||
await h.CreateAsync(new NewJob("b", JobKind.Reminder, Cron: "@daily", Text: "b"));
|
||||
|
||||
var full = await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c")));
|
||||
Assert.Equal("This chat already has 2 scheduled jobs, the maximum. Cancel one first.", full.Message);
|
||||
await h.CreateAsync(new NewJob("c", JobKind.Reminder, Cron: "@daily", Text: "c"), "222"); // other targets have their own cap
|
||||
await Assert.ThrowsAsync<JobValidationException>(() => h.CreateAsync(new NewJob("bad", JobKind.Reminder, Cron: "0 0 9 * * *", Text: "x"), "333"));
|
||||
|
||||
var ids = h.Scheduler.List("111").Select(v => v.Job.Id).ToList();
|
||||
Assert.All(ids, id => Assert.Matches("^[abcdefghjkmnpqrstuvwxyz23456789]{5}$", id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watcher_runs_with_the_target_workspace_zone_and_timeout()
|
||||
{
|
||||
await using var h = new SchedulerHarness();
|
||||
h.Delivery.Zones["111"] = SchedulerHarness.Tokyo;
|
||||
await h.StartAsync();
|
||||
var job = (await h.CreateAsync(Watcher())).Job;
|
||||
await h.CreateAsync(new NewJob("Quick", JobKind.Watcher, Cron: "* * * * *", Command: "true", TimeoutSeconds: 5), "222");
|
||||
|
||||
await h.AdvanceAsync(Minute);
|
||||
|
||||
var requests = h.Runner.Requests.OrderBy(r => r.Target).ToList();
|
||||
Assert.Equal(new ScriptRequest("scripts/price.sh", Path.Combine(h.Delivery.WorkspaceRoot, "111"), TimeSpan.FromSeconds(300), "111", job.Id, "Asia/Tokyo"), requests[0]);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), requests[1].Timeout);
|
||||
Assert.Equal("Europe/Berlin", requests[1].TimeZoneId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class JobStoreTests : IDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 14, 3, 0, TimeSpan.Zero);
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("jobstore-").FullName;
|
||||
private readonly FakeTimeProvider _time = new(Now);
|
||||
|
||||
private string StorePath => Path.Combine(_root, "scheduler", "jobs.json");
|
||||
private JobStore Store() => new(StorePath, _time, NullLogger.Instance);
|
||||
|
||||
internal static ScheduledJob Reminder(string id, string target = "111") => new()
|
||||
{
|
||||
Id = id, Target = target, Name = "Call the dentist", Kind = JobKind.Reminder, Text = "Call the dentist",
|
||||
Schedule = new JobSchedule(Now.AddDays(1), null), CreatedAt = Now,
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_file_loads_empty() => Assert.Empty(await Store().LoadAsync(CancellationToken.None));
|
||||
|
||||
[Fact]
|
||||
public async Task Round_trip_keeps_jobs_and_leaves_no_temp_file()
|
||||
{
|
||||
var watcher = new ScheduledJob
|
||||
{
|
||||
Id = "w8r4t", Target = "111", Name = "Price check", Kind = JobKind.Watcher, Command = "scripts/price.sh",
|
||||
TimeoutSeconds = 60, Schedule = new JobSchedule(null, "*/10 * * * *"), CreatedAt = Now,
|
||||
LastRunAt = Now, LastResult = new JobResult(false, 1, false, "boom", Now),
|
||||
};
|
||||
await Store().SaveAsync([Reminder("k7m2q"), watcher], CancellationToken.None);
|
||||
|
||||
Assert.Equal([Reminder("k7m2q"), watcher], await Store().LoadAsync(CancellationToken.None));
|
||||
Assert.Equal(["jobs.json"], Directory.GetFiles(Path.GetDirectoryName(StorePath)!).Select(Path.GetFileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_format_is_versioned_camel_case()
|
||||
{
|
||||
await Store().SaveAsync([Reminder("k7m2q")], CancellationToken.None);
|
||||
var json = await File.ReadAllTextAsync(StorePath);
|
||||
Assert.Contains("\"version\": 1", json);
|
||||
Assert.Contains("\"jobs\": [", json);
|
||||
Assert.Contains("\"kind\": \"reminder\"", json);
|
||||
Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(StorePath))[0]); // no BOM
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Corrupt_file_is_set_aside_and_loading_starts_empty()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
||||
await File.WriteAllTextAsync(StorePath, "{ not json");
|
||||
|
||||
Assert.Empty(await Store().LoadAsync(CancellationToken.None));
|
||||
Assert.False(File.Exists(StorePath));
|
||||
Assert.Equal("{ not json", await File.ReadAllTextAsync(StorePath + ".corrupt-20260926140300"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_entries_are_dropped()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
||||
await File.WriteAllTextAsync(StorePath, """
|
||||
{ "version": 1, "jobs": [
|
||||
{ "id": "good1", "target": "111", "name": "ok", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
||||
{ "id": "both1", "target": "111", "name": "both", "kind": "reminder", "text": "hi", "schedule": { "at": "2026-09-27T09:00:00+00:00", "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
||||
{ "id": "nocmd", "target": "111", "name": "watch", "kind": "watcher", "schedule": { "cron": "* * * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
||||
{ "id": "good1", "target": "111", "name": "duplicate id", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
|
||||
] }
|
||||
""");
|
||||
|
||||
var jobs = await Store().LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("ok", Assert.Single(jobs).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_entry_that_does_not_deserialise_is_dropped_without_setting_the_file_aside()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
||||
await File.WriteAllTextAsync(StorePath, """
|
||||
{ "version": 1, "jobs": [
|
||||
{ "id": "good1", "target": "111", "name": "ok", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
||||
{ "id": "kind1", "target": "111", "name": "bad kind", "kind": "alarm", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
||||
{ "id": "noat1", "target": "111", "name": "no createdAt", "kind": "reminder", "text": "hi", "schedule": { "cron": "0 9 * * *" } },
|
||||
null,
|
||||
42
|
||||
] }
|
||||
""");
|
||||
|
||||
var jobs = await Store().LoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("good1", Assert.Single(jobs).Id);
|
||||
Assert.True(File.Exists(StorePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_saves_are_serialised()
|
||||
{
|
||||
var store = Store();
|
||||
await Task.WhenAll(Enumerable.Range(0, 20).Select(i => Task.Run(() => store.SaveAsync([Reminder($"id{i:000}")], CancellationToken.None))));
|
||||
|
||||
Assert.Single(await Store().LoadAsync(CancellationToken.None));
|
||||
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(StorePath)!, "*.tmp"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class JobValidationTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin
|
||||
private static readonly TimeZoneInfo Berlin = ScheduleTests.Berlin;
|
||||
|
||||
private static string Error(NewJob job) =>
|
||||
Assert.Throws<JobValidationException>(() => JobValidation.ValidateNew(job, Now, Berlin)).Message;
|
||||
|
||||
[Fact]
|
||||
public void Past_at_is_rejected_beyond_the_grace()
|
||||
{
|
||||
Assert.Equal("at 2026-09-26 13:00 (Europe/Berlin) is in the past; now is 2026-09-26 14:03. Pick a future time.",
|
||||
Error(new NewJob("x", JobKind.Reminder, At: "2026-09-26T13:00", Text: "hi")));
|
||||
var schedule = JobValidation.ValidateNew(new NewJob("x", JobKind.Reminder, At: "2026-09-26T12:02:30Z", Text: "hi"), Now, Berlin);
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 12, 2, 30, TimeSpan.Zero), schedule.At); // within 60 s of grace
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("2026-09-27T09:00", "0 9 * * *")]
|
||||
public void Exactly_one_of_at_and_cron(string? at, string? cron) =>
|
||||
Assert.Equal("Set exactly one of at (a one-off time) or cron (a recurring schedule).",
|
||||
Error(new NewJob("x", JobKind.Reminder, at, cron, Text: "hi")));
|
||||
|
||||
[Fact]
|
||||
public void Kind_specific_fields()
|
||||
{
|
||||
const string cron = "0 9 * * *";
|
||||
Assert.Equal("A reminder needs text (the message to send).", Error(new NewJob("x", JobKind.Reminder, Cron: cron)));
|
||||
Assert.Equal("A prompt job needs text (what to ask the agent).", Error(new NewJob("x", JobKind.Prompt, Cron: cron)));
|
||||
Assert.Equal("command is only for watchers.", Error(new NewJob("x", JobKind.Reminder, Cron: cron, Text: "t", Command: "ls")));
|
||||
Assert.Equal("A watcher needs command (the shell command to run).", Error(new NewJob("x", JobKind.Watcher, Cron: cron)));
|
||||
Assert.Equal("A watcher takes no text: its script's output becomes the prompt.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Text: "t", Command: "ls")));
|
||||
Assert.Equal("timeoutSeconds is only for watchers.", Error(new NewJob("x", JobKind.Prompt, Cron: cron, Text: "t", TimeoutSeconds: 10)));
|
||||
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 0)));
|
||||
Assert.Equal("timeoutSeconds must be between 1 and 3600.", Error(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3601)));
|
||||
Assert.Equal(new JobSchedule(null, cron), JobValidation.ValidateNew(new NewJob("x", JobKind.Watcher, Cron: cron, Command: "ls", TimeoutSeconds: 3600), Now, Berlin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_is_required_and_capped()
|
||||
{
|
||||
Assert.Equal("name is required (a short label such as \"Call the dentist\").", Error(new NewJob(" ", JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||
Assert.Equal("name is 81 characters; the limit is 80.", Error(new NewJob(new string('n', 81), JobKind.Reminder, Cron: "@daily", Text: "t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Text_is_capped_at_4000() =>
|
||||
Assert.Equal("text is 4001 characters; the limit is 4000.", Error(new NewJob("x", JobKind.Prompt, Cron: "@daily", Text: new string('t', 4001))));
|
||||
|
||||
[Fact]
|
||||
public void Stored_jobs_are_checked_without_the_future_rule()
|
||||
{
|
||||
var job = new ScheduledJob
|
||||
{
|
||||
Id = "k7m2q", Target = "111", Name = "Old", Kind = JobKind.Reminder, Text = "hi",
|
||||
Schedule = new JobSchedule(Now.AddDays(-3), null), CreatedAt = Now.AddDays(-4),
|
||||
};
|
||||
Assert.Null(JobValidation.CheckStored(job)); // an overdue one-off is fine on load: it fires late
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(Now, "0 9 * * *") }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Schedule = new JobSchedule(null, "0 0 9 * * *") }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Kind = JobKind.Watcher }));
|
||||
Assert.NotNull(JobValidation.CheckStored(job with { Id = "" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class ScheduleTests
|
||||
{
|
||||
internal static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
internal static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
||||
|
||||
private static DateTimeOffset Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, TimeSpan.Zero);
|
||||
|
||||
private static List<DateTimeOffset> Occurrences(string cron, DateTimeOffset from, DateTimeOffset to, TimeZoneInfo zone)
|
||||
{
|
||||
var list = new List<DateTimeOffset>();
|
||||
for (var next = Schedules.NextCron(cron, from, zone); next is { } n && n < to; next = Schedules.NextCron(cron, n, zone)) list.Add(n);
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Next_cron_occurrence_is_strictly_after_from_in_the_zone()
|
||||
{
|
||||
var nineBerlin = Utc(2026, 9, 26, 7, 0); // 09:00 CEST
|
||||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Berlin));
|
||||
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.NextCron("0 9 * * *", nineBerlin, Tokyo)); // 09:00 JST on the 27th
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spring_forward_day_fires_once_at_the_transition()
|
||||
{
|
||||
// 2026-03-29: Berlin skips 02:00–03:00. 02:30 doesn't exist, so the job fires at the transition (01:00Z).
|
||||
var fires = Occurrences("30 2 * * *", Utc(2026, 3, 28, 12, 0), Utc(2026, 3, 30, 12, 0), Berlin);
|
||||
Assert.Equal([Utc(2026, 3, 29, 1, 0), Utc(2026, 3, 30, 0, 30)], fires);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fall_back_day_fires_once()
|
||||
{
|
||||
// 2026-10-25: Berlin repeats 02:00–03:00; 02:30 happens twice but the job fires once that day.
|
||||
var fires = Occurrences("30 2 * * *", Utc(2026, 10, 24, 12, 0), Utc(2026, 10, 26, 12, 0), Berlin);
|
||||
Assert.Single(fires, f => TimeZoneInfo.ConvertTime(f, Berlin).Day == 25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_without_an_offset_is_read_in_the_zone()
|
||||
{
|
||||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00", Berlin));
|
||||
Assert.Equal(Utc(2026, 9, 27, 0, 0), Schedules.ParseAt("2026-09-27T09:00", Tokyo));
|
||||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27 09:00", Berlin));
|
||||
Assert.Equal(Utc(2026, 9, 26, 22, 0), Schedules.ParseAt("2026-09-27", Berlin)); // a bare date is local midnight, not an offset
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_with_an_offset_is_used_as_given()
|
||||
{
|
||||
Assert.Equal(Utc(2026, 9, 27, 4, 0), Schedules.ParseAt("2026-09-27T09:00+05:00", Berlin));
|
||||
Assert.Equal(Utc(2026, 9, 27, 9, 0), Schedules.ParseAt("2026-09-27T09:00Z", Tokyo));
|
||||
Assert.Equal(Utc(2026, 9, 27, 7, 0), Schedules.ParseAt("2026-09-27T09:00:00.000+0200", Tokyo));
|
||||
Assert.Equal(Utc(2026, 9, 27, 11, 0), Schedules.ParseAt("2026-09-27 09:00-02", Tokyo));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("09:00")] // no date: would take the machine's date, not the chat's
|
||||
[InlineData("09:00+02:00")]
|
||||
[InlineData("09/10/2026")] // culture formats aren't ISO 8601
|
||||
[InlineData("27.09.2026 09:00")]
|
||||
[InlineData("2026-09-27T09:00+15:00")] // beyond ±14:00
|
||||
public void At_without_a_date_or_not_in_ISO_8601_is_rejected(string at)
|
||||
{
|
||||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt(at, Berlin));
|
||||
Assert.Contains("ISO 8601 with the date", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not a cron", "has 3 fields")]
|
||||
[InlineData("61 * * * *", "is invalid")]
|
||||
[InlineData("0 0 8 * * *", "seconds aren't supported")]
|
||||
[InlineData("H * * * *", "H (hash)")]
|
||||
[InlineData("0 H/2 * * *", "H (hash)")]
|
||||
[InlineData("", "cron is empty")]
|
||||
public void Invalid_cron_is_rejected_with_a_readable_message(string cron, string fragment)
|
||||
{
|
||||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseCron(cron));
|
||||
Assert.Contains(fragment, ex.Message);
|
||||
if (cron.Length > 0) Assert.Contains("minute hour day-of-month month day-of-week", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("@daily")]
|
||||
[InlineData("@hourly")]
|
||||
[InlineData("0 8 * * THU")]
|
||||
[InlineData("*/10 * * * *")]
|
||||
[InlineData("0 8 * * 1-5")]
|
||||
public void Macros_and_day_names_parse(string cron) => Schedules.ParseCron(cron);
|
||||
|
||||
[Fact]
|
||||
public void Unparseable_at_is_rejected()
|
||||
{
|
||||
var ex = Assert.Throws<JobValidationException>(() => Schedules.ParseAt("tomorrow at nine", Berlin));
|
||||
Assert.Contains("ISO 8601", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
public sealed class ScheduleToolTests
|
||||
{
|
||||
private static async Task<SchedulerHarness> StartedAsync()
|
||||
{
|
||||
var h = new SchedulerHarness(); // Sat 2026-09-26 12:03Z
|
||||
h.Delivery.Zones["111"] = SchedulerHarness.Berlin;
|
||||
h.Delivery.Zones["222"] = SchedulerHarness.Tokyo;
|
||||
await h.StartAsync();
|
||||
return h;
|
||||
}
|
||||
|
||||
private static Task<ToolResult> Run(SchedulerHarness h, string target, object args) =>
|
||||
new ScheduleTool(h.Scheduler, target).ExecuteAsync(
|
||||
new ToolCallContent("call-1", ScheduleTool.ToolName, VolcanicJson.ToElement(args)),
|
||||
new ToolInvocation("call-1", new SchedulerStubContext(), null), CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_list_and_cancel_through_the_tool()
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
|
||||
var created = await Run(h, "111", new { action = "create", name = "Call the dentist", kind = "reminder", at = "2026-09-27T09:00", text = "Call the dentist" });
|
||||
var id = Assert.Single(h.Scheduler.List("111")).Job.Id;
|
||||
Assert.Equal(
|
||||
$"Created: `{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNext run: Sun 2026-09-27 09:00 (Europe/Berlin)\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)",
|
||||
created.TextContent);
|
||||
|
||||
var listed = await Run(h, "111", new { action = "list" });
|
||||
Assert.Equal($"`{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", listed.TextContent);
|
||||
|
||||
var cancelled = await Run(h, "111", new { action = "cancel", id });
|
||||
Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent);
|
||||
Assert.Equal("No scheduled jobs.\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", (await Run(h, "111", new { action = "list" })).TextContent);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"0 0 9 * * *","text":"t"}""", "seconds aren't supported")]
|
||||
[InlineData("""{"action":"create","name":"x","kind":"reminder","at":"2026-09-26T13:00","text":"t"}""", "is in the past")]
|
||||
[InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"@daily"}""", "A reminder needs text")]
|
||||
[InlineData("""{"action":"create","name":"x","kind":"alarm","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")]
|
||||
[InlineData("""{"action":"create","name":"x","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")]
|
||||
[InlineData("""{"action":"snooze"}""", "action must be create, list or cancel.")]
|
||||
[InlineData("""{"action":"cancel"}""", "cancel needs id (see action=list).")]
|
||||
[InlineData("""{"action":"cancel","id":"zzzzz"}""", "No job zzzzz in this chat.")]
|
||||
public async Task Validation_errors_become_error_results(string json, string fragment)
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
var tool = new ScheduleTool(h.Scheduler, "111");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => tool.ExecuteAsync(
|
||||
new ToolCallContent("c", ScheduleTool.ToolName, VolcanicJson.Parse(json)), new ToolInvocation("c", new SchedulerStubContext(), null), CancellationToken.None));
|
||||
|
||||
Assert.Contains(fragment, ex.Message);
|
||||
Assert.Empty(h.Scheduler.List("111"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_and_cancel_never_see_another_targets_jobs()
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
await Run(h, "222", new { action = "create", name = "Theirs", kind = "reminder", cron = "@daily", text = "t" });
|
||||
var theirs = Assert.Single(h.Scheduler.List("222")).Job.Id;
|
||||
|
||||
Assert.StartsWith("No scheduled jobs.", (await Run(h, "111", new { action = "list" })).TextContent);
|
||||
var ex = await Assert.ThrowsAsync<ToolExecutionException>(() => Run(h, "111", new { action = "cancel", id = theirs }));
|
||||
Assert.Equal($"No job {theirs} in this chat.", ex.Message);
|
||||
Assert.Single(h.Scheduler.List("222"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_now_line_uses_the_targets_zone()
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
Assert.EndsWith("Now: Sat 2026-09-26 21:03 (Asia/Tokyo)", (await Run(h, "222", new { action = "list" })).TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_offset_less_at_is_read_in_the_targets_zone()
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
await Run(h, "222", new { action = "create", name = "Tokyo", kind = "prompt", at = "2026-09-27T09:00", text = "Good morning" });
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("222")).Job.Schedule.At);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snippet_and_guidelines_match_the_spec()
|
||||
{
|
||||
await using var h = await StartedAsync();
|
||||
var tool = new ScheduleTool(h.Scheduler, "111");
|
||||
Assert.Equal("schedule", tool.Name);
|
||||
Assert.Equal("schedule: reminders, recurring prompts and watcher scripts that message this chat", tool.PromptSnippet);
|
||||
Assert.Equal(4, tool.PromptGuidelines.Count);
|
||||
Assert.DoesNotContain(tool.PromptGuidelines, g => g.Contains("Europe/", StringComparison.Ordinal)); // zone-free, so the prompt text is stable
|
||||
Assert.Contains(tool.PromptGuidelines, g => g.Contains("scripts/", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Discovery_without_a_host_scheduler_cannot_construct_the_extension()
|
||||
{
|
||||
using var provider = new ServiceCollection().BuildServiceProvider();
|
||||
Assert.Throws<InvalidOperationException>(() => ActivatorUtilities.CreateInstance(provider, typeof(SchedulerExtension)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Processes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
||||
|
||||
/// <summary>Real processes (Unix only, like the other script tests) with a real ProcessSpawnPolicy over an injected environment.</summary>
|
||||
public sealed class ScriptRunnerTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("scriptrunner-").FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>The real environment (so the shell starts) plus host secrets the default deny-list must remove.</summary>
|
||||
private static Dictionary<string, string?> Snapshot()
|
||||
{
|
||||
var env = new Dictionary<string, string?>(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
foreach (DictionaryEntry e in Environment.GetEnvironmentVariables()) env[(string)e.Key] = (string?)e.Value;
|
||||
env.Remove("TZ");
|
||||
env["TELEGRAM_BOT_TOKEN"] = "123:bot-token";
|
||||
env["OPENAI_API_KEY"] = "sk-test-key";
|
||||
return env;
|
||||
}
|
||||
|
||||
private ScriptRunner Runner(Action<ProcessEnvironmentOptions>? configure = null, SchedulerOptions? options = null)
|
||||
{
|
||||
var env = Snapshot();
|
||||
var processOptions = new ProcessEnvironmentOptions { HardenHostProcess = false };
|
||||
configure?.Invoke(processOptions);
|
||||
return new ScriptRunner(options ?? new SchedulerOptions(), new ProcessSpawnPolicy(processOptions, null, () => env), NullLogger<ScriptRunner>.Instance);
|
||||
}
|
||||
|
||||
private ScriptRequest Request(string command, TimeSpan? timeout = null, string? zone = null, string? cwd = null) =>
|
||||
new(command, cwd ?? Path.Combine(_root, "workspace"), timeout ?? TimeSpan.FromSeconds(30), "111", "w8r4t", zone);
|
||||
|
||||
[Fact]
|
||||
public async Task Output_is_trimmed_stdout()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("printf ' price 48\\n\\n'"), CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("price 48"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_output_is_silent()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("true"), CancellationToken.None));
|
||||
Assert.IsType<ScriptOutcome.Silent>(await Runner().RunAsync(Request("printf ' \\n'; echo noise >&2"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_zero_exit_is_a_failure_with_the_last_20_stderr_lines()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("for i in $(seq 1 30); do echo line$i >&2; done; exit 3"), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.Equal(3, failed.ExitCode);
|
||||
Assert.False(failed.TimedOut);
|
||||
Assert.Equal(Enumerable.Range(11, 20).Select(i => $"line{i}"), failed.StderrTail.Split('\n'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_kills_the_process_tree()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var pidFile = Path.Combine(_root, "child.pid");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var outcome = await Runner().RunAsync(Request($"sleep 30 & echo $! > '{pidFile}'; wait", TimeSpan.FromSeconds(1)), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.True(failed.TimedOut);
|
||||
Assert.Null(failed.ExitCode);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"took {stopwatch.Elapsed}");
|
||||
var child = int.Parse(File.ReadAllText(pidFile).Trim(), System.Globalization.CultureInfo.InvariantCulture);
|
||||
await Task.Delay(200);
|
||||
Assert.True(IsGone(child), "the background sleep must be killed with the tree");
|
||||
}
|
||||
|
||||
private static bool IsGone(int pid)
|
||||
{
|
||||
try { return Process.GetProcessById(pid).HasExited; }
|
||||
catch (ArgumentException) { return true; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_kills_the_script_and_throws()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Runner().RunAsync(Request("sleep 30"), cts.Token));
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capture_is_capped_to_head_and_tail()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(Request("head -c 200000 /dev/zero | tr '\\0' a; printf END"), CancellationToken.None);
|
||||
|
||||
var text = Assert.IsType<ScriptOutcome.Output>(outcome).Text;
|
||||
Assert.StartsWith(new string('a', 100), text);
|
||||
Assert.EndsWith("aEND", text);
|
||||
Assert.Contains("characters omitted", text);
|
||||
Assert.True(text.Length < OutputCapture.HeadChars + OutputCapture.TailChars + 200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_secrets_are_absent_and_TZ_is_the_target_zone()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var outcome = await Runner().RunAsync(
|
||||
Request("printf '%s|%s|%s' \"${TELEGRAM_BOT_TOKEN-unset}\" \"${OPENAI_API_KEY-unset}\" \"$TZ\"", zone: "Asia/Tokyo"),
|
||||
CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("unset|unset|Asia/Tokyo"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_host_hook_fails_without_starting_a_process()
|
||||
{
|
||||
var marker = Path.Combine(_root, "workspace", "started");
|
||||
var runner = Runner(o => o.Hooks.Add(_ => throw new InvalidOperationException("nope")));
|
||||
|
||||
var outcome = await runner.RunAsync(Request(OperatingSystem.IsWindows() ? $"type nul > \"{marker}\"" : $"touch '{marker}'"), CancellationToken.None);
|
||||
|
||||
Assert.Equal(new ScriptOutcome.Failed(null, "process environment hook failed: nope", false), outcome);
|
||||
Assert.False(File.Exists(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cwd_is_the_workspace()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var cwd = Path.Combine(_root, "not-yet-created");
|
||||
var outcome = await Runner().RunAsync(Request("basename \"$(pwd)\"", cwd: cwd), CancellationToken.None);
|
||||
Assert.Equal(new ScriptOutcome.Output("not-yet-created"), outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_shell_that_fails_to_start_is_a_failure()
|
||||
{
|
||||
var shell = Path.Combine(_root, "no-such-shell");
|
||||
var outcome = await Runner(options: new SchedulerOptions { Shell = [shell, "-c"] }).RunAsync(Request("true"), CancellationToken.None);
|
||||
|
||||
var failed = Assert.IsType<ScriptOutcome.Failed>(outcome);
|
||||
Assert.StartsWith($"failed to start {shell}: ", failed.StderrTail);
|
||||
Assert.Null(failed.ExitCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OutputCaptureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Keeps_the_head_and_tail_and_counts_the_rest()
|
||||
{
|
||||
var capture = new OutputCapture();
|
||||
capture.Append(new string('h', OutputCapture.HeadChars));
|
||||
capture.Append(new string('x', 1000));
|
||||
capture.Append(new string('t', OutputCapture.TailChars));
|
||||
|
||||
var text = capture.ToString();
|
||||
|
||||
Assert.StartsWith(new string('h', OutputCapture.HeadChars), text);
|
||||
Assert.EndsWith(new string('t', OutputCapture.TailChars), text);
|
||||
Assert.Contains("[… 1000 characters omitted …]", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_output_is_kept_whole()
|
||||
{
|
||||
var capture = new OutputCapture();
|
||||
capture.Append("one\n");
|
||||
capture.Append("two");
|
||||
Assert.Equal("one\ntwo", capture.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Abstractions.Ui;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
|
||||
|
||||
/// <summary>Records every delivery call; can hold sends and prompt runs open, fail sends, and switch a target's zone.</summary>
|
||||
internal sealed class FakeJobDelivery : IJobDelivery
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly List<(string Target, string Text)> _texts = [];
|
||||
private readonly List<(string Target, string Prompt)> _prompts = [];
|
||||
private readonly List<string> _calls = [];
|
||||
|
||||
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
|
||||
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
public HashSet<string> Undeliverable { get; } = [];
|
||||
/// <summary>Targets whose <see cref="TimeZoneFor"/> throws (a store entry the host can't map).</summary>
|
||||
public HashSet<string> BadTargets { get; } = [];
|
||||
public TaskCompletionSource? HoldTexts { get; set; }
|
||||
public TaskCompletionSource? HoldPrompts { get; set; }
|
||||
public Exception? FailSends { get; set; }
|
||||
public string WorkspaceRoot { get; set; } = Path.Combine(Path.GetTempPath(), "fake-workspaces");
|
||||
|
||||
public IReadOnlyList<(string Target, string Text)> Texts { get { lock (_lock) return _texts.ToList(); } }
|
||||
public IReadOnlyList<(string Target, string Prompt)> Prompts { get { lock (_lock) return _prompts.ToList(); } }
|
||||
/// <summary>"SendText:111", "RunPrompt:111", "WorkspaceFor:111", "TimeZoneFor:111", "IsDeliverable:111".</summary>
|
||||
public IReadOnlyList<string> Calls { get { lock (_lock) return _calls.ToList(); } }
|
||||
|
||||
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock) { _calls.Add("SendText:" + target); _texts.Add((target, markdown)); }
|
||||
if (HoldTexts is { } hold) await hold.Task.WaitAsync(cancellationToken);
|
||||
if (FailSends is { } error) throw error;
|
||||
}
|
||||
|
||||
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
|
||||
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public string WorkspaceFor(string target)
|
||||
{
|
||||
lock (_lock) _calls.Add("WorkspaceFor:" + target);
|
||||
return Path.Combine(WorkspaceRoot, target);
|
||||
}
|
||||
|
||||
public bool IsDeliverable(string target)
|
||||
{
|
||||
lock (_lock) _calls.Add("IsDeliverable:" + target);
|
||||
return !Undeliverable.Contains(target);
|
||||
}
|
||||
|
||||
public TimeZoneInfo TimeZoneFor(string target)
|
||||
{
|
||||
lock (_lock) _calls.Add("TimeZoneFor:" + target);
|
||||
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
|
||||
return Zones.GetValueOrDefault(target, DefaultZone);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns queued outcomes (then <see cref="Fallback"/>); can hold runs open, honouring or ignoring cancellation.</summary>
|
||||
internal sealed class FakeScriptRunner : IScriptRunner
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Queue<ScriptOutcome> _outcomes = new();
|
||||
private readonly List<ScriptRequest> _requests = [];
|
||||
private int _cancellations;
|
||||
|
||||
public ScriptOutcome Fallback { get; set; } = new ScriptOutcome.Silent();
|
||||
public TaskCompletionSource? Hold { get; set; }
|
||||
public bool IgnoreCancellation { get; set; }
|
||||
public IReadOnlyList<ScriptRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
|
||||
public int CancellationsSeen => Volatile.Read(ref _cancellations);
|
||||
|
||||
public FakeScriptRunner Then(params ScriptOutcome[] outcomes)
|
||||
{
|
||||
lock (_lock) foreach (var o in outcomes) _outcomes.Enqueue(o);
|
||||
return this;
|
||||
}
|
||||
|
||||
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ScriptOutcome outcome;
|
||||
lock (_lock)
|
||||
{
|
||||
_requests.Add(request);
|
||||
outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() : Fallback;
|
||||
}
|
||||
if (Hold is { } hold)
|
||||
{
|
||||
if (IgnoreCancellation)
|
||||
{
|
||||
using var _ = cancellationToken.Register(() => Interlocked.Increment(ref _cancellations));
|
||||
await hold.Task;
|
||||
}
|
||||
else
|
||||
{
|
||||
try { await hold.Task.WaitAsync(cancellationToken); }
|
||||
catch (OperationCanceledException) { Interlocked.Increment(ref _cancellations); throw; }
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeRedactor(params string[] secrets) : ISecretRedactor
|
||||
{
|
||||
public string Redact(string text) => secrets.Aggregate(text, (t, s) => t.Replace(s, "[REDACTED]", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>A <see cref="JobScheduler"/> on a <see cref="FakeTimeProvider"/> at Sat 2026-09-26 14:03 Berlin, a temp store and the fakes.</summary>
|
||||
internal sealed class SchedulerHarness : IAsyncDisposable
|
||||
{
|
||||
public static readonly DateTimeOffset Start = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
|
||||
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
public static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
|
||||
|
||||
public SchedulerHarness(DateTimeOffset? start = null, Action<SchedulerOptions>? configure = null)
|
||||
{
|
||||
Time = new FakeTimeProvider(start ?? Start);
|
||||
Options = new SchedulerOptions { StorePath = Path.Combine(Root, "scheduler", "jobs.json") };
|
||||
configure?.Invoke(Options);
|
||||
Delivery.WorkspaceRoot = Path.Combine(Root, "workspaces");
|
||||
Scheduler = new JobScheduler(Options, Runner, Redactor, Time, NullLogger<JobScheduler>.Instance);
|
||||
}
|
||||
|
||||
public string Root { get; } = Directory.CreateTempSubdirectory("scheduler-").FullName;
|
||||
public FakeTimeProvider Time { get; }
|
||||
public SchedulerOptions Options { get; }
|
||||
public FakeJobDelivery Delivery { get; } = new();
|
||||
public FakeScriptRunner Runner { get; } = new();
|
||||
public FakeRedactor Redactor { get; } = new("hunter2-secret");
|
||||
public JobScheduler Scheduler { get; }
|
||||
public JobStore Store => new(Options.StorePath!, Time, NullLogger.Instance);
|
||||
|
||||
public Task StartAsync() => Scheduler.StartAsync(Delivery, CancellationToken.None);
|
||||
|
||||
public Task<JobView> CreateAsync(NewJob job, string target = "111") => Scheduler.CreateAsync(target, job, CancellationToken.None);
|
||||
|
||||
/// <summary>Advance the clock and wait for the tick and every fire it started (don't use while a fire is held open).</summary>
|
||||
public async Task AdvanceAsync(TimeSpan by)
|
||||
{
|
||||
Time.Advance(by);
|
||||
await Scheduler.IdleAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Runner.Hold?.TrySetResult();
|
||||
Delivery.HoldTexts?.TrySetResult();
|
||||
Delivery.HoldPrompts?.TrySetResult();
|
||||
await Scheduler.StopAsync(CancellationToken.None);
|
||||
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler).</summary>
|
||||
internal sealed class SchedulerStubContext : IExtensionContext
|
||||
{
|
||||
public string Cwd => Path.GetTempPath();
|
||||
public string SessionId => "scheduler-test";
|
||||
public bool HasUi => false;
|
||||
public IUserInterface Ui => NullUserInterface.Instance;
|
||||
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
|
||||
public IModelRegistry ModelRegistry => throw new NotSupportedException();
|
||||
public Model? Model => null;
|
||||
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
|
||||
public CancellationToken Signal => CancellationToken.None;
|
||||
public ILogger Logger => NullLogger.Instance;
|
||||
public bool IsIdle() => true;
|
||||
public void Abort() { }
|
||||
public bool HasPendingMessages() => false;
|
||||
public ContextUsage? GetContextUsage() => null;
|
||||
public string GetSystemPrompt() => "";
|
||||
public void Compact(CompactOptions? options = null) { }
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
|
||||
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
|
||||
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ChatSettingsStoreTests : IDisposable
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("chat-settings-").FullName;
|
||||
private string FilePath => ChatSettingsStore.DefaultPath(_root);
|
||||
|
||||
private ChatSettingsStore Store() =>
|
||||
new(FilePath, Berlin, NullLogger<ChatSettingsStore>.Instance, new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 14, 3, 0, TimeSpan.Zero)));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_file_uses_the_default()
|
||||
{
|
||||
var store = Store();
|
||||
Assert.Same(Berlin, store.TimeZoneFor(1001));
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.EndsWith(Path.Combine("telegram", "chat-settings.json"), FilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_persists_and_a_new_store_reads_it()
|
||||
{
|
||||
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
|
||||
var reloaded = Store();
|
||||
Assert.Equal("Asia/Tokyo", reloaded.TimeZoneFor(1001).Id);
|
||||
Assert.True(reloaded.HasOwnZone(1001));
|
||||
Assert.Same(Berlin, reloaded.TimeZoneFor(2002));
|
||||
Assert.Equal(["chat-settings.json"], Directory.GetFiles(Path.GetDirectoryName(FilePath)!).Select(Path.GetFileName)); // no temp file left
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_format_is_versioned_camel_case()
|
||||
{
|
||||
await Store().SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
var json = await File.ReadAllTextAsync(FilePath);
|
||||
Assert.Contains("\"version\": 1", json);
|
||||
Assert.Contains("\"1001\": {", json);
|
||||
Assert.Contains("\"timeZone\": \"Asia/Tokyo\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_removes_the_entry()
|
||||
{
|
||||
var store = Store();
|
||||
await store.SetTimeZoneAsync(1001, "Asia/Tokyo", CancellationToken.None);
|
||||
await store.SetTimeZoneAsync(1001, null, CancellationToken.None);
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.False(Store().HasOwnZone(1001));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrupt_file_is_set_aside_and_loading_starts_empty()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, "{ nope");
|
||||
|
||||
var store = Store();
|
||||
|
||||
Assert.False(store.HasOwnZone(1001));
|
||||
Assert.True(File.Exists(FilePath + ".corrupt-20260926140300"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_stored_id_is_ignored()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
File.WriteAllText(FilePath, """{ "version": 1, "chats": { "1001": { "timeZone": "Mars/Olympus" }, "2002": { "timeZone": "Asia/Tokyo" } } }""");
|
||||
|
||||
var store = Store();
|
||||
|
||||
Assert.Same(Berlin, store.TimeZoneFor(1001));
|
||||
Assert.Equal("Asia/Tokyo", store.TimeZoneFor(2002).Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
public sealed class ChatTimeZonesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Iana_ids_parse()
|
||||
{
|
||||
Assert.True(ChatTimeZones.TryParse(" Asia/Tokyo ", out var zone, out _));
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_ids_are_converted_to_iana()
|
||||
{
|
||||
Assert.True(ChatTimeZones.TryParse("Tokyo Standard Time", out var zone, out _));
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(zone));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Local")]
|
||||
[InlineData("+03:00")]
|
||||
[InlineData("GMT+3")]
|
||||
[InlineData("UTC-5")]
|
||||
[InlineData("Mars/Olympus")]
|
||||
[InlineData("")]
|
||||
public void Rejects_local_offsets_and_unknown_names(string input)
|
||||
{
|
||||
Assert.False(ChatTimeZones.TryParse(input, out _, out var error));
|
||||
Assert.Equal($"Unknown time zone \"{input}\". Use an IANA name such as Europe/Berlin or America/New_York.", error);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Speech;
|
||||
@@ -160,4 +161,37 @@ public sealed class FormattingAndOptionsTests
|
||||
options.Speech.BaseUrl = url;
|
||||
Assert.Contains("Telegram:Speech:BaseUrl must be an absolute http(s) URL.", options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeZone_comes_from_the_environment_and_unknown_ids_fail_validation()
|
||||
{
|
||||
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
||||
options.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? " Asia/Tokyo " : null);
|
||||
Assert.Equal("Asia/Tokyo", options.DefaultTimeZone);
|
||||
Assert.Equal("Asia/Tokyo", options.ConfiguredTimeZone()!.Id);
|
||||
Assert.Empty(options.Validate());
|
||||
|
||||
var empty = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
|
||||
empty.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? "" : null);
|
||||
Assert.Null(empty.ConfiguredTimeZone()); // empty = unset: the machine's zone
|
||||
|
||||
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], DefaultTimeZone = "Mars/Olympus" };
|
||||
Assert.Contains(bad.Validate(), e => e.StartsWith("Telegram:DefaultTimeZone: Unknown time zone \"Mars/Olympus\"", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_options_default_and_validate()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Scheduler:MaxJobsPerTarget"] = "5" }).Build();
|
||||
var options = Volcanic.AgenticCore.Telegram.Scheduling.SchedulerSetup.Options(config.GetSection("Scheduler"), "/agent");
|
||||
|
||||
Assert.True(options.Enabled);
|
||||
Assert.Equal(Path.Combine("/agent", "scheduler", "jobs.json"), options.StorePath);
|
||||
Assert.Equal(TimeSpan.FromSeconds(300), options.DefaultScriptTimeout);
|
||||
Assert.Equal(5, options.MaxJobsPerTarget);
|
||||
Assert.Empty(options.Validate());
|
||||
options.MaxJobsPerTarget = 0;
|
||||
Assert.Contains(options.Validate(), e => e.Contains("MaxJobsPerTarget", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class MessageContextChatTests
|
||||
await FlushAsync(host, time, parts: 2);
|
||||
await host.SettleAsync();
|
||||
|
||||
// The zone comes from the ChatRegistry.TimeZoneFor seam (the session's zone until the scheduler's ChatSettingsStore).
|
||||
// The zone comes from the ChatRegistry.TimeZoneFor seam (the chat's zone from ChatSettingsStore, which defaults to AgenticCoreOptions.DefaultTimeZone).
|
||||
Assert.Equal("Asia/Tokyo", Volcanic.AgenticCore.Hosting.TimeZoneIds.ToIana(host.Chats.TimeZoneFor(Updates.AllowedUser)));
|
||||
var lines = Prompt(host).Split('\n');
|
||||
Assert.Equal(MessageContext.TimestampHeader(SentAt(comment), Tokyo), lines[0]);
|
||||
@@ -64,6 +64,24 @@ public sealed class MessageContextChatTests
|
||||
Assert.Single(lines, l => TimestampLine.IsMatch(l)); // one per batch, not per part
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_timezone_Asia_Tokyo_the_next_messages_header_says_Tokyo()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
await using var host = new ChatHost(core: o => o.DefaultTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"), time: time);
|
||||
host.Faux.Enqueue(FauxTurn.Say("It's evening in Tokyo."));
|
||||
|
||||
await host.SendAsync("/timezone Asia/Tokyo"); // a bot command: handled at once, no inbox window
|
||||
var message = Updates.Text("what time is it?");
|
||||
await host.RouteAsync(message);
|
||||
await FlushAsync(host, time, parts: 1);
|
||||
await host.SettleAsync();
|
||||
|
||||
var first = Prompt(host).Split('\n')[0];
|
||||
Assert.Equal(MessageContext.TimestampHeader(SentAt(message), Tokyo), first);
|
||||
Assert.EndsWith(" Asia/Tokyo]", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_text_waits_for_the_window_and_a_forward_inside_it_joins_the_request()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Events;
|
||||
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>Scheduled prompts and watcher matches reach the agent; everything else stays out of the model and the transcript.</summary>
|
||||
public sealed class ScheduledPromptChatTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly DateTimeOffset Now = SchedulerChatTests.Now; // Sat 26 Sep 14:03 Berlin
|
||||
private const long OtherUser = 2002;
|
||||
|
||||
/// <summary>"yyyy-MM-ddTHH:mm" in Berlin, <paramref name="minutes"/> after the fake clock's now.</summary>
|
||||
private static string BerlinAt(FakeTimeProvider time, int minutes) =>
|
||||
TimeZoneInfo.ConvertTime(time.GetUtcNow().AddMinutes(minutes), Berlin).ToString("yyyy-MM-ddTHH:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
[Fact]
|
||||
public async Task A_prompt_job_on_an_idle_chat_renders_the_reply_with_the_timestamp_header()
|
||||
{
|
||||
var (host, time) = await SchedulerChatTests.StartedAsync();
|
||||
await using var _ = host;
|
||||
var job = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, At: BerlinAt(time, 2), Text: "Summarise inbox/"), CancellationToken.None)).Job;
|
||||
host.Faux.Enqueue(FauxTurn.Say("Your inbox is empty."));
|
||||
|
||||
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.Contains(host.Texts, t => t.Contains("Your inbox is empty.", StringComparison.Ordinal));
|
||||
Assert.Equal(
|
||||
$"{MessageContext.TimestampHeader(Now.AddMinutes(2), Berlin)}\n[Scheduled job \"Inbox summary\" ({job.Id})]\nSummarise inbox/",
|
||||
Assert.Single(host.Faux.Requests).Messages.Last().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_prompt_job_during_a_user_run_is_queued_as_a_follow_up_and_answered_after_the_user()
|
||||
{
|
||||
var started = new TaskCompletionSource();
|
||||
var gate = new TaskCompletionSource();
|
||||
var time = new FakeTimeProvider(Now);
|
||||
await using var host = new ChatHost(core: o => { o.DefaultTimeZone = Berlin; o.AddExtension(new GateToolExtension(started, gate)); },
|
||||
time: time, scheduler: new SchedulerOptions());
|
||||
await host.StartSchedulerAsync();
|
||||
host.Faux.Enqueue(FauxTurn.Tool(GateToolExtension.ToolName, new { }), FauxTurn.Say("User answer."), FauxTurn.Say("Job answer."));
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
|
||||
var chat = await host.ChatAsync();
|
||||
var queues = new List<AgentSessionEvent.QueueUpdate>();
|
||||
using var subscription = chat.Session.Subscribe(e => { if (e is AgentSessionEvent.QueueUpdate q) lock (queues) queues.Add(q); });
|
||||
|
||||
await host.SendAsync("run the long thing");
|
||||
await MessageContextChatTests.FlushAsync(host, time, parts: 1);
|
||||
await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
time.Advance(TimeSpan.FromMinutes(1)); // the job fires while the user's run is active
|
||||
|
||||
await Wait.UntilAsync(() => { lock (queues) return queues.Any(q => q.FollowUp.Count == 1 && q.Steering.Count == 0); }, "the job's follow-up");
|
||||
gate.SetResult();
|
||||
await host.Scheduler.IdleAsync();
|
||||
await host.SettleAsync();
|
||||
|
||||
var texts = host.Texts.ToList();
|
||||
var user = texts.FindIndex(t => t.Contains("User answer.", StringComparison.Ordinal));
|
||||
var job = texts.FindIndex(t => t.Contains("Job answer.", StringComparison.Ordinal));
|
||||
Assert.True(user >= 0 && job > user, string.Join(" | ", texts));
|
||||
lock (queues) Assert.DoesNotContain(queues, q => q.Steering.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_watcher_over_a_real_script_stays_silent_until_it_prints()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var time = new FakeTimeProvider(Now);
|
||||
await using var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions()); // real ScriptRunner
|
||||
await host.StartSchedulerAsync();
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "cat match.txt 2>/dev/null; exit 0"), CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 3; i++) await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
||||
Assert.Empty(host.Bot.Sent);
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(host.WorkspaceOf(), "match.txt"), "price 48\n");
|
||||
host.Faux.Enqueue(FauxTurn.Say("The price dropped to 48."));
|
||||
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
await host.SettleAsync();
|
||||
|
||||
var request = Assert.Single(host.Faux.Requests);
|
||||
Assert.EndsWith("The watcher script printed:\nprice 48", request.Messages.Last().Text);
|
||||
Assert.Contains(host.Texts, t => t.Contains("The price dropped to 48.", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_schedule_tool_call_creates_a_job_for_its_own_chat_only()
|
||||
{
|
||||
var (host, time) = await SchedulerChatTests.StartedAsync();
|
||||
await using var _ = host;
|
||||
host.Faux.Enqueue(
|
||||
FauxTurn.Tool("schedule", new { action = "create", name = "Stretch", kind = "reminder", cron = "0 * * * *", text = "Stretch!" }),
|
||||
FauxTurn.Say("Scheduled."));
|
||||
var chat = await host.ChatAsync();
|
||||
|
||||
await chat.Session.PromptAsync("remind me to stretch every hour");
|
||||
await host.SendAsync("/jobs", userId: OtherUser);
|
||||
|
||||
Assert.Equal("Stretch", Assert.Single(host.Scheduler!.List(ChatHost.Target)).Job.Name);
|
||||
Assert.Equal("No scheduled jobs.", host.LastReply(OtherUser));
|
||||
await host.SendAsync("/jobs");
|
||||
Assert.Contains("⏰ Stretch · 0 * * * * · next 15:00", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_delivers_an_overdue_one_off_late_and_does_not_catch_up_cron()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("scheduler-restart-").FullName;
|
||||
try
|
||||
{
|
||||
var before = new FakeTimeProvider(Now);
|
||||
await using (var first = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: before, root: root, scheduler: new SchedulerOptions()))
|
||||
{
|
||||
await first.StartSchedulerAsync();
|
||||
await first.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T15:03", Text: "Call the dentist"), CancellationToken.None);
|
||||
await first.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Hourly", JobKind.Reminder, Cron: "0 * * * *", Text: "Hourly ping"), CancellationToken.None);
|
||||
}
|
||||
|
||||
var after = new FakeTimeProvider(Now.AddHours(3)); // the bot was down from 14:03 to 17:03
|
||||
await using var second = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: after, root: root, scheduler: new SchedulerOptions());
|
||||
await second.StartSchedulerAsync();
|
||||
await second.Scheduler!.IdleAsync();
|
||||
await (await second.ChatAsync()).Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal("⏰ Call the dentist (due 26 Sep 15:03, delivered late)", Assert.Single(second.Bot.Sent).Text);
|
||||
var hourly = Assert.Single(second.Scheduler.List(ChatHost.Target));
|
||||
Assert.Equal(new DateTimeOffset(2026, 9, 26, 16, 0, 0, TimeSpan.Zero), hourly.NextRunAt); // 18:00 Berlin, not caught up
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduled_prompt_does_not_answer_a_pending_login_code_prompt()
|
||||
{
|
||||
var (host, time) = await SchedulerChatTests.StartedAsync();
|
||||
await using var _ = host;
|
||||
var ui = await new OwnerLoginChannel(host.Chats, host.Options).ResolveAsync(CancellationToken.None);
|
||||
var ask = new UiLoginPrompt(ui!).AskForCodeAsync("https://claude.com/cai/oauth/authorize?state=t", 1, CancellationToken.None);
|
||||
var chat = await host.ChatAsync();
|
||||
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the login code question");
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
|
||||
host.Faux.Enqueue(FauxTurn.Say("Build is green."));
|
||||
|
||||
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
await host.SettleAsync();
|
||||
|
||||
Assert.False(ask.IsCompleted);
|
||||
Assert.True(chat.Ui.IsWaitingForInput);
|
||||
Assert.Contains("[Scheduled job \"Check\"", Assert.Single(host.Faux.Requests).Messages.Last().Text);
|
||||
|
||||
await host.SendAsync("pasted-code");
|
||||
Assert.Equal("pasted-code", await ask.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduled_prompt_that_fails_is_reported_to_the_chat_and_the_job_still_completes()
|
||||
{
|
||||
var (host, time) = await SchedulerChatTests.StartedAsync();
|
||||
await using var _ = host;
|
||||
var chat = await host.ChatAsync();
|
||||
await chat.Session.DisposeAsync(); // PromptAsync now throws, as any failure to start the run would
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Check", JobKind.Prompt, At: BerlinAt(time, 1), Text: "Check the build"), CancellationToken.None);
|
||||
|
||||
await SchedulerChatTests.AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Contains(host.Texts, t => t.Contains("The scheduled job failed:", StringComparison.Ordinal));
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Empty(host.Scheduler.List(ChatHost.Target)); // the one-off completed (no redelivery queue)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_with_jobs_reports_counts_and_jobs_shows_tokyo_times()
|
||||
{
|
||||
var (host, _) = await SchedulerChatTests.StartedAsync();
|
||||
await using var __ = host;
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Morning", JobKind.Reminder, Cron: "0 9 * * *", Text: "Good morning"), CancellationToken.None);
|
||||
await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-27T09:00", Text: "Dentist"), CancellationToken.None);
|
||||
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
|
||||
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03. 1 recurring job now follows this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).",
|
||||
host.LastReply());
|
||||
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
|
||||
|
||||
await host.SendAsync("/jobs");
|
||||
var jobs = host.LastReply();
|
||||
Assert.StartsWith("Scheduled jobs (Asia/Tokyo, now 26 Sep 21:03)", jobs);
|
||||
Assert.Contains("Morning · 0 9 * * * · next 27 Sep 09:00", jobs);
|
||||
Assert.Contains("Dentist · once 27 Sep 16:00", jobs);
|
||||
}
|
||||
}
|
||||
176
tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs
Normal file
176
tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>The scheduler through the Telegram delivery and commands, on a fake clock with a fake script runner.</summary>
|
||||
public sealed class SchedulerChatTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
internal static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // Sat 14:03 Berlin
|
||||
private const long OtherUser = 2002;
|
||||
|
||||
internal sealed class Runner : IScriptRunner
|
||||
{
|
||||
public int Calls;
|
||||
public ScriptOutcome Outcome { get; set; } = new ScriptOutcome.Silent();
|
||||
public Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref Calls);
|
||||
return Task.FromResult(Outcome);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<(ChatHost Host, FakeTimeProvider Time)> StartedAsync(IScriptRunner? runner = null, Action<Microsoft.Extensions.DependencyInjection.IServiceCollection>? services = null)
|
||||
{
|
||||
var time = new FakeTimeProvider(Now);
|
||||
var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions(), runner: runner);
|
||||
host.Options.AllowedUserIds.Add(OtherUser);
|
||||
await host.StartSchedulerAsync();
|
||||
return (host, time);
|
||||
}
|
||||
|
||||
internal static async Task AdvanceAsync(ChatHost host, FakeTimeProvider time, TimeSpan by)
|
||||
{
|
||||
time.Advance(by);
|
||||
await host.Scheduler!.IdleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reminder_is_sent_to_its_chat_without_a_model_call_or_transcript_entry()
|
||||
{
|
||||
var (host, time) = await StartedAsync();
|
||||
await using var _ = host;
|
||||
var chat = await host.ChatAsync();
|
||||
var entriesBefore = chat.Session.SessionManager.GetEntries().Count;
|
||||
var leafBefore = chat.Session.SessionManager.LeafId;
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Dentist", JobKind.Reminder, At: "2026-09-26T14:05", Text: "Call the **dentist**"), CancellationToken.None);
|
||||
|
||||
await AdvanceAsync(host, time, TimeSpan.FromMinutes(2));
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var sent = Assert.Single(host.Bot.Sent);
|
||||
Assert.Equal(Updates.AllowedUser, sent.ChatId.Identifier);
|
||||
Assert.Equal("⏰ Call the <b>dentist</b>", sent.Text);
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
Assert.Empty(chat.Session.Messages);
|
||||
Assert.Equal(entriesBefore, chat.Session.SessionManager.GetEntries().Count); // no entry of any kind
|
||||
Assert.Equal(leafBefore, chat.Session.SessionManager.LeafId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing()
|
||||
{
|
||||
var runner = new Runner();
|
||||
var (host, time) = await StartedAsync(runner);
|
||||
await using var _ = host;
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "scripts/price.sh"), CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 5; i++) await AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.Equal(5, runner.Calls);
|
||||
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // no session created or woken
|
||||
Assert.Empty(host.Bot.Requests);
|
||||
Assert.Empty(host.Faux.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delivery_WorkspaceFor_and_TimeZoneFor_never_create_a_session()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
await host.Settings.SetTimeZoneAsync(Updates.AllowedUser, "Asia/Tokyo", CancellationToken.None);
|
||||
|
||||
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
|
||||
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
|
||||
Assert.Same(host.Settings.DefaultZone, host.Delivery.TimeZoneFor("not-a-chat")); // a hand-edited store never throws here
|
||||
Assert.Null(host.Chats.Find(Updates.AllowedUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsDeliverable_follows_the_allow_list()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
Assert.True(host.Delivery!.IsDeliverable(ChatHost.Target));
|
||||
Assert.False(host.Delivery.IsDeliverable(Updates.Stranger.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||
Assert.False(host.Delivery.IsDeliverable("not-a-chat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jobs_lists_this_chats_jobs_in_its_zone()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
var once = await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, At: "2026-09-27T09:00", Text: "Call"), CancellationToken.None);
|
||||
var cron = await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, Cron: "0 8 * * 1-5", Text: "Summarise"), CancellationToken.None);
|
||||
|
||||
await host.SendAsync("/jobs");
|
||||
|
||||
Assert.Equal(
|
||||
$"Scheduled jobs (Europe/Berlin, now 26 Sep 14:03)\n{once.Job.Id} ⏰ Call the dentist · once 27 Sep 09:00\n{cron.Job.Id} 💬 Inbox summary · 0 8 * * 1-5 · next 28 Sep 08:00",
|
||||
host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jobs_without_jobs_says_so()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
await host.SendAsync("/jobs");
|
||||
Assert.Equal("No scheduled jobs.", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_handles_own_unknown_and_other_chats_ids()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
var mine = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None)).Job.Id;
|
||||
var theirs = (await host.Scheduler.CreateAsync("2002", new NewJob("Theirs", JobKind.Reminder, Cron: "@daily", Text: "y"), CancellationToken.None)).Job.Id;
|
||||
|
||||
await host.SendAsync($"/cancel {theirs}");
|
||||
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
|
||||
await host.SendAsync("/cancel zzzzz");
|
||||
Assert.Equal("No job zzzzz in this chat.", host.LastReply());
|
||||
await host.SendAsync($"/cancel {mine.ToUpperInvariant()}");
|
||||
Assert.Equal($"Cancelled {mine} (Call the dentist).", host.LastReply());
|
||||
|
||||
Assert.Empty(host.Scheduler.List(ChatHost.Target));
|
||||
Assert.Single(host.Scheduler.List("2002"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_without_an_id_shows_usage()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
await host.SendAsync("/cancel");
|
||||
Assert.Equal("Usage: /cancel <id> (see /jobs).", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_shows_the_job_count()
|
||||
{
|
||||
var (host, _) = await StartedAsync();
|
||||
await using var __ = host;
|
||||
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("a", JobKind.Reminder, Cron: "@daily", Text: "x"), CancellationToken.None);
|
||||
await host.SendAsync("/status");
|
||||
Assert.Contains("Jobs: 1\n", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_scheduler_jobs_says_scheduling_is_off()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
await host.SendAsync("/jobs");
|
||||
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
||||
await host.SendAsync("/cancel abcde");
|
||||
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
|
||||
await host.SendAsync("/status");
|
||||
Assert.DoesNotContain("Jobs:", host.LastReply());
|
||||
Assert.Contains("Time zone:", host.LastReply());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Abstractions.Processes;
|
||||
using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.Sessions;
|
||||
@@ -9,6 +11,7 @@ using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
@@ -27,12 +30,34 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
public UpdateRouter Router { get; }
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly string _root;
|
||||
private readonly bool _ownsRoot;
|
||||
|
||||
/// <summary>Per-chat settings (<c><root>/agent/telegram/chat-settings.json</c>); the default zone is the core option.</summary>
|
||||
public ChatSettingsStore Settings { get; }
|
||||
|
||||
public ChatTimeZoneService Zones { get; }
|
||||
|
||||
/// <summary>The host's root directory; a caller-supplied root survives disposal (e.g. for a restart).</summary>
|
||||
public string Root => _root;
|
||||
|
||||
/// <summary>The scheduler when the host was built with <c>scheduler:</c> options; start it with <see cref="StartSchedulerAsync"/>.</summary>
|
||||
public JobScheduler? Scheduler { get; }
|
||||
|
||||
public TelegramJobDelivery? Delivery { get; }
|
||||
|
||||
/// <summary><see cref="Updates.AllowedUser"/> as a scheduler target.</summary>
|
||||
public const string Target = "1001";
|
||||
|
||||
public Task StartSchedulerAsync() => Scheduler!.StartAsync(Delivery!, CancellationToken.None);
|
||||
|
||||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
|
||||
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
|
||||
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null)
|
||||
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
|
||||
IScriptRunner? runner = null)
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
_ownsRoot = root is null;
|
||||
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
var agentDir = Path.Combine(_root, "agent");
|
||||
Options = new TelegramOptions
|
||||
{
|
||||
BotToken = "test",
|
||||
@@ -45,10 +70,11 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
};
|
||||
var store = new InMemorySessionStore();
|
||||
var services = new ServiceCollection();
|
||||
if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it
|
||||
configure?.Invoke(this, services);
|
||||
services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = Path.Combine(_root, "agent");
|
||||
o.AgentDir = agentDir;
|
||||
o.DiscoverExtensionAssemblies = false;
|
||||
o.DefaultModel = FauxProvider.ModelKey;
|
||||
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
|
||||
@@ -59,10 +85,22 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
_services = services.BuildServiceProvider();
|
||||
ILoggerFactory loggers = NullLoggerFactory.Instance;
|
||||
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
|
||||
var clock = time ?? TimeProvider.System;
|
||||
var coreOptions = _services.GetRequiredService<Microsoft.Extensions.Options.IOptions<AgenticCoreOptions>>().Value;
|
||||
Settings = new ChatSettingsStore(ChatSettingsStore.DefaultPath(agentDir), coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local, NullLogger<ChatSettingsStore>.Instance, clock);
|
||||
if (scheduler is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scheduler.StorePath)) scheduler.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
|
||||
Scheduler = new JobScheduler(scheduler,
|
||||
runner ?? new ScriptRunner(scheduler, _services.GetRequiredService<IProcessSpawnPolicy>(), NullLogger<ScriptRunner>.Instance, clock),
|
||||
_services.GetRequiredService<ISecretRedactor>(), clock, NullLogger<JobScheduler>.Instance);
|
||||
}
|
||||
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler);
|
||||
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
|
||||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender, Composer, NullLogger<UpdateRouter>.Instance);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>The composer the router uses (content steps 1–3).</summary>
|
||||
@@ -88,12 +126,19 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
await chat.Renderer.DrainAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>The last message sent to <paramref name="chatId"/>, as plain text (tags stripped, entities decoded).</summary>
|
||||
public string LastReply(long chatId = Updates.AllowedUser) =>
|
||||
System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(
|
||||
Bot.Sent.Last(s => s.ChatId.Identifier == chatId).Text, "<[^>]+>", ""));
|
||||
|
||||
public IEnumerable<string> Texts => Bot.CurrentTexts.OrderBy(kv => kv.Key).Select(kv => kv.Value);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None);
|
||||
await Chats.DisposeAsync();
|
||||
await _services.DisposeAsync();
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
if (_ownsRoot)
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
135
tests/Volcanic.AgenticCore.Telegram.Tests/TimeZoneChatTests.cs
Normal file
135
tests/Volcanic.AgenticCore.Telegram.Tests/TimeZoneChatTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>Per-chat time zones through <c>/timezone</c>, the <c>set_timezone</c> tool, a restart and <c>/status</c>.</summary>
|
||||
public sealed class TimeZoneChatTests
|
||||
{
|
||||
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // 14:03 Berlin, 21:03 Tokyo
|
||||
private const long OtherUser = 2002;
|
||||
|
||||
private static ChatHost Host(string? root = null) =>
|
||||
new(core: o => o.DefaultTimeZone = Berlin, time: new FakeTimeProvider(Now), root: root);
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_sets_the_zone_the_session_and_the_next_system_prompt()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
|
||||
Assert.Equal("Time zone set to Asia/Tokyo (UTC+09:00). Now 26 Sep 21:03.", host.LastReply());
|
||||
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
|
||||
var chat = await host.ChatAsync();
|
||||
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
|
||||
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
await chat.Session.PromptAsync("hi"); // straight to the session: the inbox window runs on the fake clock
|
||||
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests.Last().SystemPrompt);
|
||||
Assert.Contains("Current date: 2026-09-26 (Saturday)", host.Faux.Requests.Last().SystemPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_zone_replies_with_the_error_and_stores_nothing()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Mars/Olympus");
|
||||
Assert.Equal("Unknown time zone \"Mars/Olympus\". Use an IANA name such as Europe/Berlin or America/New_York.", host.LastReply());
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
Assert.False(File.Exists(Volcanic.AgenticCore.Telegram.Chats.ChatSettingsStore.DefaultPath(Path.Combine(host.Root, "agent"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_returns_to_the_default()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/timezone reset");
|
||||
|
||||
Assert.Equal("Time zone reset to the default, Europe/Berlin (UTC+02:00). Now 26 Sep 14:03.", host.LastReply());
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
Assert.Equal("Europe/Berlin", (await host.ChatAsync()).Session.TimeZone.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timezone_alone_shows_the_zone_and_whether_it_is_the_default()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone");
|
||||
Assert.Equal("Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.", host.LastReply());
|
||||
await host.SendAsync("/timezone America/New_York");
|
||||
await host.SendAsync("/timezone");
|
||||
Assert.StartsWith("Time zone: America/New_York (UTC-04:00). Change it", host.LastReply());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_restart_keeps_the_zone()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("tz-restart-").FullName;
|
||||
try
|
||||
{
|
||||
await using (var first = Host(root)) await first.SendAsync("/timezone Asia/Tokyo");
|
||||
await using var second = Host(root);
|
||||
Assert.Equal("Asia/Tokyo", (await second.ChatAsync()).Session.TimeZone.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scripted_set_timezone_call_does_the_same_as_the_command()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Asia/Tokyo" }), FauxTurn.Say("Done: Tokyo time."));
|
||||
var chat = await host.ChatAsync();
|
||||
|
||||
await chat.Session.PromptAsync("I moved to Tokyo");
|
||||
|
||||
Assert.Equal("Asia/Tokyo", host.Settings.TimeZoneFor(Updates.AllowedUser).Id);
|
||||
Assert.Equal("Asia/Tokyo", chat.Session.TimeZone.Id);
|
||||
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
|
||||
Assert.False(result.IsError);
|
||||
Assert.StartsWith("Time zone set to Asia/Tokyo (UTC+09:00).", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scripted_set_timezone_with_an_unknown_zone_is_an_error_result()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Faux.Enqueue(FauxTurn.Tool("set_timezone", new { zone = "Mars/Olympus" }), FauxTurn.Say("Sorry."));
|
||||
var chat = await host.ChatAsync();
|
||||
|
||||
await chat.Session.PromptAsync("I live on Mars");
|
||||
|
||||
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
|
||||
Assert.True(result.IsError);
|
||||
Assert.StartsWith("Unknown time zone \"Mars/Olympus\"", result.Text);
|
||||
Assert.False(host.Settings.HasOwnZone(Updates.AllowedUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_chats_zone_is_untouched()
|
||||
{
|
||||
await using var host = Host();
|
||||
host.Options.AllowedUserIds.Add(OtherUser);
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/timezone", userId: OtherUser);
|
||||
|
||||
Assert.Equal("Europe/Berlin", host.Settings.TimeZoneFor(OtherUser).Id);
|
||||
Assert.StartsWith("Time zone: Europe/Berlin (UTC+02:00, default).", host.LastReply(OtherUser));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_shows_the_time_zone()
|
||||
{
|
||||
await using var host = Host();
|
||||
await host.SendAsync("/timezone Asia/Tokyo");
|
||||
await host.SendAsync("/status");
|
||||
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.LastReply());
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,29 @@ public class AgentLoopTests
|
||||
Assert.Equal(["start", "steer me", "later"], session.Messages.OfType<UserMessage>().Select(m => m.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_follow_up_prompt_that_loses_the_race_to_a_run_is_queued_as_a_follow_up_not_steering()
|
||||
{
|
||||
var gate = new TaskCompletionSource();
|
||||
await using var host = new TestHost(null, WithTools(Echo("wait", async v => { await gate.Task; return v; })));
|
||||
host.Faux.Enqueue(FauxTurn.Tool("wait", new { value = "x" }), FauxTurn.Say("after tool"), FauxTurn.Say("after scheduled"));
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
|
||||
var run = session.PromptAsync("user");
|
||||
await WaitUntil(() => session.IsStreaming && host.Events.OfType<AgentSessionEvent.ToolExecutionStart>().Any());
|
||||
// What PromptAsync(FollowUp) does when it saw an idle session just before the user's run took the run gate.
|
||||
var scheduled = session.StartRunAsync(UserMessage.From("scheduled", null), null, CancellationToken.None, StreamingBehavior.FollowUp);
|
||||
await WaitUntil(() => host.Events.OfType<AgentSessionEvent.QueueUpdate>().Any());
|
||||
Assert.Contains(host.Events, e => e is AgentSessionEvent.QueueUpdate { Steering.Count: 0, FollowUp.Count: 1 });
|
||||
gate.SetResult();
|
||||
await run;
|
||||
await scheduled;
|
||||
|
||||
Assert.Equal(3, host.Faux.Requests.Count);
|
||||
Assert.IsType<ToolResultMessage>(host.Faux.Requests[1].Messages[^1]); // not steered into the user's turn
|
||||
Assert.Equal("scheduled", host.Faux.Requests[2].Messages[^1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prompt_while_streaming_without_behavior_throws()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
@@ -28,7 +29,7 @@ public class SystemPromptBuilderTests
|
||||
new DelegateTool<object>("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]),
|
||||
new DelegateTool<object>("silent", "d", (_, _, _) => Task.FromResult(ToolResult.Text(""))),
|
||||
};
|
||||
var builder = new SystemPromptBuilder(sub, () => tools, () => new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero));
|
||||
var builder = new SystemPromptBuilder(sub, () => tools, new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero)), () => TimeZoneInfo.Utc);
|
||||
var options = builder.DefaultOptions("BASE");
|
||||
options.Sections["extra"] = "more";
|
||||
var prompt = builder.Render(options);
|
||||
@@ -41,7 +42,7 @@ public class SystemPromptBuilderTests
|
||||
var ctxStart = prompt.IndexOf("<project_context>", StringComparison.Ordinal);
|
||||
Assert.True(prompt.IndexOf("Sub rules", StringComparison.Ordinal) < prompt.IndexOf("Root rules", StringComparison.Ordinal));
|
||||
Assert.True(ctxStart > 0);
|
||||
Assert.Contains("Current date: 2026-09-25", prompt);
|
||||
Assert.Contains("Current date: 2026-09-25 (Friday)\nTime zone: ", prompt);
|
||||
Assert.Contains($"Working directory: {sub}", prompt);
|
||||
|
||||
options.SelectedTools.Clear();
|
||||
@@ -66,7 +67,7 @@ public class SystemPromptBuilderTests
|
||||
File.WriteAllText(Path.Combine(dir, "memory.md"), "- User prefers tabs");
|
||||
try
|
||||
{
|
||||
var builder = new SystemPromptBuilder(dir, () => [], () => DateTimeOffset.UnixEpoch);
|
||||
var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc);
|
||||
var prompt = builder.Render(builder.DefaultOptions("BASE"));
|
||||
|
||||
Assert.Contains("Agent rules", prompt);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Features.Agent;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
@@ -76,4 +78,72 @@ public class SessionTimeZoneTests
|
||||
Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(Berlin));
|
||||
Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time")));
|
||||
}
|
||||
|
||||
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
private static readonly DateTimeOffset LateSaturdayUtc = new(2026, 9, 26, 23, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Asia/Tokyo", "Current date: 2026-09-27 (Sunday)", "Time zone: Asia/Tokyo (UTC+09:00)")]
|
||||
[InlineData("Europe/Berlin", "Current date: 2026-09-27 (Sunday)", "Time zone: Europe/Berlin (UTC+02:00)")]
|
||||
[InlineData("America/New_York", "Current date: 2026-09-26 (Saturday)", "Time zone: America/New_York (UTC-04:00)")]
|
||||
public async Task Prompt_shows_the_date_and_zone_in_the_session_zone(string zoneId, string dateLine, string zoneLine)
|
||||
{
|
||||
await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc));
|
||||
await using var session = await host.CreateSessionAsync(b => b.TimeZone = TimeZoneInfo.FindSystemTimeZoneById(zoneId));
|
||||
|
||||
var prompt = session.SystemPrompt;
|
||||
|
||||
Assert.Contains($"{dateLine}\n{zoneLine}\nWorking directory: ", prompt);
|
||||
Assert.DoesNotMatch(@"\d{2}:\d{2}\n", prompt.Split("<environment>")[1]); // no time of day in the cached prefix
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTimeZone_during_a_run_changes_the_next_runs_prompt_not_the_current_one()
|
||||
{
|
||||
AgentSession? session = null;
|
||||
var switchZone = new DelegateTool<ProbeArgs>("switch_zone", "s", (_, _, _) =>
|
||||
{
|
||||
session!.SetTimeZone(Tokyo);
|
||||
return Task.FromResult(ToolResult.Text("switched"));
|
||||
});
|
||||
await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc), null,
|
||||
new LambdaExtension("tools", api => { api.RegisterTool(switchZone); return ValueTask.CompletedTask; }));
|
||||
host.Faux.Enqueue(FauxTurn.Tool("switch_zone", new { value = "x" }), FauxTurn.Say("done"), FauxTurn.Say("second"));
|
||||
session = await host.CreateSessionAsync(b => b.TimeZone = Berlin);
|
||||
|
||||
await session.PromptAsync("first");
|
||||
Assert.Equal("Asia/Tokyo", TimeZoneIds.ToIana(session.TimeZone)); // the property changes at once
|
||||
await session.PromptAsync("again");
|
||||
|
||||
Assert.Equal(3, host.Faux.Requests.Count);
|
||||
Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[0].SystemPrompt);
|
||||
Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[1].SystemPrompt); // same run, same prompt
|
||||
Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests[2].SystemPrompt);
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTimeZone_changes_the_TZ_id_for_processes()
|
||||
{
|
||||
await using var host = new TestHost();
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
session.SetTimeZone(Tokyo);
|
||||
Assert.Equal("Asia/Tokyo", ((IExtensionHost)session).TimeZoneId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_options_the_prompt_uses_the_local_zone()
|
||||
{
|
||||
await using var host = new TestHost();
|
||||
await using var session = await host.CreateSessionAsync();
|
||||
Assert.Contains($"Time zone: {TimeZoneIds.Name(TimeZoneInfo.Local)} (", session.SystemPrompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9, 0, "UTC+09:00")]
|
||||
[InlineData(-4, 0, "UTC-04:00")]
|
||||
[InlineData(5, 30, "UTC+05:30")]
|
||||
[InlineData(0, 0, "UTC+00:00")]
|
||||
public void FormatOffset_formats_sign_hours_and_minutes(int hours, int minutes, string expected) =>
|
||||
Assert.Equal(expected, TimeZoneIds.FormatOffset(new TimeSpan(hours, hours < 0 ? -minutes : minutes, 0)));
|
||||
}
|
||||
|
||||
@@ -19,13 +19,21 @@ public sealed class TestHost : IAsyncDisposable
|
||||
public string Cwd { get; }
|
||||
public List<AgentSessionEvent> Events { get; } = [];
|
||||
|
||||
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, configure, extensions) { }
|
||||
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, time: null, configure, extensions) { }
|
||||
|
||||
public TestHost(bool useFileStore, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
|
||||
: this(useFileStore, time: null, configure, extensions) { }
|
||||
|
||||
/// <summary>A host whose sessions read <paramref name="time"/> (registered as the DI <see cref="TimeProvider"/>).</summary>
|
||||
public TestHost(TimeProvider time, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
|
||||
: this(useFileStore: false, time, configure, extensions) { }
|
||||
|
||||
private TestHost(bool useFileStore, TimeProvider? time, Action<AgenticCoreOptions>? configure, IExtension[] extensions)
|
||||
{
|
||||
Cwd = Path.Combine(Path.GetTempPath(), "volcanic-host", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Cwd);
|
||||
var services = new ServiceCollection();
|
||||
if (time is not null) services.AddSingleton(time);
|
||||
services.AddAgenticCore(o =>
|
||||
{
|
||||
o.AgentDir = Path.Combine(Cwd, ".agent");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user