@fastagent-sh/fastagent 0.21.1 → 0.22.1
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 +9 -12
- package/dist/agent.d.ts +24 -37
- package/dist/agent.js +7 -15
- package/dist/atomic-write.d.ts +13 -17
- package/dist/atomic-write.js +18 -29
- package/dist/bind.d.ts +12 -22
- package/dist/bind.js +18 -27
- package/dist/channel.d.ts +11 -33
- package/dist/channels/agentcore-limits.d.ts +2 -13
- package/dist/channels/agentcore-limits.js +2 -13
- package/dist/channels/agentcore-protocol.d.ts +67 -58
- package/dist/channels/agentcore-protocol.js +23 -15
- package/dist/channels/agentcore-service.d.ts +19 -33
- package/dist/channels/agentcore-service.js +176 -59
- package/dist/channels/agentcore.d.ts +39 -33
- package/dist/channels/agentcore.js +90 -224
- package/dist/channels/body.d.ts +15 -2
- package/dist/channels/body.js +24 -2
- package/dist/channels/busy.d.ts +1 -21
- package/dist/channels/busy.js +2 -39
- package/dist/channels/control.d.ts +17 -62
- package/dist/channels/control.js +74 -182
- package/dist/channels/define-channel.d.ts +39 -0
- package/dist/channels/define-channel.js +9 -0
- package/dist/channels/discover.d.ts +10 -31
- package/dist/channels/discover.js +37 -31
- package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
- package/dist/channels/feishu/bootstrap-token.js +7 -23
- package/dist/channels/feishu/card.d.ts +14 -28
- package/dist/channels/feishu/card.js +16 -30
- package/dist/channels/feishu/cloud.d.ts +1 -5
- package/dist/channels/feishu/context-buffer.d.ts +4 -23
- package/dist/channels/feishu/context-buffer.js +8 -31
- package/dist/channels/feishu/crypto.d.ts +10 -5
- package/dist/channels/feishu/crypto.js +12 -19
- package/dist/channels/feishu/feishu-api.d.ts +2 -1
- package/dist/channels/feishu/feishu-api.js +5 -4
- package/dist/channels/feishu/feishu.d.ts +4 -10
- package/dist/channels/feishu/feishu.js +113 -227
- package/dist/channels/feishu/invoke-turn.d.ts +5 -4
- package/dist/channels/feishu/invoke-turn.js +36 -54
- package/dist/channels/feishu/model.d.ts +4 -21
- package/dist/channels/feishu/model.js +1 -8
- package/dist/channels/feishu/normalize.d.ts +2 -10
- package/dist/channels/feishu/normalize.js +7 -42
- package/dist/channels/feishu/parse.d.ts +9 -29
- package/dist/channels/feishu/parse.js +8 -26
- package/dist/channels/feishu/preview.d.ts +12 -14
- package/dist/channels/feishu/preview.js +108 -207
- package/dist/channels/feishu/register-app.d.ts +8 -23
- package/dist/channels/feishu/register-app.js +7 -20
- package/dist/channels/feishu/register-webhook.d.ts +2 -9
- package/dist/channels/feishu/register-webhook.js +9 -33
- package/dist/channels/feishu/scaffold/channel.ts +27 -19
- package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/feishu/setup-mode.d.ts +11 -36
- package/dist/channels/feishu/setup-mode.js +7 -26
- package/dist/channels/feishu/ws-ingress.d.ts +1 -2
- package/dist/channels/feishu/ws-ingress.js +6 -8
- package/dist/channels/http.d.ts +15 -15
- package/dist/channels/http.js +19 -9
- package/dist/channels/kit/attachment-path.d.ts +4 -5
- package/dist/channels/kit/attachment-path.js +10 -34
- package/dist/channels/kit/context-buffer.d.ts +14 -21
- package/dist/channels/kit/context-buffer.js +25 -38
- package/dist/channels/kit/delivery.d.ts +35 -0
- package/dist/channels/kit/delivery.js +129 -0
- package/dist/channels/kit/event-stream.d.ts +5 -0
- package/dist/channels/kit/event-stream.js +29 -0
- package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
- package/dist/channels/kit/invoke-turn-kit.js +89 -32
- package/dist/channels/kit/preview-kit.d.ts +18 -72
- package/dist/channels/kit/preview-kit.js +22 -88
- package/dist/channels/kit/signature.d.ts +3 -9
- package/dist/channels/kit/signature.js +3 -9
- package/dist/channels/kit/state.d.ts +1 -2
- package/dist/channels/kit/state.js +5 -14
- package/dist/channels/kit/stop-command.d.ts +1 -2
- package/dist/channels/kit/stop-command.js +6 -9
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +8 -21
- package/dist/channels/kit/text.d.ts +2 -16
- package/dist/channels/kit/text.js +23 -31
- package/dist/channels/kit/thread-participants.d.ts +4 -17
- package/dist/channels/kit/thread-participants.js +8 -55
- package/dist/channels/kit/transport.d.ts +26 -0
- package/dist/channels/kit/transport.js +20 -0
- package/dist/channels/kit/turn-queue.d.ts +7 -11
- package/dist/channels/kit/turn-queue.js +21 -39
- package/dist/channels/kit/turn-runner.d.ts +32 -18
- package/dist/channels/kit/turn-runner.js +98 -53
- package/dist/channels/kit/turn-store.d.ts +32 -32
- package/dist/channels/kit/turn-store.js +45 -88
- package/dist/channels/lark/lark.d.ts +1 -5
- package/dist/channels/lark/onboard.d.ts +3 -12
- package/dist/channels/lark/onboard.js +3 -13
- package/dist/channels/lark/scaffold/channel.ts +27 -19
- package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
- package/dist/channels/registration.d.ts +9 -35
- package/dist/channels/registration.js +6 -37
- package/dist/channels/secret.js +2 -5
- package/dist/channels/serve.d.ts +47 -44
- package/dist/channels/serve.js +165 -101
- package/dist/channels/slack/config-api.d.ts +4 -27
- package/dist/channels/slack/config-api.js +5 -34
- package/dist/channels/slack/context-buffer.d.ts +2 -3
- package/dist/channels/slack/context-buffer.js +5 -5
- package/dist/channels/slack/invoke-turn.d.ts +3 -1
- package/dist/channels/slack/invoke-turn.js +25 -34
- package/dist/channels/slack/manifest.js +1 -3
- package/dist/channels/slack/model.d.ts +1 -2
- package/dist/channels/slack/model.js +1 -1
- package/dist/channels/slack/onboard.d.ts +4 -3
- package/dist/channels/slack/onboard.js +8 -11
- package/dist/channels/slack/onboarding-state.d.ts +3 -5
- package/dist/channels/slack/onboarding-state.js +9 -6
- package/dist/channels/slack/parse.d.ts +4 -13
- package/dist/channels/slack/parse.js +7 -28
- package/dist/channels/slack/preview.d.ts +12 -2
- package/dist/channels/slack/preview.js +292 -269
- package/dist/channels/slack/reaction.d.ts +11 -3
- package/dist/channels/slack/reaction.js +19 -8
- package/dist/channels/slack/register-webhook.js +4 -7
- package/dist/channels/slack/scaffold/channel.ts +20 -13
- package/dist/channels/slack/scaffold/slack-send.ts +2 -2
- package/dist/channels/slack/setup-server.d.ts +1 -5
- package/dist/channels/slack/setup-server.js +1 -5
- package/dist/channels/slack/shared-api.d.ts +1 -4
- package/dist/channels/slack/shared-api.js +2 -13
- package/dist/channels/slack/slack-api.d.ts +2 -1
- package/dist/channels/slack/slack-api.js +6 -4
- package/dist/channels/slack/slack.d.ts +11 -14
- package/dist/channels/slack/slack.js +121 -122
- package/dist/channels/sse.d.ts +1 -1
- package/dist/channels/sse.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +7 -22
- package/dist/channels/telegram/context-buffer.js +6 -23
- package/dist/channels/telegram/invoke-turn.d.ts +7 -7
- package/dist/channels/telegram/invoke-turn.js +24 -43
- package/dist/channels/telegram/parse.d.ts +24 -51
- package/dist/channels/telegram/parse.js +32 -84
- package/dist/channels/telegram/preview.d.ts +10 -1
- package/dist/channels/telegram/preview.js +79 -100
- package/dist/channels/telegram/register-webhook.js +5 -23
- package/dist/channels/telegram/scaffold/channel.ts +25 -18
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
- package/dist/channels/telegram/telegram-api.d.ts +4 -3
- package/dist/channels/telegram/telegram-api.js +9 -7
- package/dist/channels/telegram/telegram.d.ts +4 -18
- package/dist/channels/telegram/telegram.js +38 -94
- package/dist/channels/telegram/turn-store.d.ts +11 -14
- package/dist/channels/telegram/turn-store.js +8 -6
- package/dist/channels/wait-health.d.ts +4 -6
- package/dist/channels/wait-health.js +10 -14
- package/dist/cli/add-feishu.d.ts +2 -12
- package/dist/cli/add-feishu.js +24 -71
- package/dist/cli/add-slack.d.ts +5 -3
- package/dist/cli/add-slack.js +53 -76
- package/dist/cli/auth-view.d.ts +2 -16
- package/dist/cli/auth-view.js +2 -16
- package/dist/cli/commands/add.js +25 -40
- package/dist/cli/commands/chat.d.ts +0 -1
- package/dist/cli/commands/chat.js +5 -6
- package/dist/cli/commands/deploy/agentcore.js +60 -65
- package/dist/cli/commands/deploy/docker.js +50 -23
- package/dist/cli/commands/deploy/fly.js +23 -61
- package/dist/cli/commands/deploy/railway.js +23 -18
- package/dist/cli/commands/deploy/shared.d.ts +38 -57
- package/dist/cli/commands/deploy/shared.js +40 -59
- package/dist/cli/commands/deploy.d.ts +10 -27
- package/dist/cli/commands/deploy.js +38 -60
- package/dist/cli/commands/destroy.d.ts +4 -0
- package/dist/cli/commands/destroy.js +56 -0
- package/dist/cli/commands/dev.d.ts +2 -1
- package/dist/cli/commands/dev.js +20 -23
- package/dist/cli/commands/info.d.ts +0 -2
- package/dist/cli/commands/info.js +83 -49
- package/dist/cli/commands/init.d.ts +1 -2
- package/dist/cli/commands/init.js +16 -55
- package/dist/cli/commands/invoke.d.ts +0 -1
- package/dist/cli/commands/invoke.js +5 -7
- package/dist/cli/commands/login.d.ts +2 -1
- package/dist/cli/commands/login.js +33 -38
- package/dist/cli/commands/logs.js +3 -4
- package/dist/cli/commands/models.js +1 -5
- package/dist/cli/commands/routine-run.d.ts +6 -0
- package/dist/cli/commands/routine-run.js +47 -0
- package/dist/cli/commands/routine.d.ts +25 -0
- package/dist/cli/commands/routine.js +122 -0
- package/dist/cli/commands/start.d.ts +31 -3
- package/dist/cli/commands/start.js +131 -81
- package/dist/cli/commands/tool.js +26 -12
- package/dist/cli/fail.d.ts +13 -23
- package/dist/cli/fail.js +20 -23
- package/dist/cli/invoke-stream.d.ts +1 -8
- package/dist/cli/kernel.d.ts +6 -38
- package/dist/cli/kernel.js +14 -77
- package/dist/cli/models-view.d.ts +1 -9
- package/dist/cli/models-view.js +11 -22
- package/dist/cli/program.d.ts +5 -6
- package/dist/cli/program.js +120 -157
- package/dist/cli/serve.d.ts +57 -41
- package/dist/cli/serve.js +94 -121
- package/dist/cli/shared.d.ts +15 -61
- package/dist/cli/shared.js +55 -181
- package/dist/cli.js +1 -8
- package/dist/collect.d.ts +6 -0
- package/dist/collect.js +6 -0
- package/dist/core.d.ts +4 -1
- package/dist/core.js +9 -7
- package/dist/declared-secrets.d.ts +55 -0
- package/dist/declared-secrets.js +88 -0
- package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
- package/dist/deploy/agentcore/aws-cli.js +64 -0
- package/dist/deploy/agentcore/destroy.d.ts +51 -0
- package/dist/deploy/agentcore/destroy.js +273 -0
- package/dist/deploy/agentcore/forwarder.js +28 -79
- package/dist/deploy/agentcore/logs.d.ts +2 -18
- package/dist/deploy/agentcore/logs.js +27 -51
- package/dist/deploy/agentcore/plan.d.ts +85 -105
- package/dist/deploy/agentcore/plan.js +152 -314
- package/dist/deploy/agentcore/run.d.ts +28 -28
- package/dist/deploy/agentcore/run.js +227 -306
- package/dist/deploy/agentcore/zip.d.ts +2 -15
- package/dist/deploy/agentcore/zip.js +3 -17
- package/dist/deploy/build-context.d.ts +21 -0
- package/dist/deploy/build-context.js +137 -0
- package/dist/deploy/channel-ingress.d.ts +26 -39
- package/dist/deploy/channel-ingress.js +57 -28
- package/dist/deploy/container.d.ts +20 -34
- package/dist/deploy/container.js +49 -100
- package/dist/deploy/docker/plan.d.ts +12 -11
- package/dist/deploy/docker/plan.js +43 -24
- package/dist/deploy/docker/run.d.ts +22 -25
- package/dist/deploy/docker/run.js +39 -37
- package/dist/deploy/fly/plan.d.ts +15 -44
- package/dist/deploy/fly/plan.js +41 -73
- package/dist/deploy/fly/run.d.ts +16 -51
- package/dist/deploy/fly/run.js +42 -95
- package/dist/deploy/hosts.d.ts +24 -3
- package/dist/deploy/hosts.js +24 -3
- package/dist/deploy/preflight.d.ts +47 -41
- package/dist/deploy/preflight.js +282 -383
- package/dist/deploy/railway/plan.d.ts +16 -42
- package/dist/deploy/railway/plan.js +42 -60
- package/dist/deploy/railway/run.d.ts +30 -68
- package/dist/deploy/railway/run.js +86 -100
- package/dist/deploy/registration-gate.d.ts +6 -13
- package/dist/deploy/residency.d.ts +37 -0
- package/dist/deploy/residency.js +24 -0
- package/dist/deploy/runner.d.ts +10 -9
- package/dist/deploy/runner.js +7 -18
- package/dist/deploy/secrets.d.ts +40 -41
- package/dist/deploy/secrets.js +117 -103
- package/dist/deploy/workspace.d.ts +50 -0
- package/dist/deploy/workspace.js +197 -0
- package/dist/dev-supervisor.d.ts +1 -9
- package/dist/dev-supervisor.js +21 -64
- package/dist/effect-port.d.ts +50 -0
- package/dist/effect-port.js +77 -0
- package/dist/engines/pi/agent-session-factory.d.ts +50 -69
- package/dist/engines/pi/agent-session-factory.js +249 -146
- package/dist/engines/pi/auth.d.ts +15 -11
- package/dist/engines/pi/auth.js +96 -99
- package/dist/engines/pi/chat.d.ts +1 -5
- package/dist/engines/pi/chat.js +2 -14
- package/dist/engines/pi/config.d.ts +53 -51
- package/dist/engines/pi/config.js +83 -92
- package/dist/engines/pi/create.d.ts +60 -75
- package/dist/engines/pi/create.js +108 -141
- package/dist/engines/pi/definition.d.ts +14 -58
- package/dist/engines/pi/definition.js +21 -85
- package/dist/engines/pi/invoke-session.d.ts +5 -25
- package/dist/engines/pi/invoke-session.js +233 -244
- package/dist/engines/pi/login.d.ts +95 -35
- package/dist/engines/pi/login.js +259 -107
- package/dist/engines/pi/machine.d.ts +31 -0
- package/dist/engines/pi/machine.js +106 -0
- package/dist/engines/pi/models.d.ts +70 -89
- package/dist/engines/pi/models.js +188 -81
- package/dist/engines/pi/open.d.ts +65 -53
- package/dist/engines/pi/open.js +129 -69
- package/dist/engines/pi/report.d.ts +3 -12
- package/dist/engines/pi/report.js +2 -16
- package/dist/engines/pi/search-tools.d.ts +1 -9
- package/dist/engines/pi/search-tools.js +26 -57
- package/dist/engines/pi/service.d.ts +2 -16
- package/dist/engines/pi/service.js +2 -16
- package/dist/engines/pi/session-builder.d.ts +5 -9
- package/dist/engines/pi/session-builder.js +44 -82
- package/dist/engines/pi/session-control.js +286 -269
- package/dist/engines/pi/session-effects.d.ts +14 -0
- package/dist/engines/pi/session-effects.js +30 -0
- package/dist/engines/pi/session-inheritance.d.ts +9 -18
- package/dist/engines/pi/session-inheritance.js +103 -160
- package/dist/engines/pi/session-markers.d.ts +31 -30
- package/dist/engines/pi/session-markers.js +26 -25
- package/dist/engines/pi/session-settings.d.ts +6 -22
- package/dist/engines/pi/session-settings.js +10 -21
- package/dist/engines/pi/session-store.d.ts +26 -92
- package/dist/engines/pi/session-store.js +96 -310
- package/dist/engines/pi/tool-context.d.ts +17 -45
- package/dist/engines/pi/tool-context.js +7 -26
- package/dist/engines/pi/tool.d.ts +45 -36
- package/dist/engines/pi/tool.js +43 -57
- package/dist/engines/pi/turn-kit.d.ts +14 -52
- package/dist/engines/pi/turn-kit.js +18 -67
- package/dist/engines/pi/wake-tool.d.ts +2 -10
- package/dist/engines/pi/wake-tool.js +7 -27
- package/dist/env.d.ts +27 -28
- package/dist/env.js +45 -50
- package/dist/index.js +0 -2
- package/dist/lark.d.ts +1 -2
- package/dist/lark.js +1 -2
- package/dist/loader.d.ts +14 -55
- package/dist/loader.js +24 -43
- package/dist/log.d.ts +2 -12
- package/dist/log.js +3 -24
- package/dist/node.d.ts +2 -15
- package/dist/node.js +2 -18
- package/dist/observe.d.ts +2 -8
- package/dist/open-url.d.ts +1 -1
- package/dist/open-url.js +1 -1
- package/dist/paths.d.ts +70 -134
- package/dist/paths.js +165 -232
- package/dist/pi.d.ts +4 -6
- package/dist/pi.js +3 -3
- package/dist/proxy.d.ts +16 -6
- package/dist/proxy.js +52 -16
- package/dist/runtime.d.ts +18 -12
- package/dist/runtime.js +18 -11
- package/dist/scaffold/add-channel.d.ts +14 -31
- package/dist/scaffold/add-channel.js +48 -112
- package/dist/scaffold/init.d.ts +9 -31
- package/dist/scaffold/init.js +45 -138
- package/dist/scaffold/templates/env.example +6 -4
- package/dist/scaffold/templates/fastagent.config.ts +36 -0
- package/dist/scaffold/templates.d.ts +3 -3
- package/dist/scaffold/templates.js +5 -8
- package/dist/scaffold/vendor-skill.d.ts +2 -5
- package/dist/scaffold/vendor-skill.js +19 -29
- package/dist/schedule/cron.d.ts +9 -5
- package/dist/schedule/cron.js +9 -11
- package/dist/schedule/discover.d.ts +12 -12
- package/dist/schedule/discover.js +43 -34
- package/dist/schedule/routine.d.ts +43 -0
- package/dist/schedule/routine.js +32 -0
- package/dist/schedule/run.d.ts +55 -0
- package/dist/schedule/run.js +146 -0
- package/dist/schedule/scheduler.d.ts +44 -55
- package/dist/schedule/scheduler.js +291 -265
- package/dist/schedule/state.d.ts +105 -10
- package/dist/schedule/state.js +293 -19
- package/dist/schedule/wake-alarm.d.ts +10 -23
- package/dist/schedule/wake-alarm.js +91 -121
- package/dist/schedule/wakeups.d.ts +23 -39
- package/dist/schedule/wakeups.js +33 -75
- package/dist/secrets-gate.d.ts +32 -0
- package/dist/secrets-gate.js +33 -0
- package/dist/service.d.ts +137 -98
- package/dist/service.js +250 -252
- package/dist/session-remote.d.ts +33 -44
- package/dist/session-remote.js +203 -192
- package/dist/session.d.ts +166 -229
- package/dist/session.js +33 -55
- package/dist/tunnel.d.ts +8 -36
- package/dist/tunnel.js +24 -65
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +10 -16
- package/dist/channels/agentcore-state.d.ts +0 -77
- package/dist/channels/agentcore-state.js +0 -276
- package/dist/channels/github/github.d.ts +0 -31
- package/dist/channels/github/github.js +0 -89
- package/dist/channels/github/scaffold/channel.ts +0 -25
- package/dist/cli/commands/attach.d.ts +0 -108
- package/dist/cli/commands/attach.js +0 -637
- package/dist/cli/commands/fire.d.ts +0 -7
- package/dist/cli/commands/fire.js +0 -38
- package/dist/cli/commands/schedule.d.ts +0 -12
- package/dist/cli/commands/schedule.js +0 -87
- package/dist/github.d.ts +0 -3
- package/dist/github.js +0 -2
- package/dist/scaffold/templates/fastagent.config.mjs +0 -23
- package/dist/schedule/audit.d.ts +0 -18
- package/dist/schedule/audit.js +0 -57
- package/dist/schedule/schedule.d.ts +0 -35
- package/dist/schedule/schedule.js +0 -23
package/dist/schedule/state.d.ts
CHANGED
|
@@ -1,14 +1,109 @@
|
|
|
1
1
|
/** Path of a JSON file under `<stateRoot>/schedule/`. */
|
|
2
2
|
export declare function scheduleFile(stateRoot: string, name: string): string;
|
|
3
|
-
/**
|
|
4
|
-
* Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
|
|
5
|
-
* (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
|
|
6
|
-
* file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
|
|
7
|
-
* state. The caller owns shape validation (an IO boundary).
|
|
8
|
-
*/
|
|
9
3
|
export declare function readScheduleFile(path: string): unknown;
|
|
10
4
|
export declare function writeScheduleFile(path: string, value: unknown): void;
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
/**
|
|
6
|
+
* How a fired slot ended. There is no `deferred` or `stale` here because neither has a claim: a deferred wake-up was
|
|
7
|
+
* never claimed, and a stale delivery is refused before one is taken. Both are log lines.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* How a claimed occurrence ended.
|
|
11
|
+
*
|
|
12
|
+
* `skipped` is NOT a failure and is the reason this is an enum rather than a boolean: a schedule's turns share one
|
|
13
|
+
* `routine:<name>` session, so an occurrence arriving while the previous one is still running is refused by that
|
|
14
|
+
* session, and recording it as `failed` made "the last run was still going" indistinguishable from "the model call
|
|
15
|
+
* died" — two things an operator reacts to completely differently. Every scheduler names this: Kubernetes calls it
|
|
16
|
+
* `concurrencyPolicy: Forbid`, Temporal calls it the `Skip` overlap policy, and neither treats it as an error.
|
|
17
|
+
*/
|
|
18
|
+
export type FireOutcome = "completed" | "failed" | "skipped" | "interrupted";
|
|
19
|
+
/** One fired slot, as its claim file records it. `outcome` absent = claimed, never settled. */
|
|
20
|
+
export interface Fire {
|
|
21
|
+
/** The slot this fire was FOR (the claim's name). */
|
|
22
|
+
slot: string;
|
|
23
|
+
/** When the claim was taken (falls back to `slot` when the stamp is unusable). */
|
|
24
|
+
firedAt: string;
|
|
25
|
+
outcome?: FireOutcome;
|
|
26
|
+
ms?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A schedule name becomes a path segment (its claims live in `claims/<name>/`), so the rule is exactly the safety
|
|
30
|
+
* boundary and nothing more: a name may not leave that directory. The name comes from a filename under `routines/`,
|
|
31
|
+
* which already cannot contain a separator — so what a stricter rule would actually reject is legal filenames
|
|
32
|
+
* (`每日简报`, `my schedule`), whose only symptom would be a schedule that silently never fires again.
|
|
33
|
+
*
|
|
34
|
+
* One definition, two enforcers: `discover.ts` refuses an illegal one where the author can see which file is wrong,
|
|
35
|
+
* and `claimDir` asserts it again for a programmatic caller.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isSafeScheduleName(name: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Why a slot is not ours. `duplicate` is ordinary (the same delivery arrived twice, or another scheduler took it and
|
|
40
|
+
* is running it now); `stale` means the schedule has moved past this instant and the slot will never run — a turn
|
|
41
|
+
* missing from the bill, which reads differently in a log.
|
|
42
|
+
*/
|
|
43
|
+
export type SlotClaimOutcome = {
|
|
44
|
+
taken: true;
|
|
45
|
+
}
|
|
46
|
+
/** The same delivery arrived twice, or another scheduler took this slot and may be running it now. */
|
|
47
|
+
| {
|
|
48
|
+
taken: false;
|
|
49
|
+
why: "duplicate";
|
|
50
|
+
}
|
|
51
|
+
/** The schedule has moved past this instant: `newest` holds it, and this slot will never run. */
|
|
52
|
+
| {
|
|
53
|
+
taken: false;
|
|
54
|
+
why: "stale";
|
|
55
|
+
newest: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Take a cron slot, or report that it is not ours to take.
|
|
59
|
+
*
|
|
60
|
+
* `O_EXCL` is the whole mechanism: creating the file IS the decision, and the kernel gives it to exactly one creator,
|
|
61
|
+
* even with a second scheduler on the same state (two `start`s, a restart overlapping its predecessor, an external
|
|
62
|
+
* clock racing the resident one).
|
|
63
|
+
*
|
|
64
|
+
* Two ways to lose it, and both must hold beyond the pruning window: the slot's own claim exists, or a LATER slot has
|
|
65
|
+
* already been claimed — a delivery older than the newest claim is a stale replay (AWS keeps retrying an event for up
|
|
66
|
+
* to 24h), and firing it would bill a turn for an instant the schedule has already moved past.
|
|
67
|
+
*
|
|
68
|
+
* The claim carries the wall-clock instant it was taken (as `{"firedAt":"…"}`, the shape `settleClaim` writes the
|
|
69
|
+
* outcome back into), so the next boot can tell a fire that never reported from one that did
|
|
70
|
+
* (`markInterruptedFire`) without depending on a second file being written after it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function claimSlot(stateRoot: string, name: string, slot: Date, firedAt: Date): SlotClaimOutcome;
|
|
73
|
+
/**
|
|
74
|
+
* Every fire this state root still keeps for `name`, oldest first — bounded by `KEEP_CLAIMS` because the claims ARE
|
|
75
|
+
* the history. Nothing here grows: the pruning that keeps the claim gate cheap keeps the history's size fixed too,
|
|
76
|
+
* which is why there is no separate audit file to rotate (what the turn SAID is in its session, not here).
|
|
77
|
+
*
|
|
78
|
+
* THE HISTORY read, and its only caller is the read-only `routine history` — which translates a read fault into a
|
|
79
|
+
* one-line refusal. The serving boot reads `latestFire` instead: it asks about ONE claim, and a window of files kept
|
|
80
|
+
* for an operator to look at has no business deciding whether a service starts.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readFires(stateRoot: string, name: string): Fire[];
|
|
83
|
+
/**
|
|
84
|
+
* The NEWEST fire recorded for `name` — the one claim the serving boot reads, for the two planes that must agree
|
|
85
|
+
* about it: where catch-up resumes (`firedAt`) and whether that fire ever reported (`outcome`). Deriving those
|
|
86
|
+
* separately is how they come to disagree about the same file.
|
|
87
|
+
*
|
|
88
|
+
* Only the newest matters to either. A schedule's resident loop runs one turn at a time, so it can leave at most one
|
|
89
|
+
* unsettled claim behind; an older unsettled one needs a second writer, and the second-writer topology (an external
|
|
90
|
+
* clock) skips the reconciler entirely. Reading the whole retained window here would instead make a file that is
|
|
91
|
+
* 400 fires old — kept only so an operator can look at it — able to fail a boot.
|
|
92
|
+
*
|
|
93
|
+
* A read fault on THIS claim stays fatal: it decides the stale gate and the resume point, and running blind on it
|
|
94
|
+
* fires a slot twice or never.
|
|
95
|
+
*/
|
|
96
|
+
export declare function latestFire(stateRoot: string, name: string): Fire | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Write back how the claimed fire ended, into the claim file itself.
|
|
99
|
+
*
|
|
100
|
+
* The SAME file, because a second one would reintroduce the window this whole design closes: a killed process would
|
|
101
|
+
* leave a claimed slot that nothing accounts for. An unsettled claim IS the record of an interrupted fire.
|
|
102
|
+
*
|
|
103
|
+
* NOT `writeFileAtomic`: its temp would land in the directory `claimSlot` lists (`<slot>.tmp` is not a claim name,
|
|
104
|
+
* so `listClaims` skips a leftover one, but the rename would still be a second writer of the same directory for no
|
|
105
|
+
* gain) — and a rename would re-create a name a concurrent prune had just removed, which the open below exists to
|
|
106
|
+
* prevent. A torn write degrades the way an unusable stamp already does (`parseClaim`) — the slot is re-read as
|
|
107
|
+
* unsettled, which is visible, not lost.
|
|
108
|
+
*/
|
|
109
|
+
export declare function settleClaim(stateRoot: string, name: string, slot: Date, outcome: FireOutcome, ms?: number): void;
|
package/dist/schedule/state.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
* files, atomic (tmp+rename) so a crash never leaves a torn file:
|
|
4
|
-
* - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
|
|
5
|
-
* - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
|
|
6
|
-
* No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
|
|
7
|
-
*/
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
/** Durable scheduler state under `<stateRoot>/schedule/`. */
|
|
2
|
+
import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
|
|
9
3
|
import { join } from "node:path";
|
|
10
4
|
import { writeFileAtomic } from "../atomic-write.js";
|
|
11
5
|
import { log } from "../log.js";
|
|
@@ -13,12 +7,6 @@ import { log } from "../log.js";
|
|
|
13
7
|
export function scheduleFile(stateRoot, name) {
|
|
14
8
|
return join(stateRoot, "schedule", `${name}.json`);
|
|
15
9
|
}
|
|
16
|
-
/**
|
|
17
|
-
* Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
|
|
18
|
-
* (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
|
|
19
|
-
* file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
|
|
20
|
-
* state. The caller owns shape validation (an IO boundary).
|
|
21
|
-
*/
|
|
22
10
|
export function readScheduleFile(path) {
|
|
23
11
|
let raw;
|
|
24
12
|
try {
|
|
@@ -42,10 +30,296 @@ export function readScheduleFile(path) {
|
|
|
42
30
|
export function writeScheduleFile(path, value) {
|
|
43
31
|
writeFileAtomic(path, JSON.stringify(value));
|
|
44
32
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
// ── claims/: one file per fired slot — the DECISION to fire, made atomically ──
|
|
34
|
+
/**
|
|
35
|
+
* How many claims to keep per schedule — and therefore how far back `fastagent routine history` can see, since
|
|
36
|
+
* the claims ARE the history. The gate below reads only the newest, so this number answers the history's question,
|
|
37
|
+
* "did last night's run silently fail?": at 512 a minute cron keeps ~8.5 hours, an hourly one ~3 weeks.
|
|
38
|
+
*
|
|
39
|
+
* Raise it by FILE COUNT, not by byte count: 512 short lines are ~25 KB of content but ~2 MiB of blocks and 512
|
|
40
|
+
* inodes per schedule on a 4 KiB-block filesystem. Pruning removes the OLDEST names only, so the newest claim never
|
|
41
|
+
* goes — which is what keeps the gate working past the window.
|
|
42
|
+
*/
|
|
43
|
+
const KEEP_CLAIMS = 512;
|
|
44
|
+
/** A slot instant as a filename (ISO minus the characters a path cannot carry); sorts in slot order. */
|
|
45
|
+
const claimName = (slot) => slot.toISOString().replace(/[:.]/g, "-");
|
|
46
|
+
/** The instant a claim's file name stands for — `claimName` read backwards. */
|
|
47
|
+
const slotInstant = (name) => name.replace(/-(\d{2})-(\d{2})-(\d{3})Z$/, ":$1:$2.$3Z");
|
|
48
|
+
/**
|
|
49
|
+
* Is this file name one `claimName` produced? Defined by the round trip, so the answer is exactly "this is a slot
|
|
50
|
+
* instant this code could have written", with no second spelling to keep in sync.
|
|
51
|
+
*
|
|
52
|
+
* It has to be asked because a claims directory is a directory, and a foreign name breaks things two ways. A name
|
|
53
|
+
* that sorts AFTER every real claim (`<slot>.tmp`, an editor's `<slot>~`) would be read as the newest one — which
|
|
54
|
+
* decides whether the next slot is refused as stale, where catch-up resumes, and which fire the boot reconciler
|
|
55
|
+
* settles. Any other name (`.DS_Store`, which sorts before all of them) cannot be turned back into a slot instant,
|
|
56
|
+
* and the reconciler's `new Date(fire.slot)` on it throws `RangeError: Invalid time value` out of `start()`.
|
|
57
|
+
*/
|
|
58
|
+
const isClaimName = (name) => {
|
|
59
|
+
const instant = Date.parse(slotInstant(name));
|
|
60
|
+
return !Number.isNaN(instant) && claimName(new Date(instant)) === name;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* THE listing of a claims directory: claim files only, oldest slot first. Every reader goes through it — the gate,
|
|
64
|
+
* the history, and the pruning — so "what counts as a claim" is decided once instead of by whoever reads next.
|
|
65
|
+
*/
|
|
66
|
+
function listClaims(dir) {
|
|
67
|
+
let names;
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(dir);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
return []; // never fired here yet
|
|
74
|
+
throw e; // unreadable state: the boot fails on it rather than running blind
|
|
75
|
+
}
|
|
76
|
+
const claims = [];
|
|
77
|
+
for (const name of names.sort()) {
|
|
78
|
+
if (isClaimName(name))
|
|
79
|
+
claims.push(name);
|
|
80
|
+
// Not an error of ours and not the operator's to fix on a schedule's behalf (macOS writes `.DS_Store` into any
|
|
81
|
+
// directory someone opens), so it is skipped at debug level rather than warned about on every single fire.
|
|
82
|
+
else
|
|
83
|
+
log.debug(`[schedule] ignoring ${join(dir, name)}: not a fired-slot claim`);
|
|
84
|
+
}
|
|
85
|
+
return claims;
|
|
48
86
|
}
|
|
49
|
-
|
|
50
|
-
|
|
87
|
+
const isOutcome = (s) => s === "completed" || s === "failed" || s === "skipped" || s === "interrupted";
|
|
88
|
+
/**
|
|
89
|
+
* Read one claim file: `{"firedAt":"…"}`, plus `outcome` and `ms` once the turn has reported.
|
|
90
|
+
*
|
|
91
|
+
* JSON, and not a line this module splits itself, because the write is deliberately not atomic (`settleClaim` may
|
|
92
|
+
* not leave a temp file in the directory `claimSlot` lists). A torn write must therefore be DETECTABLE, and that is
|
|
93
|
+
* exactly what JSON gives for free: half an object does not parse, so a partially written claim reads as unsettled
|
|
94
|
+
* — the safe state this design already handles — instead of as a record whose third field happened to look like a
|
|
95
|
+
* number. The positional form cost two shipped bugs of that shape (a torn duration read as `0ms`, an absent one
|
|
96
|
+
* coerced to the same), and the next field anyone adds would inherit them.
|
|
97
|
+
*
|
|
98
|
+
* Anything unusable — a torn write, a missing stamp, a date that is not one — falls back to the slot instant in the
|
|
99
|
+
* file name. That is the earliest moment the fire can have happened, so the only degradation is catching up one run
|
|
100
|
+
* that already ran: too many rather than too few.
|
|
101
|
+
*/
|
|
102
|
+
function parseClaim(raw, dir, name) {
|
|
103
|
+
const fire = { slot: slotInstant(name), firedAt: slotInstant(name) };
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(raw);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
parsed = undefined;
|
|
110
|
+
}
|
|
111
|
+
// `null` and a bare `12` parse FINE and are not records — destructuring them throws, and this runs on the
|
|
112
|
+
// synchronous boot path, so the throw would be a service that does not start over one unreadable file. Empty is
|
|
113
|
+
// ordinary (the process died between `openSync` creating the claim and the stamp being written), and so is an
|
|
114
|
+
// earlier format's bare timestamp, which is why the rest is debug: the consequence is already visible as a fire
|
|
115
|
+
// that reads `unreported`.
|
|
116
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
117
|
+
if (raw.trim() !== "") {
|
|
118
|
+
log.debug(`[schedule] claim ${join(dir, name)} is not a claim record (torn write, or an earlier format)`);
|
|
119
|
+
}
|
|
120
|
+
return fire;
|
|
121
|
+
}
|
|
122
|
+
const { firedAt, outcome, ms } = parsed;
|
|
123
|
+
if (typeof firedAt === "string" && !Number.isNaN(Date.parse(firedAt)))
|
|
124
|
+
fire.firedAt = firedAt;
|
|
125
|
+
else
|
|
126
|
+
log.warn(`[schedule] claim ${join(dir, name)} carries an unreadable stamp — using the slot instant`);
|
|
127
|
+
if (isOutcome(outcome)) {
|
|
128
|
+
fire.outcome = outcome;
|
|
129
|
+
// A settled fire may carry NO duration: nobody timed an `interrupted` one (see `settleClaim`). Absent stays
|
|
130
|
+
// absent — `0` would print as a turn that really did finish instantly, the one value this record will not invent.
|
|
131
|
+
if (typeof ms === "number" && Number.isFinite(ms))
|
|
132
|
+
fire.ms = ms;
|
|
133
|
+
}
|
|
134
|
+
else if (outcome !== undefined) {
|
|
135
|
+
// A word we do not know is not an outcome: the fire reads as unsettled, which the next boot reports.
|
|
136
|
+
log.warn(`[schedule] claim ${join(dir, name)} carries an unknown outcome — reading it as unsettled: ${outcome}`);
|
|
137
|
+
}
|
|
138
|
+
return fire;
|
|
139
|
+
}
|
|
140
|
+
function readClaim(dir, name) {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = readFileSync(join(dir, name), "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e.code === "ENOENT")
|
|
147
|
+
return undefined;
|
|
148
|
+
throw e; // unreadable state (EACCES, EIO): the boot fails on it rather than running blind
|
|
149
|
+
}
|
|
150
|
+
return parseClaim(raw, dir, name);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A schedule name becomes a path segment (its claims live in `claims/<name>/`), so the rule is exactly the safety
|
|
154
|
+
* boundary and nothing more: a name may not leave that directory. The name comes from a filename under `routines/`,
|
|
155
|
+
* which already cannot contain a separator — so what a stricter rule would actually reject is legal filenames
|
|
156
|
+
* (`每日简报`, `my schedule`), whose only symptom would be a schedule that silently never fires again.
|
|
157
|
+
*
|
|
158
|
+
* One definition, two enforcers: `discover.ts` refuses an illegal one where the author can see which file is wrong,
|
|
159
|
+
* and `claimDir` asserts it again for a programmatic caller.
|
|
160
|
+
*/
|
|
161
|
+
export function isSafeScheduleName(name) {
|
|
162
|
+
return name !== "" && !/[/\\]/.test(name) && !name.includes("\u0000") && name !== "." && name !== "..";
|
|
163
|
+
}
|
|
164
|
+
/** This function builds a path from it, so a name that can leave `claims/<name>/` is a bug, not an input. */
|
|
165
|
+
function claimDir(stateRoot, name) {
|
|
166
|
+
if (!isSafeScheduleName(name)) {
|
|
167
|
+
throw new Error(`refusing to build a claim path for the unsafe schedule name ${JSON.stringify(name)}`);
|
|
168
|
+
}
|
|
169
|
+
return join(stateRoot, "schedule", "claims", name);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Take a cron slot, or report that it is not ours to take.
|
|
173
|
+
*
|
|
174
|
+
* `O_EXCL` is the whole mechanism: creating the file IS the decision, and the kernel gives it to exactly one creator,
|
|
175
|
+
* even with a second scheduler on the same state (two `start`s, a restart overlapping its predecessor, an external
|
|
176
|
+
* clock racing the resident one).
|
|
177
|
+
*
|
|
178
|
+
* Two ways to lose it, and both must hold beyond the pruning window: the slot's own claim exists, or a LATER slot has
|
|
179
|
+
* already been claimed — a delivery older than the newest claim is a stale replay (AWS keeps retrying an event for up
|
|
180
|
+
* to 24h), and firing it would bill a turn for an instant the schedule has already moved past.
|
|
181
|
+
*
|
|
182
|
+
* The claim carries the wall-clock instant it was taken (as `{"firedAt":"…"}`, the shape `settleClaim` writes the
|
|
183
|
+
* outcome back into), so the next boot can tell a fire that never reported from one that did
|
|
184
|
+
* (`markInterruptedFire`) without depending on a second file being written after it.
|
|
185
|
+
*/
|
|
186
|
+
export function claimSlot(stateRoot, name, slot, firedAt) {
|
|
187
|
+
const dir = claimDir(stateRoot, name);
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
const taken = listClaims(dir);
|
|
190
|
+
const wanted = claimName(slot);
|
|
191
|
+
const newest = taken.at(-1);
|
|
192
|
+
if (newest !== undefined && wanted < newest)
|
|
193
|
+
return { taken: false, why: "stale", newest };
|
|
194
|
+
let fd;
|
|
195
|
+
try {
|
|
196
|
+
fd = openSync(join(dir, wanted), "wx");
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (e.code === "EEXIST")
|
|
200
|
+
return { taken: false, why: "duplicate" };
|
|
201
|
+
throw e; // a real IO fault: the caller reports it and leaves the schedule armed
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
writeFileSync(fd, JSON.stringify({ firedAt: firedAt.toISOString() }));
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
// The claim exists from `openSync` on, so a failed stamp (ENOSPC, EIO) would leave a slot that can only ever be
|
|
208
|
+
// read as `duplicate` — taken, never run, never reported. Remove it so the failure this rethrows costs a retry
|
|
209
|
+
// instead of the slot itself. The cleanup's own failure is reported but never replaces `e`: the caller needs
|
|
210
|
+
// the reason the write failed, not the reason the rollback did.
|
|
211
|
+
try {
|
|
212
|
+
closeSync(fd);
|
|
213
|
+
unlinkSync(join(dir, wanted));
|
|
214
|
+
}
|
|
215
|
+
catch (cleanup) {
|
|
216
|
+
log.warn(`[schedule] ${name}: could not remove the unstamped claim ${wanted} — that slot is now taken but will never run: ${String(cleanup)}`);
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
closeSync(fd);
|
|
221
|
+
pruneClaims(dir, taken);
|
|
222
|
+
return { taken: true };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Every fire this state root still keeps for `name`, oldest first — bounded by `KEEP_CLAIMS` because the claims ARE
|
|
226
|
+
* the history. Nothing here grows: the pruning that keeps the claim gate cheap keeps the history's size fixed too,
|
|
227
|
+
* which is why there is no separate audit file to rotate (what the turn SAID is in its session, not here).
|
|
228
|
+
*
|
|
229
|
+
* THE HISTORY read, and its only caller is the read-only `routine history` — which translates a read fault into a
|
|
230
|
+
* one-line refusal. The serving boot reads `latestFire` instead: it asks about ONE claim, and a window of files kept
|
|
231
|
+
* for an operator to look at has no business deciding whether a service starts.
|
|
232
|
+
*/
|
|
233
|
+
export function readFires(stateRoot, name) {
|
|
234
|
+
const dir = claimDir(stateRoot, name);
|
|
235
|
+
// A claim pruned between the listing and the read is not a fire this state root still keeps, so it is dropped
|
|
236
|
+
// rather than reported as an unsettled one — which is what `unreported` would claim about a run nobody skipped.
|
|
237
|
+
return listClaims(dir).flatMap((slot) => readClaim(dir, slot) ?? []);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The NEWEST fire recorded for `name` — the one claim the serving boot reads, for the two planes that must agree
|
|
241
|
+
* about it: where catch-up resumes (`firedAt`) and whether that fire ever reported (`outcome`). Deriving those
|
|
242
|
+
* separately is how they come to disagree about the same file.
|
|
243
|
+
*
|
|
244
|
+
* Only the newest matters to either. A schedule's resident loop runs one turn at a time, so it can leave at most one
|
|
245
|
+
* unsettled claim behind; an older unsettled one needs a second writer, and the second-writer topology (an external
|
|
246
|
+
* clock) skips the reconciler entirely. Reading the whole retained window here would instead make a file that is
|
|
247
|
+
* 400 fires old — kept only so an operator can look at it — able to fail a boot.
|
|
248
|
+
*
|
|
249
|
+
* A read fault on THIS claim stays fatal: it decides the stale gate and the resume point, and running blind on it
|
|
250
|
+
* fires a slot twice or never.
|
|
251
|
+
*/
|
|
252
|
+
export function latestFire(stateRoot, name) {
|
|
253
|
+
const dir = claimDir(stateRoot, name);
|
|
254
|
+
for (const slot of listClaims(dir).reverse()) {
|
|
255
|
+
// `undefined` = pruned between the listing and the read; the one before it is then the newest that still exists.
|
|
256
|
+
const fire = readClaim(dir, slot);
|
|
257
|
+
if (fire !== undefined)
|
|
258
|
+
return fire;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Write back how the claimed fire ended, into the claim file itself.
|
|
264
|
+
*
|
|
265
|
+
* The SAME file, because a second one would reintroduce the window this whole design closes: a killed process would
|
|
266
|
+
* leave a claimed slot that nothing accounts for. An unsettled claim IS the record of an interrupted fire.
|
|
267
|
+
*
|
|
268
|
+
* NOT `writeFileAtomic`: its temp would land in the directory `claimSlot` lists (`<slot>.tmp` is not a claim name,
|
|
269
|
+
* so `listClaims` skips a leftover one, but the rename would still be a second writer of the same directory for no
|
|
270
|
+
* gain) — and a rename would re-create a name a concurrent prune had just removed, which the open below exists to
|
|
271
|
+
* prevent. A torn write degrades the way an unusable stamp already does (`parseClaim`) — the slot is re-read as
|
|
272
|
+
* unsettled, which is visible, not lost.
|
|
273
|
+
*/
|
|
274
|
+
export function settleClaim(stateRoot, name, slot, outcome, ms) {
|
|
275
|
+
const dir = claimDir(stateRoot, name);
|
|
276
|
+
const file = claimName(slot);
|
|
277
|
+
let fd;
|
|
278
|
+
try {
|
|
279
|
+
// `r+`, so this can only ever write a claim that EXISTS. A concurrent `claimSlot` may prune this slot while its
|
|
280
|
+
// turn is still running; reading first and then writing by path would put the pruned file back — a fire in the
|
|
281
|
+
// history that this state root had already decided to forget. Held open, a later unlink leaves the writes on a
|
|
282
|
+
// dead inode instead, which is exactly the outcome being dropped along with the slot.
|
|
283
|
+
fd = openSync(join(dir, file), "r+");
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
if (e.code === "ENOENT")
|
|
287
|
+
return; // pruned: the outcome goes with the slot, silently
|
|
288
|
+
// EACCES/EIO. The fire happened; killing the schedule's loop over its bookkeeping would cost every later fire
|
|
289
|
+
// too, so this is reported instead. The outcome is then lost: the slot stays `unreported` in the history, and
|
|
290
|
+
// the next boot settles it as `interrupted` ONLY if it is still the newest claim by then (`markInterruptedFire`
|
|
291
|
+
// reads that one) — a later fire on the same schedule leaves it unreported for good.
|
|
292
|
+
log.warn(`[schedule] ${name}: could not open the claim for slot ${file} to settle it: ${String(e)}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
// The stamp is preserved, not rewritten: `firedAt` is what catch-up resumes from. A duration is written only
|
|
297
|
+
// when one was measured: nobody timed an `interrupted` fire, and writing `0` for it would print as a turn that
|
|
298
|
+
// took no time — the same false value the history deliberately leaves blank.
|
|
299
|
+
const claimed = parseClaim(readFileSync(fd, "utf8"), dir, file);
|
|
300
|
+
const record = { firedAt: claimed.firedAt, outcome, ...(ms === undefined ? {} : { ms: Math.round(ms) }) };
|
|
301
|
+
// Truncate first: a shorter record would otherwise leave the old tail behind. With JSON that tail can only
|
|
302
|
+
// produce a parse failure rather than a plausible field, but a file that says one thing is still worth having.
|
|
303
|
+
ftruncateSync(fd, 0);
|
|
304
|
+
writeSync(fd, JSON.stringify(record), 0);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
// Housekeeping: the turn itself already happened. The outcome is lost the same way the open failure above
|
|
308
|
+
// loses it — `unreported` in the history, settled as `interrupted` by the next boot only while it is still the
|
|
309
|
+
// newest claim.
|
|
310
|
+
log.warn(`[schedule] ${name}: could not record the ${outcome} outcome of slot ${file}: ${String(e)}`);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
closeSync(fd);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function pruneClaims(dir, taken) {
|
|
317
|
+
try {
|
|
318
|
+
for (const stale of taken.slice(0, Math.max(0, taken.length + 1 - KEEP_CLAIMS)))
|
|
319
|
+
unlinkSync(join(dir, stale));
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
// Pruning is housekeeping: a failure leaves files behind, never an unclaimed slot.
|
|
323
|
+
log.warn(`[schedule] could not prune fired-slot claims in ${dir}: ${String(e)}`);
|
|
324
|
+
}
|
|
51
325
|
}
|
|
@@ -1,36 +1,23 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
1
2
|
import { type WakeAlarm } from "../channels/agentcore-protocol.ts";
|
|
2
3
|
import { type Wakeup } from "./wakeups.ts";
|
|
3
4
|
/**
|
|
4
|
-
* Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on
|
|
5
|
-
*
|
|
6
|
-
* container whose FIRST action is a wake fire (recurring advance → save → sink) knows the URL
|
|
7
|
-
* before any envelope of its own has arrived.
|
|
5
|
+
* Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on every turn, the
|
|
6
|
+
* file should not churn).
|
|
8
7
|
*/
|
|
9
8
|
export declare function rememberWakeAlarmUrl(stateRoot: string, url: string): void;
|
|
10
9
|
/** The persisted forwarder URL, or undefined before the first envelope ever seen. */
|
|
11
10
|
export declare function readWakeAlarmUrl(stateRoot: string): string | undefined;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
/**
|
|
12
|
+
* How many times one alarm sync is retried before giving up (each store mutation and every boot restart the cycle, and
|
|
13
|
+
* the pending store is the durable desired state — so "give up" means "until the next mirror", never "lost").
|
|
14
|
+
*/
|
|
15
15
|
export declare const MAX_SYNC_ATTEMPTS = 5;
|
|
16
16
|
/** Pending wake-ups → the desired alarm set, minus already-due entries (see {@link DUE_MARGIN_MS}). */
|
|
17
17
|
export declare function toAlarms(pending: Wakeup[], now: Date): WakeAlarm[];
|
|
18
18
|
/**
|
|
19
|
-
* Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start`
|
|
20
|
-
*
|
|
21
|
-
* failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
|
|
22
|
-
* still fires on the next time the box happens to be awake), it must never break the store write.
|
|
23
|
-
*
|
|
24
|
-
* A single-flight RECONCILER, not a per-mutation delivery: a notification only marks the desired
|
|
25
|
-
* state dirty, and the loop re-derives it from the store before every attempt. So a mutation during
|
|
26
|
-
* a retry needs no ordering rule — there is one desired state, never two snapshots to rank — and the
|
|
27
|
-
* {@link DUE_MARGIN_MS} filter is re-applied with a fresh clock, which a captured payload could not
|
|
28
|
-
* do: entries that became due mid-retry would keep being POSTed with a past `at()`, a rejection no
|
|
29
|
-
* number of retries could clear.
|
|
30
|
-
*
|
|
31
|
-
* Assumes ONE sink per process over ONE state root (what `start` builds): the loop keeps the root of
|
|
32
|
-
* the call that started it, and its dirty flag is shared, so a second root would fold into the first
|
|
33
|
-
* one's pass.
|
|
19
|
+
* Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start` when
|
|
20
|
+
* `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present).
|
|
34
21
|
*/
|
|
35
22
|
export declare function createWakeAlarmSink(options: {
|
|
36
23
|
secret: string;
|
|
@@ -39,4 +26,4 @@ export declare function createWakeAlarmSink(options: {
|
|
|
39
26
|
now?: () => Date;
|
|
40
27
|
/** Injectable retry pause (tests); defaults to exponential-ish backoff off RETRY_BASE_MS. */
|
|
41
28
|
delay?: (ms: number) => Promise<void>;
|
|
42
|
-
}): (stateRoot: string) => void
|
|
29
|
+
}): Effect.Effect<(stateRoot: string) => void>;
|