@fastagent-sh/fastagent 0.14.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 +16 -6
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- 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 +35 -0
- package/dist/channels/context-buffer.js +103 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +15 -13
- package/dist/channels/feishu/context-buffer.js +62 -69
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +24 -3
- package/dist/channels/feishu/feishu.d.ts +27 -27
- package/dist/channels/feishu/feishu.js +318 -207
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +44 -79
- package/dist/channels/feishu/model.d.ts +2 -2
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +22 -8
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +27 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- 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 +37 -0
- package/dist/channels/feishu/setup-mode.js +28 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +16 -7
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +36 -3
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- 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 +115 -0
- package/dist/channels/preview-kit.js +184 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/parse.d.ts +38 -0
- package/dist/channels/slack/parse.js +124 -0
- package/dist/channels/slack/preview.d.ts +23 -0
- package/dist/channels/slack/preview.js +403 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +57 -0
- package/dist/channels/slack/slack-api.js +399 -0
- package/dist/channels/slack/slack.d.ts +51 -0
- package/dist/channels/slack/slack.js +496 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +22 -15
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +7 -0
- package/dist/channels/tasks.js +25 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +24 -10
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
- package/dist/channels/{feishu/text.js → text.js} +15 -1
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +8 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +30 -0
- package/dist/cli/add-feishu.js +366 -0
- package/dist/cli/add-slack.d.ts +11 -0
- package/dist/cli/add-slack.js +199 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +191 -60
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +563 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +12 -7
- package/dist/cli/commands/deploy.d.ts +3 -1
- package/dist/cli/commands/deploy.js +384 -82
- package/dist/cli/commands/dev.js +34 -26
- package/dist/cli/commands/fire.js +17 -18
- 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 +11 -9
- package/dist/cli/commands/login.js +48 -45
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +102 -39
- package/dist/cli/commands/tool.js +23 -18
- package/dist/cli/fail.d.ts +17 -2
- package/dist/cli/fail.js +25 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +135 -56
- package/dist/cli/serve.d.ts +60 -20
- package/dist/cli/serve.js +252 -47
- package/dist/cli/shared.d.ts +40 -7
- package/dist/cli/shared.js +180 -38
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- 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 +3 -3
- package/dist/deploy/docker/plan.js +20 -23
- package/dist/deploy/fly/plan.d.ts +5 -1
- package/dist/deploy/fly/plan.js +39 -26
- package/dist/deploy/fly/run.d.ts +17 -5
- package/dist/deploy/fly/run.js +52 -7
- package/dist/deploy/preflight.d.ts +20 -8
- package/dist/deploy/preflight.js +267 -71
- package/dist/deploy/railway/plan.d.ts +10 -1
- package/dist/deploy/railway/plan.js +53 -20
- package/dist/deploy/railway/run.d.ts +13 -5
- package/dist/deploy/railway/run.js +22 -7
- package/dist/deploy/runner.d.ts +7 -3
- package/dist/deploy/runner.js +9 -3
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +11 -10
- package/dist/dev-supervisor.js +56 -50
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +92 -62
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +20 -55
- package/dist/engines/pi/config.js +49 -121
- package/dist/engines/pi/create.d.ts +30 -10
- package/dist/engines/pi/create.js +33 -26
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +12 -1
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +59 -13
- package/dist/engines/pi/models.js +62 -24
- package/dist/engines/pi/open.d.ts +114 -0
- package/dist/engines/pi/open.js +127 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +10 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +25 -8
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- 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/observe.js +3 -0
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +6 -5
- package/dist/pi.js +4 -3
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +26 -9
- package/dist/scaffold/add-channel.js +175 -40
- 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 +5 -4
- 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 -3
- package/dist/schedule/wakeups.js +19 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +23 -12
- package/package.json +22 -7
- package/dist/channels/feishu/owned-threads.d.ts +0 -11
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-add-feishu.js +0 -223
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
- package/dist/engines/pi/workspace.d.ts +0 -56
- package/dist/engines/pi/workspace.js +0 -72
- 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,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,9 +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
|
-
|
|
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;
|
|
29
|
+
export declare function setWakeupsSink(sink: ((stateRoot: string, pending: Wakeup[]) => void) | undefined): void;
|
|
32
30
|
/** The current pending wake-ups (`fastagent schedule list` uses this). */
|
|
33
31
|
export declare function listWakeups(stateRoot: string): Wakeup[];
|
|
34
32
|
export type AddWakeupResult = {
|
package/dist/schedule/wakeups.js
CHANGED
|
@@ -26,7 +26,7 @@ export const MAX_PENDING_WAKEUPS = 20;
|
|
|
26
26
|
export const MAX_WAKE_ATTEMPTS = 120;
|
|
27
27
|
/** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
|
|
28
28
|
* a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
|
|
29
|
-
|
|
29
|
+
const MIN_RECURRING_GAP_MS = 10 * 60_000; // 10 minutes
|
|
30
30
|
/** A stored entry is a real Wakeup: the fields are present and `fireAt` is a parseable date. A malformed
|
|
31
31
|
* one (bad/missing fireAt) would compare NaN <= now = false forever — never due, never cleared, but still
|
|
32
32
|
* eating the pending quota. So validate at this IO boundary and drop it (warn), like a corrupt file. */
|
|
@@ -47,6 +47,18 @@ function isWakeup(e) {
|
|
|
47
47
|
(w.cron === undefined ||
|
|
48
48
|
(typeof w.cron === "string" && cronError(w.cron, typeof w.tz === "string" ? w.tz : undefined) === undefined)));
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The wake-ALARM sink: notified after EVERY wakeups-store mutation with the new pending set. The
|
|
52
|
+
* AgentCore deployment registers one (schedule/wake-alarm.ts) that mirrors pending wake-ups into
|
|
53
|
+
* one-shot EventBridge schedules — the external clock that makes `wake` reliable on a host with no
|
|
54
|
+
* resident process. Neutral seam: this module knows only "someone wants to observe changes"; a
|
|
55
|
+
* resident host registers nothing and behaves exactly as before. The sink is fire-and-forget and
|
|
56
|
+
* must own its errors; a throw is caught here so a broken alarm never corrupts a store write.
|
|
57
|
+
*/
|
|
58
|
+
let wakeupsSink;
|
|
59
|
+
export function setWakeupsSink(sink) {
|
|
60
|
+
wakeupsSink = sink;
|
|
61
|
+
}
|
|
50
62
|
function load(stateRoot) {
|
|
51
63
|
const v = readScheduleFile(scheduleFile(stateRoot, "wakeups"));
|
|
52
64
|
if (v === undefined)
|
|
@@ -68,6 +80,12 @@ function load(stateRoot) {
|
|
|
68
80
|
}
|
|
69
81
|
function save(stateRoot, wakeups) {
|
|
70
82
|
writeScheduleFile(scheduleFile(stateRoot, "wakeups"), wakeups);
|
|
83
|
+
try {
|
|
84
|
+
wakeupsSink?.(stateRoot, wakeups);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
log.error(`[schedule] wake-alarm sink failed (store write is unaffected): ${String(e)}`);
|
|
88
|
+
}
|
|
71
89
|
}
|
|
72
90
|
/** The current pending wake-ups (`fastagent schedule list` uses this). */
|
|
73
91
|
export function listWakeups(stateRoot) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote `SessionControl` — the client half of the Phase 3 transport (design §13). Engine- and
|
|
3
|
+
* server-neutral: speaks only the wire protocol `controlRoutes` serves (HTTP JSON + SSE with the
|
|
4
|
+
* {sessionId, epoch, seq, event} envelope) and re-exposes the SAME `SessionControl` interface, so
|
|
5
|
+
* local and remote consumers are isomorphic — client code does not change when the agent moves out
|
|
6
|
+
* of process.
|
|
7
|
+
*
|
|
8
|
+
* Envelope consumption is internal: a seq gap (loss in transit on this connection) — and any
|
|
9
|
+
* mid-stream transport failure, a server restart included (its connections drop) — THROWS from
|
|
10
|
+
* the events iterator, so the consumer's failure handling and budget own it; only the consumer's
|
|
11
|
+
* own detach reads as a clean end. Recovery is the standard reconnect steps (`entries({ since })`
|
|
12
|
+
* → `state()` → resubscribe), exactly as after any disconnect. The envelope's `epoch` is
|
|
13
|
+
* informational for consumers that correlate ACROSS connections — within one connection it cannot
|
|
14
|
+
* change, so this client does not compare it. Nothing here retries silently: a broken stream is
|
|
15
|
+
* visible as a thrown iteration error, a failed request as a rejected promise.
|
|
16
|
+
*/
|
|
17
|
+
import type { Agent } from "./agent.ts";
|
|
18
|
+
import type { SessionControl } from "./session.ts";
|
|
19
|
+
/** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
|
|
20
|
+
* consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
|
|
21
|
+
* trouble branches on `status`, never on message prose. */
|
|
22
|
+
export declare class ControlRequestError extends Error {
|
|
23
|
+
readonly status: number;
|
|
24
|
+
constructor(status: number, body: string);
|
|
25
|
+
}
|
|
26
|
+
/** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
|
|
27
|
+
* `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */
|
|
28
|
+
export interface RemoteEndpointOptions {
|
|
29
|
+
/** Base URL of the serving process (e.g. `http://127.0.0.1:8787`); `/control/*` is appended. */
|
|
30
|
+
url: string;
|
|
31
|
+
/** The shared bearer secret (`<stateRoot>/control.json` on the serving machine). */
|
|
32
|
+
token: string;
|
|
33
|
+
/** Injectable for tests. Defaults to global fetch. */
|
|
34
|
+
fetchFn?: typeof fetch;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
|
|
38
|
+
* the contract: the static declaration is fetched ONCE here and served from memory — which also
|
|
39
|
+
* makes a wrong URL/token fail at connect time, not on first use.
|
|
40
|
+
*/
|
|
41
|
+
export declare function connectSessionControl(options: RemoteEndpointOptions): Promise<SessionControl>;
|
|
42
|
+
/**
|
|
43
|
+
* The remote DATA plane: an `Agent` whose `invoke` drives `POST /control/invoke` on a serving
|
|
44
|
+
* process — paired with {@link connectSessionControl}, a client holds a full remote fastagent
|
|
45
|
+
* instance through the same two contracts local code uses. A REAL Agent, failure discipline
|
|
46
|
+
* included: SPEC MUST 2 forbids iteration throws, so every failure — transport (401/refused/
|
|
47
|
+
* dropped mid-stream), protocol, and the images precheck — becomes a terminal `failed` event
|
|
48
|
+
* (`retryable` from the HTTP status where one exists; network trouble is retryable). Breaking out
|
|
49
|
+
* of iteration disconnects the request, which cancels the run (SPEC cancellation semantics travel
|
|
50
|
+
* the wire). The invoke wire is text-only for now: a prompt with images fails visibly instead of
|
|
51
|
+
* silently dropping them (steer/follow_up on the control plane carry full Prompts).
|
|
52
|
+
*/
|
|
53
|
+
export declare function connectAgent(options: RemoteEndpointOptions): Agent;
|