forked from admin/VolcanicAgenticCore
feat(scheduler): JobScheduler on TimeProvider with watcher, reminder and prompt firing
Spec: a skipped one-off fires after the next start; the late note's placement and no-exit-code failure wording are pinned down; IsDeliverable is part of the watcher invariant and must not create a session; saves snapshot inside a serialising gate.
This commit is contained in:
@@ -199,11 +199,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);
|
||||
}
|
||||
```
|
||||
@@ -215,15 +215,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
|
||||
@@ -237,7 +238,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.
|
||||
|
||||
Reference in New Issue
Block a user