@fastagent-sh/fastagent 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduler: a time-trigger that fires the agent on each schedule's cron. SINGLE-PROCESS (like all
|
|
3
|
+
* fastagent state today) — a deployment with schedules must keep one machine running, since cron has no
|
|
4
|
+
* external wake-up (`deploy` enforces that). Started/stopped by the serve path (dev/start).
|
|
5
|
+
*
|
|
6
|
+
* Fire model:
|
|
7
|
+
* - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
|
|
8
|
+
* conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
|
|
9
|
+
* - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
|
|
10
|
+
* - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
|
|
11
|
+
* already past (the process was down across it), fire once and advance — not once per missed slot.
|
|
12
|
+
* lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
|
|
13
|
+
* "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
|
|
14
|
+
*/
|
|
15
|
+
import { type Agent } from "../agent.ts";
|
|
16
|
+
import type { LoadedSchedule } from "./schedule.ts";
|
|
17
|
+
/** A schedule's turns share this stable session — a continuing conversation, like the telegram channel's
|
|
18
|
+
* per-chat session. Derived at RUNTIME from the name (never an authored field). */
|
|
19
|
+
export declare function scheduleSession(name: string): string;
|
|
20
|
+
export interface Scheduler {
|
|
21
|
+
/** Arm every schedule (catching up an overdue one once). Idempotent-ish: call once per process. */
|
|
22
|
+
start(): void;
|
|
23
|
+
/** Clear all armed timers. Does NOT drain an in-flight fire (SIGTERM exits mid-turn by design; the
|
|
24
|
+
* interrupted turn is not re-fired — lastFired was already claimed). A catch-up fire in flight has NO
|
|
25
|
+
* timer entry to clear either (the overdue branch fires directly, without arming), so `stop()` simply
|
|
26
|
+
* lets it run out or be cut by process exit — same non-drain, and its claim is already persisted. */
|
|
27
|
+
stop(): void;
|
|
28
|
+
}
|
|
29
|
+
export interface SchedulerOptions {
|
|
30
|
+
agent: Agent;
|
|
31
|
+
stateRoot: string;
|
|
32
|
+
schedules: LoadedSchedule[];
|
|
33
|
+
/** Injectable clock for tests; defaults to the wall clock. */
|
|
34
|
+
now?: () => Date;
|
|
35
|
+
}
|
|
36
|
+
export declare function createScheduler({ agent, stateRoot, schedules, now }: SchedulerOptions): Scheduler;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduler: a time-trigger that fires the agent on each schedule's cron. SINGLE-PROCESS (like all
|
|
3
|
+
* fastagent state today) — a deployment with schedules must keep one machine running, since cron has no
|
|
4
|
+
* external wake-up (`deploy` enforces that). Started/stopped by the serve path (dev/start).
|
|
5
|
+
*
|
|
6
|
+
* Fire model:
|
|
7
|
+
* - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
|
|
8
|
+
* conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
|
|
9
|
+
* - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
|
|
10
|
+
* - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
|
|
11
|
+
* already past (the process was down across it), fire once and advance — not once per missed slot.
|
|
12
|
+
* lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
|
|
13
|
+
* "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
|
|
14
|
+
*/
|
|
15
|
+
import { SESSION_BUSY_CODE } from "../agent.js";
|
|
16
|
+
import { log } from "../log.js";
|
|
17
|
+
import { appendRun } from "./audit.js";
|
|
18
|
+
import { nextRun } from "./cron.js";
|
|
19
|
+
import { loadFires, saveFires } from "./state.js";
|
|
20
|
+
import { deferWakeup, takeFirstDueWakeup } from "./wakeups.js";
|
|
21
|
+
/** A schedule's turns share this stable session — a continuing conversation, like the telegram channel's
|
|
22
|
+
* per-chat session. Derived at RUNTIME from the name (never an authored field). */
|
|
23
|
+
export function scheduleSession(name) {
|
|
24
|
+
return `schedule:${name}`;
|
|
25
|
+
}
|
|
26
|
+
// A single setTimeout maxes out at ~24.8 days and drifts over long sleeps; cap each wait so a long
|
|
27
|
+
// interval (or a suspended machine) re-checks against the wall clock rather than firing wildly early/late.
|
|
28
|
+
const MAX_WAIT_MS = 6 * 60 * 60 * 1000; // 6h
|
|
29
|
+
// How often to poll the agent's self-scheduled wake-ups (wakeups.ts). A wake fires within this of its
|
|
30
|
+
// due time — fine for "wake me in N minutes"; cheap (reads a small JSON, writes only when one is due).
|
|
31
|
+
const WAKEUP_POLL_MS = 30 * 1000;
|
|
32
|
+
export function createScheduler({ agent, stateRoot, schedules, now = () => new Date() }) {
|
|
33
|
+
const timers = new Map();
|
|
34
|
+
let wakeupTimer;
|
|
35
|
+
let stopped = false;
|
|
36
|
+
/** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total — never throws (its callers are
|
|
37
|
+
* void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
|
|
38
|
+
* material — `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
|
|
39
|
+
* it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
|
|
40
|
+
* reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). */
|
|
41
|
+
async function runTurn(label, session, prompt) {
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
log.info(`[schedule] ${label} firing (session=${session})`);
|
|
44
|
+
try {
|
|
45
|
+
let failed;
|
|
46
|
+
let busy = false;
|
|
47
|
+
let reply = "";
|
|
48
|
+
for await (const e of agent.invoke({ session }, { text: prompt })) {
|
|
49
|
+
if (e.type === "text")
|
|
50
|
+
reply += e.delta;
|
|
51
|
+
if (e.type === "failed") {
|
|
52
|
+
failed = e.details;
|
|
53
|
+
busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (failed)
|
|
57
|
+
log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
|
|
58
|
+
else
|
|
59
|
+
log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
|
|
60
|
+
return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
// invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
|
|
64
|
+
// is not the busy case, so don't defer on it.
|
|
65
|
+
log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
|
|
66
|
+
return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Fire one schedule's turn: claim the slot (persist lastFired BEFORE invoking) so a crash mid-turn
|
|
70
|
+
* does not re-fire this slot on restart, then run the turn. A state fault while claiming (loadFires or
|
|
71
|
+
* saveFires — both before the invoke, so nothing ran) THROWS to the single skip+audit boundary in
|
|
72
|
+
* {@link fireThenReArm}; skipping rather than firing unclaimed also avoids an infinite catch-up loop
|
|
73
|
+
* on restart. */
|
|
74
|
+
async function fire(s) {
|
|
75
|
+
const fires = loadFires(stateRoot);
|
|
76
|
+
fires[s.name] = now().toISOString();
|
|
77
|
+
saveFires(stateRoot, fires);
|
|
78
|
+
const firedAt = now().toISOString();
|
|
79
|
+
const r = await runTurn(s.name, scheduleSession(s.name), s.prompt);
|
|
80
|
+
appendRun(stateRoot, {
|
|
81
|
+
name: s.name,
|
|
82
|
+
session: scheduleSession(s.name),
|
|
83
|
+
firedAt,
|
|
84
|
+
ms: r.ms,
|
|
85
|
+
outcome: r.failed ? "failed" : "completed",
|
|
86
|
+
reply: r.failed ? undefined : r.reply,
|
|
87
|
+
error: r.failed,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The woken turn's prompt arrives ENVELOPED: without it the model sees its own instruction as a bare
|
|
92
|
+
* user message — in a chat session it may answer a "user" who said nothing — and it has no way to know
|
|
93
|
+
* the wake-up's id (buried in a long-past tool result), which `unwake` needs. Static cron fires stay raw
|
|
94
|
+
* on purpose: their prompt is the AUTHOR's instruction in a dedicated `schedule:<name>` session, where
|
|
95
|
+
* every turn is a fire and an envelope would only dilute it.
|
|
96
|
+
*/
|
|
97
|
+
function wakeEnvelope(w) {
|
|
98
|
+
const tag = w.cron
|
|
99
|
+
? `[wake-up ${w.id} fired — YOUR recurring self-scheduled turn (cron "${w.cron}"${w.tz ? ` ${w.tz}` : ""}), not a user message; unwake({ id: "${w.id}" }) stops it]`
|
|
100
|
+
: `[wake-up ${w.id} fired — YOUR self-scheduled turn, not a user message]`;
|
|
101
|
+
return `${tag} ${w.prompt}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Fire every due self-scheduled wake-up, ONE at a time (claim → fire → claim next) so a crash loses at
|
|
105
|
+
* most one occurrence. Each fires back into the session it was set in. Busy handling splits by kind: a
|
|
106
|
+
* ONE-SHOT that failed because the session was BUSY (`code: session_busy` — the turn never started; the
|
|
107
|
+
* very case "wake me in 10 min" hits while the user is still chatting) is deferred to the next poll,
|
|
108
|
+
* bounded, because a dropped one-shot has no "next time"; a RECURRING busy occurrence is skipped
|
|
109
|
+
* immediately (its claim already advanced the entry — the next occurrence comes by definition). Any
|
|
110
|
+
* other failure is terminal for that occurrence (the turn ran — re-running risks duplicate side effects).
|
|
111
|
+
*/
|
|
112
|
+
async function pollWakeups() {
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (stopped)
|
|
115
|
+
break; // stop() must halt an in-flight drain, like it clears the cron timers
|
|
116
|
+
const w = takeFirstDueWakeup(stateRoot, now());
|
|
117
|
+
if (!w)
|
|
118
|
+
break;
|
|
119
|
+
const label = `wake ${w.id.slice(0, 8)}`;
|
|
120
|
+
const firedAt = now().toISOString();
|
|
121
|
+
const r = await runTurn(label, w.session, wakeEnvelope(w));
|
|
122
|
+
// Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
|
|
123
|
+
// terminal for this occurrence, since a turn that DID start may have run side effects). ONE-SHOT: defer (bounded) — it has no "next time", dropping it
|
|
124
|
+
// would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
|
|
125
|
+
// takeFirstDueWakeup), so a busy occurrence is simply SKIPPED and audited — the next one comes by
|
|
126
|
+
// definition, and never touching the stored entry here is what keeps unwake/cancel race-free.
|
|
127
|
+
let kept = false;
|
|
128
|
+
if (r.busy && !w.cron) {
|
|
129
|
+
kept = deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS));
|
|
130
|
+
if (kept)
|
|
131
|
+
log.info(`[schedule] ${label}: session busy — retrying next poll`);
|
|
132
|
+
else
|
|
133
|
+
log.error(`[schedule] ${label}: dropped after too many busy retries`);
|
|
134
|
+
}
|
|
135
|
+
else if (r.busy && w.cron) {
|
|
136
|
+
log.error(`[schedule] ${label}: occurrence skipped (session busy); next fires per cron`);
|
|
137
|
+
}
|
|
138
|
+
// Audit honesty: `deferred` ONLY when the same occurrence was actually re-scheduled. A busy one-shot
|
|
139
|
+
// dropped at the ceiling, and a busy recurring occurrence (skipped — its recurrence survives), are
|
|
140
|
+
// both FINAL for that occurrence → `failed`.
|
|
141
|
+
appendRun(stateRoot, {
|
|
142
|
+
name: "wake",
|
|
143
|
+
session: w.session,
|
|
144
|
+
firedAt,
|
|
145
|
+
ms: r.ms,
|
|
146
|
+
outcome: r.busy ? (kept ? "deferred" : "failed") : r.failed ? "failed" : "completed",
|
|
147
|
+
reply: r.failed || r.busy ? undefined : r.reply,
|
|
148
|
+
error: r.busy
|
|
149
|
+
? kept
|
|
150
|
+
? undefined
|
|
151
|
+
: w.cron
|
|
152
|
+
? "occurrence skipped (session busy); the recurrence continues"
|
|
153
|
+
: "dropped after too many busy retries"
|
|
154
|
+
: r.failed,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Drain due wake-ups, then chain the next poll AFTER — never overlapping two drains. TOTAL: a state-IO
|
|
159
|
+
* fault (an unreadable store) is caught + logged and the chain continues, never a crash / a silent stop
|
|
160
|
+
* (this is `void`-scheduled, so an escaping throw would be an unhandled rejection). */
|
|
161
|
+
async function pumpWakeups() {
|
|
162
|
+
if (stopped)
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
await pollWakeups();
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(e)}`);
|
|
169
|
+
}
|
|
170
|
+
if (stopped)
|
|
171
|
+
return;
|
|
172
|
+
wakeupTimer = setTimeout(() => void pumpWakeups(), WAKEUP_POLL_MS);
|
|
173
|
+
}
|
|
174
|
+
/** Arm a timer for `at`, capped so a long wait re-checks the wall clock instead of trusting one sleep. */
|
|
175
|
+
function arm(s, at) {
|
|
176
|
+
if (stopped)
|
|
177
|
+
return;
|
|
178
|
+
const delay = Math.min(Math.max(0, at.getTime() - now().getTime()), MAX_WAIT_MS);
|
|
179
|
+
timers.set(s.name, setTimeout(() => {
|
|
180
|
+
if (stopped)
|
|
181
|
+
return;
|
|
182
|
+
if (now().getTime() >= at.getTime())
|
|
183
|
+
void fireThenReArm(s);
|
|
184
|
+
else
|
|
185
|
+
arm(s, at); // woke early (the cap) — keep waiting for the real instant
|
|
186
|
+
}, delay));
|
|
187
|
+
}
|
|
188
|
+
/** Fire, then arm the NEXT run computed from now — so a slow fire never double-fires the same slot and
|
|
189
|
+
* missed slots collapse to the single catch-up already done. TOTAL, like pumpWakeups: this is
|
|
190
|
+
* void-scheduled from a timer, so an escaping throw (a state-IO fault while claiming — an unreadable
|
|
191
|
+
* fires.json, an unpersistable claim) would be an unhandled rejection = the WHOLE service crashing over
|
|
192
|
+
* one skipped fire. Log it and keep the schedule armed instead; boot-time state faults still fail `start()`
|
|
193
|
+
* loudly (an operator fixes those before serving). */
|
|
194
|
+
async function fireThenReArm(s) {
|
|
195
|
+
try {
|
|
196
|
+
await fire(s);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
// Audited too (appendRun is total, and runs.jsonl ≠ the broken state file) — a skipped fire must
|
|
200
|
+
// show up in `schedule history`, not only in stderr.
|
|
201
|
+
log.error(`[schedule] ${s.name}: fire failed (skipping this run, schedule stays armed): ${String(e)}`);
|
|
202
|
+
appendRun(stateRoot, {
|
|
203
|
+
name: s.name,
|
|
204
|
+
session: scheduleSession(s.name),
|
|
205
|
+
firedAt: now().toISOString(),
|
|
206
|
+
ms: 0,
|
|
207
|
+
outcome: "failed",
|
|
208
|
+
error: `run skipped — fire failed: ${String(e)}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const due = nextRun(s.cron, s.tz, now());
|
|
212
|
+
if (due)
|
|
213
|
+
arm(s, due);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
start() {
|
|
217
|
+
stopped = false;
|
|
218
|
+
const fires = loadFires(stateRoot);
|
|
219
|
+
const current = now();
|
|
220
|
+
for (const s of schedules) {
|
|
221
|
+
// Anchor on the last fire (catch-up basis), or `now` on a first-ever run so a brand-new schedule
|
|
222
|
+
// never back-fires before the process first booted.
|
|
223
|
+
const lastFired = fires[s.name];
|
|
224
|
+
const anchor = lastFired ? new Date(lastFired) : current;
|
|
225
|
+
const due = nextRun(s.cron, s.tz, anchor);
|
|
226
|
+
if (!due) {
|
|
227
|
+
log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (due.getTime() <= current.getTime()) {
|
|
231
|
+
log.info(`[schedule] ${s.name}: catching up a missed run`);
|
|
232
|
+
void fireThenReArm(s); // overdue → fire once, then arm the next
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
arm(s, due);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Self-scheduled wake-ups: drain now (catch any overdue while the process was down), then chain the
|
|
239
|
+
// next poll AFTER this drain finishes — a CHAIN, not setInterval, so a wake turn longer than the poll
|
|
240
|
+
// interval never overlaps two drains (which would break the one-at-a-time claim→fire→claim promise).
|
|
241
|
+
void pumpWakeups();
|
|
242
|
+
},
|
|
243
|
+
stop() {
|
|
244
|
+
stopped = true;
|
|
245
|
+
for (const t of timers.values())
|
|
246
|
+
clearTimeout(t);
|
|
247
|
+
timers.clear();
|
|
248
|
+
if (wakeupTimer)
|
|
249
|
+
clearTimeout(wakeupTimer);
|
|
250
|
+
wakeupTimer = undefined;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Path of a JSON file under `<stateRoot>/schedule/`. */
|
|
2
|
+
export declare function scheduleFile(stateRoot: string, name: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
|
|
5
|
+
* (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
|
|
6
|
+
* file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
|
|
7
|
+
* state. The caller owns shape validation (an IO boundary).
|
|
8
|
+
*/
|
|
9
|
+
export declare function readScheduleFile(path: string): unknown;
|
|
10
|
+
export declare function writeScheduleFile(path: string, value: unknown): void;
|
|
11
|
+
/** name → last-fired ISO timestamp. */
|
|
12
|
+
export type Fires = Record<string, string>;
|
|
13
|
+
export declare function loadFires(stateRoot: string): Fires;
|
|
14
|
+
export declare function saveFires(stateRoot: string, fires: Fires): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable scheduler state for a SINGLE-PROCESS deployment, under `<stateRoot>/schedule/`. Small JSON
|
|
3
|
+
* files, atomic (tmp+rename) so a crash never leaves a torn file:
|
|
4
|
+
* - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
|
|
5
|
+
* - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
|
|
6
|
+
* The root state dir already self-ignores (`.fastagent/.gitignore`), so no per-dir .gitignore.
|
|
7
|
+
*
|
|
8
|
+
* ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
|
|
9
|
+
* tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { log } from "../log.js";
|
|
14
|
+
/** Path of a JSON file under `<stateRoot>/schedule/`. */
|
|
15
|
+
export function scheduleFile(stateRoot, name) {
|
|
16
|
+
return join(stateRoot, "schedule", `${name}.json`);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
|
|
20
|
+
* (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
|
|
21
|
+
* file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
|
|
22
|
+
* state. The caller owns shape validation (an IO boundary).
|
|
23
|
+
*/
|
|
24
|
+
export function readScheduleFile(path) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(path, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (e.code === "ENOENT")
|
|
31
|
+
return undefined;
|
|
32
|
+
throw new Error(`schedule state ${path} is unreadable — fix permissions/disk and restart: ${String(e)}`, {
|
|
33
|
+
cause: e,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
log.warn(`[schedule] corrupt state file ${path} — ignoring: ${String(e)}`);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function writeScheduleFile(path, value) {
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
const tmp = `${path}.tmp`;
|
|
47
|
+
writeFileSync(tmp, JSON.stringify(value));
|
|
48
|
+
renameSync(tmp, path);
|
|
49
|
+
}
|
|
50
|
+
export function loadFires(stateRoot) {
|
|
51
|
+
const v = readScheduleFile(scheduleFile(stateRoot, "fires"));
|
|
52
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
53
|
+
}
|
|
54
|
+
export function saveFires(stateRoot, fires) {
|
|
55
|
+
writeScheduleFile(scheduleFile(stateRoot, "fires"), fires);
|
|
56
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface Wakeup {
|
|
2
|
+
id: string;
|
|
3
|
+
/** The session to fire back into (the conversation the `wake` call ran in). */
|
|
4
|
+
session: string;
|
|
5
|
+
/** The instruction for the woken turn. */
|
|
6
|
+
prompt: string;
|
|
7
|
+
/** When to fire next (ISO). For a recurring wake this advances to the next cron instant on each fire. */
|
|
8
|
+
fireAt: string;
|
|
9
|
+
/** RECURRING: the cron expression (5-field). Absent = one-shot (fires once, removed). */
|
|
10
|
+
cron?: string;
|
|
11
|
+
/** IANA timezone for `cron` (default UTC). */
|
|
12
|
+
tz?: string;
|
|
13
|
+
/** ONE-SHOT ONLY: consecutive busy-defer attempts. A one-shot into a BUSY session (`code: session_busy`
|
|
14
|
+
* — the turn never started) is deferred, bounded by {@link MAX_WAKE_ATTEMPTS}, then dropped. A RECURRING
|
|
15
|
+
* occurrence never defers (this field never increments for it): a busy one is skipped on FIRST contact
|
|
16
|
+
* — its claim already advanced the entry, and the next occurrence comes by definition. */
|
|
17
|
+
attempts?: number;
|
|
18
|
+
}
|
|
19
|
+
/** The minimum delay a wake-up may be scheduled for — rejects a busy-loop (`wake in 1s`, then again…). */
|
|
20
|
+
export declare const MIN_WAKE_MS = 60000;
|
|
21
|
+
/** The cap on pending wake-ups PER SESSION — rejects one conversation's unbounded self-fan-out without
|
|
22
|
+
* letting it starve others' quota (a global cap would make one chatty session a DoS on everyone else's
|
|
23
|
+
* `wake`, since a multi-user deploy is one session per chat). */
|
|
24
|
+
export declare const MAX_PENDING_WAKEUPS = 20;
|
|
25
|
+
/** How many times a busy/transient wake is retried (deferred) before being dropped. At the ~30s poll a
|
|
26
|
+
* wake into an active conversation fires in the first gap between the user's turns; this generous
|
|
27
|
+
* ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
|
|
28
|
+
export declare const MAX_WAKE_ATTEMPTS = 120;
|
|
29
|
+
/** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
|
|
30
|
+
* a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
|
|
31
|
+
export declare const MIN_RECURRING_GAP_MS: number;
|
|
32
|
+
/** The current pending wake-ups (`fastagent schedule list` uses this). */
|
|
33
|
+
export declare function listWakeups(stateRoot: string): Wakeup[];
|
|
34
|
+
export type AddWakeupResult = {
|
|
35
|
+
ok: true;
|
|
36
|
+
id: string;
|
|
37
|
+
fireAt: string;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Add a wake-up — one-shot (`fireAt`) or recurring (`cron`/`tz`, `fireAt` = the first instant) —
|
|
44
|
+
* enforcing the guardrails (min delay / min recurring gap, per-session pending cap). Returns a
|
|
45
|
+
* MODEL-facing error string on rejection (the `wake` tool passes it back so the model can adjust),
|
|
46
|
+
* never throws for a bad request.
|
|
47
|
+
*/
|
|
48
|
+
export declare function addWakeup(stateRoot: string, input: {
|
|
49
|
+
session: string;
|
|
50
|
+
prompt: string;
|
|
51
|
+
fireAt?: Date;
|
|
52
|
+
cron?: string;
|
|
53
|
+
tz?: string;
|
|
54
|
+
}, now?: Date): AddWakeupResult;
|
|
55
|
+
/**
|
|
56
|
+
* Remove a wake-up by id. `session` (when given — the agent's `unwake`) must ALSO match, so a
|
|
57
|
+
* conversation can only cancel its OWN wake-ups; the operator's `schedule cancel` passes none.
|
|
58
|
+
* Returns whether anything was removed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function removeWakeup(stateRoot: string, id: string, session?: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* CLAIM the FIRST due wake-up (at or before `now`) in stored order, remove + return it, or undefined if
|
|
63
|
+
* none is due. Stored order, not earliest-`fireAt`: the caller drains them all in one poll, so order among
|
|
64
|
+
* due entries doesn't matter. One at a time on purpose — the caller fires it before claiming the next, so a
|
|
65
|
+
* crash loses AT MOST one wake-up (claiming a whole due batch up front would lose the lot). Claim-before-fire
|
|
66
|
+
* = a turn EXECUTES at most once per claim; a wake that couldn't start (busy session) is re-claimed via
|
|
67
|
+
* {@link deferWakeup}.
|
|
68
|
+
*/
|
|
69
|
+
export declare function takeFirstDueWakeup(stateRoot: string, now?: Date): Wakeup | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Re-schedule a ONE-SHOT wake whose fire failed TRANSIENTLY (its session was busy — a channel is mid-turn
|
|
72
|
+
* on it), deferred to `fireAt`. Returns false (dropped, NOT re-added) once attempts exceed
|
|
73
|
+
* {@link MAX_WAKE_ATTEMPTS} — a one-shot has no "next time", so a busy-skip that just dropped it would lose
|
|
74
|
+
* it forever; bounded retries, then give up visibly. A RECURRING occurrence is never deferred: its claim
|
|
75
|
+
* already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
|
|
76
|
+
*
|
|
77
|
+
* Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
|
|
78
|
+
* window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
|
|
79
|
+
* and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
|
|
80
|
+
* claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
|
|
81
|
+
*/
|
|
82
|
+
export declare function deferWakeup(stateRoot: string, w: Wakeup, fireAt: Date): boolean;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's self-scheduled wake-ups — the SECOND producer of scheduled invocations (the first is the
|
|
3
|
+
* author's `schedules/` files). The `wake` tool writes one here; the scheduler polls and fires it back
|
|
4
|
+
* into the SAME session so the agent resumes the conversation it was in. Persisted
|
|
5
|
+
* (`<stateRoot>/schedule/wakeups.json`) so a wake survives a restart. ONE-SHOT (`in`) or RECURRING
|
|
6
|
+
* (`cron` — the entry keeps its id; each fire re-arms `fireAt` to the next cron instant).
|
|
7
|
+
*
|
|
8
|
+
* Guardrails (self-scheduling is a real runaway surface): a minimum one-shot delay (no busy-looping), a
|
|
9
|
+
* minimum RECURRING gap (a high-frequency recurring burns tokens forever — stricter than one-shot), a
|
|
10
|
+
* per-session pending cap (no fan-out, no cross-session quota DoS; a recurring occupies a slot for its
|
|
11
|
+
* whole life), and the agent's `unwake` / the operator's `schedule cancel` as the kill switches.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { log } from "../log.js";
|
|
15
|
+
import { cronError, nextRun } from "./cron.js";
|
|
16
|
+
import { readScheduleFile, scheduleFile, writeScheduleFile } from "./state.js";
|
|
17
|
+
/** The minimum delay a wake-up may be scheduled for — rejects a busy-loop (`wake in 1s`, then again…). */
|
|
18
|
+
export const MIN_WAKE_MS = 60_000; // 1 minute
|
|
19
|
+
/** The cap on pending wake-ups PER SESSION — rejects one conversation's unbounded self-fan-out without
|
|
20
|
+
* letting it starve others' quota (a global cap would make one chatty session a DoS on everyone else's
|
|
21
|
+
* `wake`, since a multi-user deploy is one session per chat). */
|
|
22
|
+
export const MAX_PENDING_WAKEUPS = 20;
|
|
23
|
+
/** How many times a busy/transient wake is retried (deferred) before being dropped. At the ~30s poll a
|
|
24
|
+
* wake into an active conversation fires in the first gap between the user's turns; this generous
|
|
25
|
+
* ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
|
|
26
|
+
export const MAX_WAKE_ATTEMPTS = 120;
|
|
27
|
+
/** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
|
|
28
|
+
* a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
|
|
29
|
+
export const MIN_RECURRING_GAP_MS = 10 * 60_000; // 10 minutes
|
|
30
|
+
/** A stored entry is a real Wakeup: the fields are present and `fireAt` is a parseable date. A malformed
|
|
31
|
+
* one (bad/missing fireAt) would compare NaN <= now = false forever — never due, never cleared, but still
|
|
32
|
+
* eating the pending quota. So validate at this IO boundary and drop it (warn), like a corrupt file. */
|
|
33
|
+
function isWakeup(e) {
|
|
34
|
+
if (!e || typeof e !== "object")
|
|
35
|
+
return false;
|
|
36
|
+
const w = e;
|
|
37
|
+
return (typeof w.id === "string" &&
|
|
38
|
+
typeof w.session === "string" &&
|
|
39
|
+
typeof w.prompt === "string" &&
|
|
40
|
+
typeof w.fireAt === "string" &&
|
|
41
|
+
!Number.isNaN(Date.parse(w.fireAt)) &&
|
|
42
|
+
(w.attempts === undefined || typeof w.attempts === "number") && // a non-number would make deferWakeup's count NaN
|
|
43
|
+
(w.tz === undefined || typeof w.tz === "string") &&
|
|
44
|
+
// A stored cron must PARSE: a bad one would throw inside the claim's nextRun — before the advance-save
|
|
45
|
+
// — so its fireAt never moves, it stays first-due forever, and every wakeup behind it starves (a poison
|
|
46
|
+
// pill worse than the bad-fireAt zombie this validator already guards).
|
|
47
|
+
(w.cron === undefined ||
|
|
48
|
+
(typeof w.cron === "string" && cronError(w.cron, typeof w.tz === "string" ? w.tz : undefined) === undefined)));
|
|
49
|
+
}
|
|
50
|
+
function load(stateRoot) {
|
|
51
|
+
const v = readScheduleFile(scheduleFile(stateRoot, "wakeups"));
|
|
52
|
+
if (v === undefined)
|
|
53
|
+
return []; // absent (first run) or a corrupt file readScheduleFile already warned on
|
|
54
|
+
if (!Array.isArray(v)) {
|
|
55
|
+
// Valid JSON but the wrong SHAPE (an object, say) — fail-visible, don't silently drop the whole store
|
|
56
|
+
// (a per-entry malformed drop already warns; a whole-store shape error must too).
|
|
57
|
+
log.warn(`[schedule] wakeups store is not an array — ignoring it: ${JSON.stringify(v).slice(0, 80)}`);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const valid = [];
|
|
61
|
+
for (const e of v) {
|
|
62
|
+
if (isWakeup(e))
|
|
63
|
+
valid.push(e);
|
|
64
|
+
else
|
|
65
|
+
log.warn(`[schedule] dropping a malformed wake-up entry: ${JSON.stringify(e)}`);
|
|
66
|
+
}
|
|
67
|
+
return valid;
|
|
68
|
+
}
|
|
69
|
+
function save(stateRoot, wakeups) {
|
|
70
|
+
writeScheduleFile(scheduleFile(stateRoot, "wakeups"), wakeups);
|
|
71
|
+
}
|
|
72
|
+
/** The current pending wake-ups (`fastagent schedule list` uses this). */
|
|
73
|
+
export function listWakeups(stateRoot) {
|
|
74
|
+
return load(stateRoot);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Add a wake-up — one-shot (`fireAt`) or recurring (`cron`/`tz`, `fireAt` = the first instant) —
|
|
78
|
+
* enforcing the guardrails (min delay / min recurring gap, per-session pending cap). Returns a
|
|
79
|
+
* MODEL-facing error string on rejection (the `wake` tool passes it back so the model can adjust),
|
|
80
|
+
* never throws for a bad request.
|
|
81
|
+
*/
|
|
82
|
+
export function addWakeup(stateRoot, input, now = new Date()) {
|
|
83
|
+
let fireAtDate;
|
|
84
|
+
if (input.cron !== undefined) {
|
|
85
|
+
const err = cronError(input.cron, input.tz);
|
|
86
|
+
if (err)
|
|
87
|
+
return { ok: false, error: `invalid cron/tz: ${err}` };
|
|
88
|
+
// A recurring wake runs FOREVER — gate its frequency harder than a one-shot: the gap between the next
|
|
89
|
+
// two instants must be ≥ the recurring floor. (First-pair heuristic; an irregular cron with one tight
|
|
90
|
+
// pair can slip through — an accepted ceiling, the per-session cap still bounds total load.)
|
|
91
|
+
const first = nextRun(input.cron, input.tz, now);
|
|
92
|
+
const second = first && nextRun(input.cron, input.tz, first);
|
|
93
|
+
if (!first || !second)
|
|
94
|
+
return { ok: false, error: "this cron never fires (or fires only once) — use `in` for a one-shot." };
|
|
95
|
+
if (second.getTime() - first.getTime() < MIN_RECURRING_GAP_MS) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `too frequent — a recurring wake must fire at most every ${MIN_RECURRING_GAP_MS / 60_000} minutes.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
fireAtDate = first; // DERIVED from the cron — a caller-passed fireAt can't disagree with the schedule
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (!input.fireAt)
|
|
105
|
+
return { ok: false, error: "a one-shot wake needs its fire time (`in`)." };
|
|
106
|
+
if (input.fireAt.getTime() < now.getTime() + MIN_WAKE_MS) {
|
|
107
|
+
return { ok: false, error: `too soon — the minimum wake delay is ${MIN_WAKE_MS / 1000}s.` };
|
|
108
|
+
}
|
|
109
|
+
fireAtDate = input.fireAt;
|
|
110
|
+
}
|
|
111
|
+
const all = load(stateRoot);
|
|
112
|
+
const mine = all.filter((w) => w.session === input.session);
|
|
113
|
+
if (mine.length >= MAX_PENDING_WAKEUPS) {
|
|
114
|
+
// List what's pending WITH a prompt preview: "unwake one" is only actionable if the model has the
|
|
115
|
+
// ids AND can choose by meaning — both were returned when set, but that may be buried far back in
|
|
116
|
+
// the conversation.
|
|
117
|
+
const pending = mine
|
|
118
|
+
.map((w) => `${w.id}${w.cron ? ` (recurring "${w.cron}")` : ""} at ${w.fireAt}: ${w.prompt.length > 60 ? `${w.prompt.slice(0, 60)}…` : w.prompt}`)
|
|
119
|
+
.join("; ");
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: `too many pending wake-ups for this conversation (${MAX_PENDING_WAKEUPS}) — wait for some to fire, or unwake one. Pending: ${pending}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const id = randomUUID();
|
|
126
|
+
const fireAt = fireAtDate.toISOString();
|
|
127
|
+
save(stateRoot, [
|
|
128
|
+
...all,
|
|
129
|
+
{ id, session: input.session, prompt: input.prompt, fireAt, cron: input.cron, tz: input.tz },
|
|
130
|
+
]);
|
|
131
|
+
return { ok: true, id, fireAt };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Remove a wake-up by id. `session` (when given — the agent's `unwake`) must ALSO match, so a
|
|
135
|
+
* conversation can only cancel its OWN wake-ups; the operator's `schedule cancel` passes none.
|
|
136
|
+
* Returns whether anything was removed.
|
|
137
|
+
*/
|
|
138
|
+
export function removeWakeup(stateRoot, id, session) {
|
|
139
|
+
const all = load(stateRoot);
|
|
140
|
+
const kept = all.filter((w) => !(w.id === id && (session === undefined || w.session === session)));
|
|
141
|
+
if (kept.length === all.length)
|
|
142
|
+
return false;
|
|
143
|
+
save(stateRoot, kept);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* CLAIM the FIRST due wake-up (at or before `now`) in stored order, remove + return it, or undefined if
|
|
148
|
+
* none is due. Stored order, not earliest-`fireAt`: the caller drains them all in one poll, so order among
|
|
149
|
+
* due entries doesn't matter. One at a time on purpose — the caller fires it before claiming the next, so a
|
|
150
|
+
* crash loses AT MOST one wake-up (claiming a whole due batch up front would lose the lot). Claim-before-fire
|
|
151
|
+
* = a turn EXECUTES at most once per claim; a wake that couldn't start (busy session) is re-claimed via
|
|
152
|
+
* {@link deferWakeup}.
|
|
153
|
+
*/
|
|
154
|
+
export function takeFirstDueWakeup(stateRoot, now = new Date()) {
|
|
155
|
+
const all = load(stateRoot);
|
|
156
|
+
const idx = all.findIndex((w) => new Date(w.fireAt).getTime() <= now.getTime());
|
|
157
|
+
if (idx === -1)
|
|
158
|
+
return undefined;
|
|
159
|
+
const w = all[idx];
|
|
160
|
+
if (w.cron !== undefined) {
|
|
161
|
+
// RECURRING claim = ADVANCE IN PLACE: the entry STAYS in the store with fireAt pushed to the next cron
|
|
162
|
+
// instant (attempts cleared). So (a) `unwake`/`schedule cancel` work at ANY moment — including inside
|
|
163
|
+
// the woken turn itself, the documented way to stop a done recurring job (a remove+re-add claim would
|
|
164
|
+
// resurrect what that turn just cancelled); and (b) a crash mid-fire loses at most ONE occurrence,
|
|
165
|
+
// never the recurrence — matching the static cron schedules' claim semantics.
|
|
166
|
+
const next = nextRun(w.cron, w.tz, now);
|
|
167
|
+
if (next)
|
|
168
|
+
all[idx] = { ...w, fireAt: next.toISOString(), attempts: undefined };
|
|
169
|
+
else
|
|
170
|
+
all.splice(idx, 1); // the cron has no next instant — this is its final occurrence
|
|
171
|
+
save(stateRoot, all);
|
|
172
|
+
return { ...w }; // THIS occurrence (original fireAt); the store already holds the next
|
|
173
|
+
}
|
|
174
|
+
all.splice(idx, 1);
|
|
175
|
+
save(stateRoot, all);
|
|
176
|
+
return w;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Re-schedule a ONE-SHOT wake whose fire failed TRANSIENTLY (its session was busy — a channel is mid-turn
|
|
180
|
+
* on it), deferred to `fireAt`. Returns false (dropped, NOT re-added) once attempts exceed
|
|
181
|
+
* {@link MAX_WAKE_ATTEMPTS} — a one-shot has no "next time", so a busy-skip that just dropped it would lose
|
|
182
|
+
* it forever; bounded retries, then give up visibly. A RECURRING occurrence is never deferred: its claim
|
|
183
|
+
* already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
|
|
184
|
+
*
|
|
185
|
+
* Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
|
|
186
|
+
* window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
|
|
187
|
+
* and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
|
|
188
|
+
* claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
|
|
189
|
+
*/
|
|
190
|
+
export function deferWakeup(stateRoot, w, fireAt) {
|
|
191
|
+
const attempts = (w.attempts ?? 0) + 1;
|
|
192
|
+
if (attempts > MAX_WAKE_ATTEMPTS)
|
|
193
|
+
return false;
|
|
194
|
+
save(stateRoot, [...load(stateRoot), { ...w, fireAt: fireAt.toISOString(), attempts }]);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** `@fastagent-sh/fastagent/telegram` — the Telegram bot channel subpath export, kept off the root surface. */
|
|
2
|
+
export { telegramChannel, defaultTelegramRoute, telegramEnvelope, type TelegramChannelOptions, type TelegramUpdate, type TelegramMessage, type TelegramRoute, type TelegramFailure, } from "./channels/telegram/telegram.ts";
|
package/dist/telegram.js
ADDED