@fastagent-sh/fastagent 0.15.0 → 0.16.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/README.md +13 -3
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/deploy.js +323 -77
- package/dist/cli/commands/dev.js +24 -22
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +93 -37
- package/dist/cli/commands/tool.js +22 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +57 -36
- package/dist/cli/serve.d.ts +26 -6
- package/dist/cli/serve.js +62 -15
- package/dist/cli/shared.d.ts +15 -2
- package/dist/cli/shared.js +30 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +217 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +6 -46
- package/dist/engines/pi/config.js +21 -108
- package/dist/engines/pi/create.d.ts +8 -8
- package/dist/engines/pi/create.js +13 -12
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +11 -11
- package/dist/engines/pi/tool.js +4 -0
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +2 -2
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +4 -1
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
/** Read a base
|
|
1
|
+
/** Read a base agent template (src/scaffold/templates/<name>). */
|
|
2
2
|
export declare const baseTemplate: (name: string) => string;
|
|
3
|
-
/** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
|
|
4
|
-
* risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
|
|
5
|
-
* run root — a skill written to the root `skills/` would never be scanned. */
|
|
6
|
-
export declare function personaTemplate(agentDir?: string): string;
|
|
7
|
-
/** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
|
|
8
|
-
export declare function configTemplate(agentDir?: string): string;
|
|
9
3
|
/** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
|
|
10
4
|
export declare const channelTemplate: (kind: string, name: string) => string;
|
|
11
5
|
/** The .ts files in a channel's bundle: `channel.ts` is the channel adapter; the rest are companion tools. */
|
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scaffold template ACCESS (data, not logic): readers for the real files `init`/`add` write into
|
|
3
|
-
*
|
|
2
|
+
* Scaffold template ACCESS (data, not logic): readers for the real files `init`/`add` write into an
|
|
3
|
+
* agent dir, plus the parametric pieces. Base agent templates live under ./templates/; each
|
|
4
4
|
* channel's bundle lives WITH the channel at ../channels/<kind>/scaffold/ (so a channel owns its
|
|
5
5
|
* starter kit and could ship as its own package). Both trees are excluded from this package's tsc +
|
|
6
6
|
* biome (they import the published @fastagent-sh/fastagent, not this source) and copied into dist/ by the build.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, readdirSync } from "node:fs";
|
|
9
9
|
import { basename, resolve } from "node:path";
|
|
10
|
-
/** Read a base
|
|
10
|
+
/** Read a base agent template (src/scaffold/templates/<name>). */
|
|
11
11
|
export const baseTemplate = (name) => readFileSync(new URL(`./templates/${name}`, import.meta.url), "utf8");
|
|
12
|
-
/** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
|
|
13
|
-
* risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
|
|
14
|
-
* run root — a skill written to the root `skills/` would never be scanned. */
|
|
15
|
-
export function personaTemplate(agentDir) {
|
|
16
|
-
const base = baseTemplate("persona.md");
|
|
17
|
-
if (!agentDir)
|
|
18
|
-
return base;
|
|
19
|
-
return `${base}\nNote: your definition lives under \`${agentDir}/\` relative to the workspace root — this file is \`${agentDir}/persona.md\`, and a new skill goes to \`${agentDir}/skills/<name>/SKILL.md\` (a \`skills/\` at the root is not scanned).\n`;
|
|
20
|
-
}
|
|
21
|
-
/** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
|
|
22
|
-
export function configTemplate(agentDir) {
|
|
23
|
-
const base = baseTemplate("fastagent.config.mjs");
|
|
24
|
-
if (!agentDir)
|
|
25
|
-
return base;
|
|
26
|
-
const out = base.replace("export default {\n", `export default {\n agentDir: ${JSON.stringify(agentDir)}, // the agent's own surface (persona.md / skills / tools / channels) lives there\n`);
|
|
27
|
-
// Fail visibly if the template drifted and the anchor no longer matches — a config that silently
|
|
28
|
-
// doesn't point at the kit would assemble an EMPTY agent with no error. Check the OPERATION happened
|
|
29
|
-
// (out !== base), not a substring — a commented `agentDir:` example in the template would fool that.
|
|
30
|
-
if (out === base)
|
|
31
|
-
throw new Error("configTemplate: anchor not found in fastagent.config.mjs template");
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
12
|
const channelScaffoldDir = (kind) => new URL(`../channels/${kind}/scaffold/`, import.meta.url);
|
|
35
13
|
/** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
|
|
36
14
|
export const channelTemplate = (kind, name) => readFileSync(new URL(name, channelScaffoldDir(kind)), "utf8");
|
|
@@ -13,12 +13,12 @@ export interface VendoredSkill {
|
|
|
13
13
|
overwritten: boolean;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Vendor an Agent Skills skill into `<
|
|
16
|
+
* Vendor an Agent Skills skill into `<agent dir>/skills/<name>/` from a giget ref (github default), a
|
|
17
17
|
* local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
|
|
18
18
|
* Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
|
|
19
19
|
* Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
|
|
20
20
|
* existing skill.
|
|
21
21
|
*/
|
|
22
|
-
export declare function vendorSkill(
|
|
22
|
+
export declare function vendorSkill(agentDir: string, source: string, options?: {
|
|
23
23
|
update?: boolean;
|
|
24
24
|
}): Promise<VendoredSkill>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `fastagent add skill <source>`: vendor an Agent Skills skill into `<
|
|
2
|
+
* `fastagent add skill <source>`: vendor an Agent Skills skill into `<agent dir>/skills/<name>/` —
|
|
3
3
|
* copy-in, git-tracked, never a runtime registry. Source is a giget ref (github default), a local
|
|
4
4
|
* path, or a bare name (resolved against the local global skill dirs as an add-time copy source only).
|
|
5
5
|
* Fetch → staging → validate → rollback-protected replace, so a bad fetch never destroys an existing
|
|
@@ -11,19 +11,19 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { loadAgentDefinition } from "../engines/pi/definition.js";
|
|
14
|
-
import {
|
|
14
|
+
import { assertInsideAgentDir } from "../paths.js";
|
|
15
15
|
/** Derive the destination skill name from a source ref: the last path segment, sans `#ref`. */
|
|
16
16
|
function skillNameFromSource(source) {
|
|
17
17
|
const noRef = source.split("#")[0] ?? source;
|
|
18
18
|
return basename(noRef.replace(/\/+$/, ""));
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* The skills/ path must be safe to write through: never follow a symlink that escapes the
|
|
21
|
+
* The skills/ path must be safe to write through: never follow a symlink that escapes the agent dir
|
|
22
22
|
* (mkdir would), and reject a plain file with one clear message.
|
|
23
23
|
*/
|
|
24
|
-
async function assertSkillsDirUsable(
|
|
25
|
-
await
|
|
26
|
-
const skillsDir = join(
|
|
24
|
+
async function assertSkillsDirUsable(agentDir) {
|
|
25
|
+
await assertInsideAgentDir(agentDir, "skills");
|
|
26
|
+
const skillsDir = join(agentDir, "skills");
|
|
27
27
|
const st = await stat(skillsDir).catch(() => undefined);
|
|
28
28
|
if (st && !st.isDirectory()) {
|
|
29
29
|
throw new Error(`${skillsDir} exists and is not a directory — remove it (skills must be a directory)`);
|
|
@@ -47,19 +47,19 @@ function findGlobalSkillSource(name) {
|
|
|
47
47
|
return undefined;
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Vendor an Agent Skills skill into `<
|
|
50
|
+
* Vendor an Agent Skills skill into `<agent dir>/skills/<name>/` from a giget ref (github default), a
|
|
51
51
|
* local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
|
|
52
52
|
* Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
|
|
53
53
|
* Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
|
|
54
54
|
* existing skill.
|
|
55
55
|
*/
|
|
56
|
-
export async function vendorSkill(
|
|
56
|
+
export async function vendorSkill(agentDir, source, options = {}) {
|
|
57
57
|
const name = skillNameFromSource(source);
|
|
58
58
|
if (name === "" || name === "." || name === "..") {
|
|
59
59
|
throw new Error(`cannot derive a skill name from "${source}" — point at a skill directory (…/skills/<name>)`);
|
|
60
60
|
}
|
|
61
|
-
const skillsDir = join(
|
|
62
|
-
await assertSkillsDirUsable(
|
|
61
|
+
const skillsDir = join(agentDir, "skills");
|
|
62
|
+
await assertSkillsDirUsable(agentDir);
|
|
63
63
|
const dest = join(skillsDir, name);
|
|
64
64
|
// A process can die between moving the old skill aside and installing staging. Never guess that an
|
|
65
65
|
// arbitrary hidden directory is ours or delete it: stop with the exact backup path for manual restore.
|
|
@@ -143,15 +143,15 @@ export async function vendorSkill(workspaceDir, source, options = {}) {
|
|
|
143
143
|
await rm(previous, { recursive: true, force: true });
|
|
144
144
|
// Report via the runtime loader, matching THIS skill by EXACT directory (a substring match would
|
|
145
145
|
// prefix-pollute a sibling `<name>-x` and break on Windows path separators).
|
|
146
|
-
const def = await loadAgentDefinition(
|
|
146
|
+
const def = await loadAgentDefinition(agentDir);
|
|
147
147
|
const rel = join("skills", name);
|
|
148
|
-
const skill = def.skills.find((sk) => relative(
|
|
148
|
+
const skill = def.skills.find((sk) => relative(agentDir, dirname(sk.filePath)) === rel);
|
|
149
149
|
return {
|
|
150
150
|
name: skill?.name ?? name,
|
|
151
151
|
description: skill?.description,
|
|
152
152
|
dest: rel,
|
|
153
153
|
hasScripts: existsSync(join(dest, "scripts")),
|
|
154
|
-
diagnostics: def.diagnostics.filter((d) => d.path !== undefined && relative(
|
|
154
|
+
diagnostics: def.diagnostics.filter((d) => d.path !== undefined && relative(agentDir, dirname(d.path)) === rel),
|
|
155
155
|
overwritten,
|
|
156
156
|
};
|
|
157
157
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schedule discovery (the N axis, clock form):
|
|
2
|
+
* Schedule discovery (the N axis, clock form): an agent declares its time-triggers by dropping files
|
|
3
3
|
* in `schedules/`, mirroring `tools/` and `channels/`. Each file default-exports `defineSchedule({...})`,
|
|
4
4
|
* named from its filename. This is the FILE producer of scheduled invocations (the author's, declarative,
|
|
5
5
|
* git-tracked, deploy-guaranteed); the agent's `wake` tool is the second producer.
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { readdir } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { isModuleFile, loadModuleDir } from "../loader.js";
|
|
10
|
-
import {
|
|
10
|
+
import { assertInsideAgentDir } from "../paths.js";
|
|
11
11
|
import { cronError } from "./cron.js";
|
|
12
12
|
/** Schedule file basenames under `<dir>/schedules/` — an existence probe listed WITHOUT importing
|
|
13
13
|
* (deploy pre-flight's time-trigger detection; `fastagent info` uses {@link loadSchedules} instead,
|
|
14
14
|
* since it also reports broken files and next instants). */
|
|
15
15
|
export async function discoverScheduleFiles(dir) {
|
|
16
|
-
await
|
|
16
|
+
await assertInsideAgentDir(dir, "schedules");
|
|
17
17
|
let names;
|
|
18
18
|
try {
|
|
19
19
|
names = await readdir(join(dir, "schedules"));
|
|
@@ -36,7 +36,7 @@ export async function discoverScheduleFiles(dir) {
|
|
|
36
36
|
* reports the rest.
|
|
37
37
|
*/
|
|
38
38
|
export async function loadSchedules(dir) {
|
|
39
|
-
await
|
|
39
|
+
await assertInsideAgentDir(dir, "schedules");
|
|
40
40
|
const { modules, failures } = await loadModuleDir(join(dir, "schedules"));
|
|
41
41
|
const byName = new Map();
|
|
42
42
|
for (const { name, label, file, mod } of modules) {
|
|
@@ -32,5 +32,44 @@ export interface SchedulerOptions {
|
|
|
32
32
|
schedules: LoadedSchedule[];
|
|
33
33
|
/** Injectable clock for tests; defaults to the wall clock. */
|
|
34
34
|
now?: () => Date;
|
|
35
|
+
/** External-clock mode (the AgentCore deployment): cron slots are DELIVERED by an external
|
|
36
|
+
* scheduler through the serving surface ({@link fireScheduleOnce} with a `slot`), so `start()`
|
|
37
|
+
* arms NO cron timers and does NO boot catch-up — delivery, including for instants that passed
|
|
38
|
+
* while this process was down, is the external clock's job; the adapter's slot-idempotent claim
|
|
39
|
+
* is the duplicate guard. The wake-up poll still runs (degraded: it only fires while the
|
|
40
|
+
* process happens to be awake — the deploy path warns about this). */
|
|
41
|
+
externalClock?: boolean;
|
|
35
42
|
}
|
|
36
|
-
|
|
43
|
+
/** One schedule fire's outcome, for the external caller (the AgentCore adapter returns it to the
|
|
44
|
+
* triggering clock's logs). The resident scheduler ignores it beyond completion. */
|
|
45
|
+
export interface ScheduleFireOutcome {
|
|
46
|
+
fired: boolean;
|
|
47
|
+
/** Set when a slot-keyed fire was skipped because that slot (or a later one) was already claimed. */
|
|
48
|
+
skippedReason?: string;
|
|
49
|
+
failed?: string;
|
|
50
|
+
ms: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fire ONE schedule's turn: claim (persist lastFired BEFORE invoking, so a crash mid-turn does not
|
|
54
|
+
* re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
|
|
55
|
+
* serving surface (the AgentCore adapter).
|
|
56
|
+
*
|
|
57
|
+
* `slot` is the external clock's idempotency key — the cron instant this fire is FOR. External
|
|
58
|
+
* delivery (EventBridge-style) is at-least-once, so a duplicate slot must not double-fire: when
|
|
59
|
+
* lastFired ≥ slot the fire is SKIPPED (at-most-once per slot, same trade as the resident claim —
|
|
60
|
+
* "a digest late once" beats "twice"). The resident scheduler omits `slot`: its timers fire each
|
|
61
|
+
* slot exactly once, so the unconditional claim is already correct.
|
|
62
|
+
*
|
|
63
|
+
* A state fault while claiming (loadFires/saveFires — both before the invoke, so nothing ran)
|
|
64
|
+
* THROWS: the resident path catches it at its single skip+audit boundary ({@link createScheduler}'s
|
|
65
|
+
* fireThenReArm — skipping rather than firing unclaimed also avoids an infinite catch-up loop on
|
|
66
|
+
* restart), and the external path lets it surface as a failed request (visible in the clock's logs).
|
|
67
|
+
*/
|
|
68
|
+
export declare function fireScheduleOnce(opts: {
|
|
69
|
+
agent: Agent;
|
|
70
|
+
stateRoot: string;
|
|
71
|
+
schedule: LoadedSchedule;
|
|
72
|
+
slot?: Date;
|
|
73
|
+
now?: () => Date;
|
|
74
|
+
}): Promise<ScheduleFireOutcome>;
|
|
75
|
+
export declare function createScheduler({ agent, stateRoot, schedules, now, externalClock, }: SchedulerOptions): Scheduler;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
|
|
14
14
|
*/
|
|
15
15
|
import { SESSION_BUSY_CODE } from "../agent.js";
|
|
16
|
+
import { beginWork } from "../channels/busy.js";
|
|
16
17
|
import { log } from "../log.js";
|
|
17
18
|
import { appendRun } from "./audit.js";
|
|
18
19
|
import { nextRun } from "./cron.js";
|
|
@@ -29,63 +30,89 @@ const MAX_WAIT_MS = 6 * 60 * 60 * 1000; // 6h
|
|
|
29
30
|
// How often to poll the agent's self-scheduled wake-ups (wakeups.ts). A wake fires within this of its
|
|
30
31
|
// due time — fine for "wake me in N minutes"; cheap (reads a small JSON, writes only when one is due).
|
|
31
32
|
const WAKEUP_POLL_MS = 30 * 1000;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
33
|
+
/** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total — never throws (its callers are
|
|
34
|
+
* void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
|
|
35
|
+
* material — `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
|
|
36
|
+
* it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
|
|
37
|
+
* reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). Module-level
|
|
38
|
+
* (not a scheduler closure) so {@link fireScheduleOnce} — the external-clock fire path — shares it. */
|
|
39
|
+
async function runTurn(agent, label, session, prompt) {
|
|
40
|
+
const startedAt = Date.now();
|
|
41
|
+
log.info(`[schedule] ${label} firing (session=${session})`);
|
|
42
|
+
try {
|
|
43
|
+
let failed;
|
|
44
|
+
let busy = false;
|
|
45
|
+
let reply = "";
|
|
46
|
+
for await (const e of agent.invoke({ session }, { text: prompt })) {
|
|
47
|
+
if (e.type === "text")
|
|
48
|
+
reply += e.delta;
|
|
49
|
+
if (e.type === "failed") {
|
|
50
|
+
failed = e.details;
|
|
51
|
+
busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
|
|
55
52
|
}
|
|
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
53
|
}
|
|
54
|
+
if (failed)
|
|
55
|
+
log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
|
|
56
|
+
else
|
|
57
|
+
log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
|
|
58
|
+
return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
|
|
68
59
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
catch (e) {
|
|
61
|
+
// invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
|
|
62
|
+
// is not the busy case, so don't defer on it.
|
|
63
|
+
log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
|
|
64
|
+
return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fire ONE schedule's turn: claim (persist lastFired BEFORE invoking, so a crash mid-turn does not
|
|
69
|
+
* re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
|
|
70
|
+
* serving surface (the AgentCore adapter).
|
|
71
|
+
*
|
|
72
|
+
* `slot` is the external clock's idempotency key — the cron instant this fire is FOR. External
|
|
73
|
+
* delivery (EventBridge-style) is at-least-once, so a duplicate slot must not double-fire: when
|
|
74
|
+
* lastFired ≥ slot the fire is SKIPPED (at-most-once per slot, same trade as the resident claim —
|
|
75
|
+
* "a digest late once" beats "twice"). The resident scheduler omits `slot`: its timers fire each
|
|
76
|
+
* slot exactly once, so the unconditional claim is already correct.
|
|
77
|
+
*
|
|
78
|
+
* A state fault while claiming (loadFires/saveFires — both before the invoke, so nothing ran)
|
|
79
|
+
* THROWS: the resident path catches it at its single skip+audit boundary ({@link createScheduler}'s
|
|
80
|
+
* fireThenReArm — skipping rather than firing unclaimed also avoids an infinite catch-up loop on
|
|
81
|
+
* restart), and the external path lets it surface as a failed request (visible in the clock's logs).
|
|
82
|
+
*/
|
|
83
|
+
export async function fireScheduleOnce(opts) {
|
|
84
|
+
const { agent, stateRoot, schedule: s, slot, now = () => new Date() } = opts;
|
|
85
|
+
const fires = loadFires(stateRoot);
|
|
86
|
+
const last = fires[s.name];
|
|
87
|
+
if (slot && last && new Date(last).getTime() >= slot.getTime()) {
|
|
88
|
+
const reason = `slot ${slot.toISOString()} already fired (lastFired=${last})`;
|
|
89
|
+
log.info(`[schedule] ${s.name}: skipping — ${reason}`);
|
|
90
|
+
return { fired: false, skippedReason: reason, ms: 0 };
|
|
91
|
+
}
|
|
92
|
+
// An external delivery claims the cron instant it represents, not its (possibly much later)
|
|
93
|
+
// delivery time. Otherwise a delayed fire can make the next distinct slot look like a duplicate.
|
|
94
|
+
fires[s.name] = (slot ?? now()).toISOString();
|
|
95
|
+
saveFires(stateRoot, fires);
|
|
96
|
+
const firedAt = now().toISOString();
|
|
97
|
+
const r = await runTurn(agent, s.name, scheduleSession(s.name), s.prompt);
|
|
98
|
+
appendRun(stateRoot, {
|
|
99
|
+
name: s.name,
|
|
100
|
+
session: scheduleSession(s.name),
|
|
101
|
+
firedAt,
|
|
102
|
+
ms: r.ms,
|
|
103
|
+
outcome: r.failed ? "failed" : "completed",
|
|
104
|
+
reply: r.failed ? undefined : r.reply,
|
|
105
|
+
error: r.failed,
|
|
106
|
+
});
|
|
107
|
+
return { fired: true, failed: r.failed, ms: r.ms };
|
|
108
|
+
}
|
|
109
|
+
export function createScheduler({ agent, stateRoot, schedules, now = () => new Date(), externalClock = false, }) {
|
|
110
|
+
const timers = new Map();
|
|
111
|
+
let wakeupTimer;
|
|
112
|
+
let stopped = false;
|
|
113
|
+
/** The resident fire path — the shared claim+run+audit, without a slot (see {@link fireScheduleOnce}). */
|
|
74
114
|
async function fire(s) {
|
|
75
|
-
|
|
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
|
-
});
|
|
115
|
+
await fireScheduleOnce({ agent, stateRoot, schedule: s, now });
|
|
89
116
|
}
|
|
90
117
|
/**
|
|
91
118
|
* The woken turn's prompt arrives ENVELOPED: without it the model sees its own instruction as a bare
|
|
@@ -118,7 +145,10 @@ export function createScheduler({ agent, stateRoot, schedules, now = () => new D
|
|
|
118
145
|
break;
|
|
119
146
|
const label = `wake ${w.id.slice(0, 8)}`;
|
|
120
147
|
const firedAt = now().toISOString();
|
|
121
|
-
|
|
148
|
+
// A wake turn runs in the BACKGROUND (no open request tracks it) — count it as in-flight work
|
|
149
|
+
// (busy.ts) so a serving surface that must not idle mid-turn (the AgentCore /ping) sees it.
|
|
150
|
+
const workDone = beginWork();
|
|
151
|
+
const r = await runTurn(agent, label, w.session, wakeEnvelope(w)).finally(workDone);
|
|
122
152
|
// Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
|
|
123
153
|
// 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
154
|
// would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
|
|
@@ -215,9 +245,12 @@ export function createScheduler({ agent, stateRoot, schedules, now = () => new D
|
|
|
215
245
|
return {
|
|
216
246
|
start() {
|
|
217
247
|
stopped = false;
|
|
218
|
-
|
|
248
|
+
// External-clock mode: no cron timers, no boot catch-up — slot delivery (including instants
|
|
249
|
+
// that passed while this process was down) belongs to the external clock; only the wake-up
|
|
250
|
+
// pump below runs. See SchedulerOptions.externalClock.
|
|
251
|
+
const fires = externalClock ? {} : loadFires(stateRoot);
|
|
219
252
|
const current = now();
|
|
220
|
-
for (const s of schedules) {
|
|
253
|
+
for (const s of externalClock ? [] : schedules) {
|
|
221
254
|
// Anchor on the last fire (catch-up basis), or `now` on a first-ever run so a brand-new schedule
|
|
222
255
|
// never back-fires before the process first booted.
|
|
223
256
|
const lastFired = fires[s.name];
|
package/dist/schedule/state.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* files, atomic (tmp+rename) so a crash never leaves a torn file:
|
|
4
4
|
* - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
|
|
5
5
|
* - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
|
|
6
|
-
*
|
|
6
|
+
* No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
|
|
7
7
|
*
|
|
8
8
|
* ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
|
|
9
9
|
* tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Wakeup } from "./wakeups.ts";
|
|
2
|
+
/** The forwarder's reserved wake-alarm path — never forwarded to channel routes. */
|
|
3
|
+
export declare const WAKE_ALARM_PATH = "/__fastagent/wake-alarm";
|
|
4
|
+
/** One desired alarm: mirror of a pending wake-up (id names the EventBridge schedule; at = fireAt). */
|
|
5
|
+
export interface WakeAlarm {
|
|
6
|
+
id: string;
|
|
7
|
+
at: string;
|
|
8
|
+
}
|
|
9
|
+
/** The wire shape the sink POSTs to {@link WAKE_ALARM_PATH} (the forwarder validates `secret`). */
|
|
10
|
+
export interface WakeAlarmRequest {
|
|
11
|
+
secret: string;
|
|
12
|
+
alarms: WakeAlarm[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on
|
|
16
|
+
* every turn, the file should not churn). Durable under <stateRoot>/schedule/ so a freshly booted
|
|
17
|
+
* container whose FIRST action is a wake fire (recurring advance → save → sink) knows the URL
|
|
18
|
+
* before any envelope of its own has arrived.
|
|
19
|
+
*/
|
|
20
|
+
export declare function rememberWakeAlarmUrl(stateRoot: string, url: string): void;
|
|
21
|
+
/** The persisted forwarder URL, or undefined before the first envelope ever seen. */
|
|
22
|
+
export declare function readWakeAlarmUrl(stateRoot: string): string | undefined;
|
|
23
|
+
/** How many times one alarm sync is retried before giving up (each store mutation and every boot
|
|
24
|
+
* restart the cycle, and the pending store is the durable desired state — so "give up" means "until
|
|
25
|
+
* the next mirror", never "lost"). */
|
|
26
|
+
export declare const MAX_SYNC_ATTEMPTS = 5;
|
|
27
|
+
/** Pending wake-ups → the desired alarm set, minus already-due entries (see {@link DUE_MARGIN_MS}). */
|
|
28
|
+
export declare function toAlarms(pending: Wakeup[], now: Date): WakeAlarm[];
|
|
29
|
+
/**
|
|
30
|
+
* Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start`
|
|
31
|
+
* when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
|
|
32
|
+
* failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
|
|
33
|
+
* still fires on the next time the box happens to be awake), it must never break the store write.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createWakeAlarmSink(options: {
|
|
36
|
+
secret: string;
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
/** Injectable clock (tests); defaults to the wall clock. */
|
|
39
|
+
now?: () => Date;
|
|
40
|
+
/** Injectable retry pause (tests); defaults to exponential-ish backoff off RETRY_BASE_MS. */
|
|
41
|
+
delay?: (ms: number) => Promise<void>;
|
|
42
|
+
}): (stateRoot: string, pending: Wakeup[]) => void;
|
|
43
|
+
/**
|
|
44
|
+
* One boot-time reconcile: pending wake-ups may exist while their alarms were lost (a deploy
|
|
45
|
+
* replaced the forwarder, a sink call failed) — re-mirror the current set once at start.
|
|
46
|
+
*/
|
|
47
|
+
export declare function reconcileWakeAlarms(stateRoot: string, sink: (stateRoot: string, pending: Wakeup[]) => void): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake ALARMS for the AgentCore deployment: the piece that makes the agent's self-scheduled
|
|
3
|
+
* wake-ups (`wake`) reliable on a host with NO resident process. The mechanism, end to end:
|
|
4
|
+
*
|
|
5
|
+
* wake written → wakeups store save → the SINK here → POST the full pending set to the
|
|
6
|
+
* forwarder's reserved path → the forwarder (which has the AWS SDK + an IAM role) mirrors each
|
|
7
|
+
* pending wake-up into a ONE-SHOT EventBridge schedule (`at(fireAt)`, self-deleting) → at the
|
|
8
|
+
* instant, EventBridge pokes the forwarder → InvokeAgentRuntime wakes the container → the boot /
|
|
9
|
+
* 30s wake pump finds the due entry and fires it (the existing overdue catch-up — no new fire
|
|
10
|
+
* path). A RECURRING wake re-arms itself: its claim advances `fireAt` in the store, which is a
|
|
11
|
+
* save, which re-runs this sink, which re-upserts its alarm for the next occurrence.
|
|
12
|
+
*
|
|
13
|
+
* The container itself never calls AWS (no SDK dependency, no SigV4, no credential chain): it only
|
|
14
|
+
* POSTs to its own deployment's public forwarder URL, authenticated by a shared secret
|
|
15
|
+
* (`FASTAGENT_WAKE_SECRET`, a CloudFormation NoEcho parameter both sides receive). The URL is not
|
|
16
|
+
* baked anywhere — the forwarder INJECTS it into every envelope it forwards (it resolves its own
|
|
17
|
+
* Function URL at cold start), and the adapter persists it here; every wake write happens inside a
|
|
18
|
+
* turn, and every ingress turn arrived through an envelope, so the URL is always known by then.
|
|
19
|
+
*
|
|
20
|
+
* Reconciliation is DECLARATIVE (the full pending set travels each time) and deletion is lazy: a
|
|
21
|
+
* cancelled wake-up's alarm still fires its poke, finds nothing due, and self-deletes — a harmless
|
|
22
|
+
* wasted wake-up of the box, traded for never needing list/delete choreography.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { beginWork } from "../channels/busy.js";
|
|
26
|
+
import { log } from "../log.js";
|
|
27
|
+
import { scheduleFile, writeScheduleFile } from "./state.js";
|
|
28
|
+
import { listWakeups } from "./wakeups.js";
|
|
29
|
+
/** The forwarder's reserved wake-alarm path — never forwarded to channel routes. */
|
|
30
|
+
export const WAKE_ALARM_PATH = "/__fastagent/wake-alarm";
|
|
31
|
+
const URL_FILE = "wake-alarm-url";
|
|
32
|
+
/**
|
|
33
|
+
* Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on
|
|
34
|
+
* every turn, the file should not churn). Durable under <stateRoot>/schedule/ so a freshly booted
|
|
35
|
+
* container whose FIRST action is a wake fire (recurring advance → save → sink) knows the URL
|
|
36
|
+
* before any envelope of its own has arrived.
|
|
37
|
+
*/
|
|
38
|
+
export function rememberWakeAlarmUrl(stateRoot, url) {
|
|
39
|
+
if (readWakeAlarmUrl(stateRoot) === url)
|
|
40
|
+
return;
|
|
41
|
+
writeScheduleFile(scheduleFile(stateRoot, URL_FILE), { url });
|
|
42
|
+
}
|
|
43
|
+
/** The persisted forwarder URL, or undefined before the first envelope ever seen. */
|
|
44
|
+
export function readWakeAlarmUrl(stateRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const v = JSON.parse(readFileSync(scheduleFile(stateRoot, URL_FILE), "utf8"));
|
|
47
|
+
return typeof v.url === "string" ? v.url : undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined; // absent/corrupt — the next envelope rewrites it
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** How many times one alarm sync is retried before giving up (each store mutation and every boot
|
|
54
|
+
* restart the cycle, and the pending store is the durable desired state — so "give up" means "until
|
|
55
|
+
* the next mirror", never "lost"). */
|
|
56
|
+
export const MAX_SYNC_ATTEMPTS = 5;
|
|
57
|
+
const RETRY_BASE_MS = 2_000;
|
|
58
|
+
const SYNC_TIMEOUT_MS = 10_000;
|
|
59
|
+
/** Alarms due within this margin are NOT mirrored: the box is awake handling them right now (that is
|
|
60
|
+
* how their fireAt got written/claimed), and a past `at()` would only fail at the Scheduler API —
|
|
61
|
+
* filtering them client-side keeps every forwarder failure a REAL one worth retrying. */
|
|
62
|
+
const DUE_MARGIN_MS = 5_000;
|
|
63
|
+
/** Pending wake-ups → the desired alarm set, minus already-due entries (see {@link DUE_MARGIN_MS}). */
|
|
64
|
+
export function toAlarms(pending, now) {
|
|
65
|
+
return pending
|
|
66
|
+
.filter((w) => Date.parse(w.fireAt) > now.getTime() + DUE_MARGIN_MS)
|
|
67
|
+
.map((w) => ({ id: w.id, at: w.fireAt }));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start`
|
|
71
|
+
* when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
|
|
72
|
+
* failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
|
|
73
|
+
* still fires on the next time the box happens to be awake), it must never break the store write.
|
|
74
|
+
*/
|
|
75
|
+
export function createWakeAlarmSink(options) {
|
|
76
|
+
const { secret, fetchImpl = fetch, now = () => new Date() } = options;
|
|
77
|
+
const delay = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
78
|
+
// Supersession token: a newer mutation's sync REPLACES an older one still retrying — without it,
|
|
79
|
+
// an old in-flight set could win the race and mirror stale state.
|
|
80
|
+
let latest;
|
|
81
|
+
async function sync(url, body, token) {
|
|
82
|
+
// Counted as in-flight work: the retry loop is exactly the window where the box must not be
|
|
83
|
+
// reclaimed — idle away mid-retry and a pending wake has no alarm until the next boot.
|
|
84
|
+
const workDone = beginWork();
|
|
85
|
+
try {
|
|
86
|
+
for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
|
|
87
|
+
if (latest !== token)
|
|
88
|
+
return; // superseded by a newer set — that sync owns correctness now
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetchImpl(`${url.replace(/\/$/, "")}${WAKE_ALARM_PATH}`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": "application/json" },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
|
95
|
+
});
|
|
96
|
+
if (res.ok)
|
|
97
|
+
return;
|
|
98
|
+
log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: HTTP ${res.status}`);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: ${String(e)}`);
|
|
102
|
+
}
|
|
103
|
+
await delay(RETRY_BASE_MS * attempt);
|
|
104
|
+
}
|
|
105
|
+
log.error(`[schedule] wake alarm sync FAILED after ${MAX_SYNC_ATTEMPTS} attempts — pending wake-ups have no ` +
|
|
106
|
+
`external alarm until the next store change or boot re-mirrors them`);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
workDone();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return (stateRoot, pending) => {
|
|
113
|
+
const url = readWakeAlarmUrl(stateRoot);
|
|
114
|
+
if (!url) {
|
|
115
|
+
// First-ever boot before any envelope: nothing to call yet. The wake itself is stored; the
|
|
116
|
+
// alarm catches up on the next store mutation after an envelope has arrived.
|
|
117
|
+
log.warn("[schedule] wake alarm skipped — forwarder URL not seen yet (it arrives with the first envelope)");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const alarms = toAlarms(pending, now());
|
|
121
|
+
if (alarms.length === 0)
|
|
122
|
+
return; // nothing future to mirror (deletion is lazy by design)
|
|
123
|
+
const token = Symbol("wake-alarm-sync");
|
|
124
|
+
latest = token;
|
|
125
|
+
void sync(url, { secret, alarms }, token);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* One boot-time reconcile: pending wake-ups may exist while their alarms were lost (a deploy
|
|
130
|
+
* replaced the forwarder, a sink call failed) — re-mirror the current set once at start.
|
|
131
|
+
*/
|
|
132
|
+
export function reconcileWakeAlarms(stateRoot, sink) {
|
|
133
|
+
const pending = listWakeups(stateRoot);
|
|
134
|
+
if (pending.length > 0)
|
|
135
|
+
sink(stateRoot, pending);
|
|
136
|
+
}
|
|
@@ -26,6 +26,7 @@ export declare const MAX_PENDING_WAKEUPS = 20;
|
|
|
26
26
|
* wake into an active conversation fires in the first gap between the user's turns; this generous
|
|
27
27
|
* ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
|
|
28
28
|
export declare const MAX_WAKE_ATTEMPTS = 120;
|
|
29
|
+
export declare function setWakeupsSink(sink: ((stateRoot: string, pending: Wakeup[]) => void) | undefined): void;
|
|
29
30
|
/** The current pending wake-ups (`fastagent schedule list` uses this). */
|
|
30
31
|
export declare function listWakeups(stateRoot: string): Wakeup[];
|
|
31
32
|
export type AddWakeupResult = {
|