@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
|
@@ -1,286 +1,312 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Fire model:
|
|
7
|
-
* - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
|
|
8
|
-
* conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
|
|
9
|
-
* - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
|
|
10
|
-
* - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
|
|
11
|
-
* already past (the process was down across it), fire once and advance — not once per missed slot.
|
|
12
|
-
* lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
|
|
13
|
-
* "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
|
|
14
|
-
*/
|
|
1
|
+
/** Resident cron and wake polling. */
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Clock from "effect/Clock";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
15
5
|
import { SESSION_BUSY_CODE } from "../agent.js";
|
|
6
|
+
import { PortFailure } from "../effect-port.js";
|
|
16
7
|
import { beginWork } from "../channels/busy.js";
|
|
17
8
|
import { log } from "../log.js";
|
|
18
|
-
import { appendRun } from "./audit.js";
|
|
19
9
|
import { nextRun } from "./cron.js";
|
|
20
|
-
import {
|
|
10
|
+
import { routineSession } from "./routine.js";
|
|
11
|
+
import { claimSlot, latestFire, settleClaim } from "./state.js";
|
|
21
12
|
import { deferWakeup, takeFirstDueWakeup } from "./wakeups.js";
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Account for a claim whose turn never reported. The slot is claimed BEFORE the turn runs and shutdown does not wait
|
|
15
|
+
* for that turn (see `AgentService.close`), so a restart landing mid-fire leaves a claim that the next boot skips —
|
|
16
|
+
* the exact silence the history exists to prevent. Settled once, at the next boot, which also makes it idempotent.
|
|
17
|
+
*
|
|
18
|
+
* The claim is the ONE record here: it IS the decision, and the outcome is written back into it.
|
|
19
|
+
*
|
|
20
|
+
* NOT re-fired: a turn that kills its own process would then replay on every boot. Cron only: a killed wake-up
|
|
21
|
+
* leaves no claim behind to reconcile (`takeFirstDueWakeup` removes it before the turn starts).
|
|
22
|
+
*
|
|
23
|
+
* THE NEWEST CLAIM ONLY. A schedule's loop runs one turn at a time, so a killed process leaves exactly one claim
|
|
24
|
+
* unsettled — the one it was in. Walking the whole retained window would add nothing a resident process can
|
|
25
|
+
* produce, and would instead rewrite every claim an OLDER FORMAT left without an outcome, turning a window of runs
|
|
26
|
+
* that actually succeeded into `interrupted`.
|
|
27
|
+
*
|
|
28
|
+
* KNOWN FALSE POSITIVE: a second scheduler booting while the first is mid-turn sees a claim nothing accounts for
|
|
29
|
+
* YET, and reports it; the first process then overwrites the outcome when its turn settles. Telling them apart needs
|
|
30
|
+
* the claimer's liveness, which is a lease, not a claim — and the whole point of the claim is that it needs none.
|
|
31
|
+
*/
|
|
32
|
+
function markInterruptedFire(stateRoot, name, fire) {
|
|
33
|
+
if (fire === undefined || fire.outcome !== undefined)
|
|
34
|
+
return;
|
|
35
|
+
log.warn(`[schedule] ${name}: the fire claimed at ${fire.firedAt} never finished — the process stopped mid-turn ` +
|
|
36
|
+
`and that slot stays skipped (see \`fastagent routine history ${name}\`)`);
|
|
37
|
+
// `fire.slot` came from a claim file name, which `listClaims` admits only when it round-trips through this same
|
|
38
|
+
// conversion — so the Date is valid and `settleClaim` writes back the file it was read from. No duration: this
|
|
39
|
+
// turn was never timed, and the process that could have timed it is gone.
|
|
40
|
+
settleClaim(stateRoot, name, new Date(fire.slot), "interrupted");
|
|
26
41
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 };
|
|
59
|
-
}
|
|
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
|
-
}
|
|
42
|
+
/**
|
|
43
|
+
* The longest a single armed wait sleeps before looking at the wall clock again.
|
|
44
|
+
*
|
|
45
|
+
* WHAT IT BUYS, and only this: a wait LONGER than one cap (a daily, weekly or monthly cron) re-reads the wall
|
|
46
|
+
* clock every 6 hours, so a clock that jumped forward is noticed within that, instead of after the whole
|
|
47
|
+
* original delay. MEASURED (2026-09-22): the same jump under a wait SHORTER than the cap changes nothing — a
|
|
48
|
+
* minute cron whose wall clock jumped 10 minutes past its slot still fired at t+51.4s, when its one timer was
|
|
49
|
+
* always going to. So this is not a resume guarantee and must not be read as one; it bounds lateness for the
|
|
50
|
+
* long waits at 6 hours.
|
|
51
|
+
*
|
|
52
|
+
* NOT about `setTimeout`'s 2^31-1 ms ceiling: `Effect.sleep` already splits a longer delay by itself
|
|
53
|
+
* (effect/src/internal/effect.ts, `sleepMillis`).
|
|
54
|
+
*/
|
|
55
|
+
const MAX_WAIT_MS = 6 * 60 * 60 * 1000;
|
|
56
|
+
const WAKEUP_POLL_MS = 30_000;
|
|
57
|
+
/**
|
|
58
|
+
* ONE LINE, ALWAYS: the tail a failure contributes to its log line, as `: <text>`, or nothing when there is none.
|
|
59
|
+
*
|
|
60
|
+
* `log.ts` prefixes only the first line, so a newline anywhere in here emits a second line that is byte-for-byte a
|
|
61
|
+
* real record (`ERROR [schedule] daily failed (1ms): DISK ON FIRE`) — and a failure detail can carry model or
|
|
62
|
+
* provider text. Folding also keeps the line-oriented consumers — `docker logs`, journald, CloudWatch — reading one
|
|
63
|
+
* fire as one event.
|
|
64
|
+
*/
|
|
65
|
+
function oneLine(text) {
|
|
66
|
+
const folded = text.replace(/\s+/g, " ").trim();
|
|
67
|
+
return folded === "" ? "" : `: ${folded}`;
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
68
|
-
*
|
|
69
|
-
* re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
|
|
70
|
-
* serving surface (the AgentCore adapter).
|
|
70
|
+
* The iterator is a Promise port inside an uninterruptible claimed occurrence, including its cleanup.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
72
|
+
* WHAT THE TURN SAID IS NOT LOGGED. It is already durable: every fire runs in the session named in the line below
|
|
73
|
+
* (`routine:<name>` for a cron, the asking conversation for a wake-up), and a session is persisted under
|
|
74
|
+
* EXPORTED for the API path (`POST /run`), which runs the same turn without a claim: it has no occurrence to
|
|
75
|
+
* claim, because nobody's grid produced it (schedule/run.ts). The resident clock's own claim/run/settle is
|
|
76
|
+
* {@link fireRoutineOnce}.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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).
|
|
78
|
+
* `<stateRoot>/sessions/` like any other. Copying the reply into the log would be a second store of the same text,
|
|
79
|
+
* unstructured, in a stream with a wider audience and a bound that differs per host.
|
|
82
80
|
*/
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
export function runTurn(agent, label, session, prompt) {
|
|
82
|
+
return Effect.gen(function* () {
|
|
83
|
+
const clock = yield* Clock.Clock;
|
|
84
|
+
const startedAt = clock.currentTimeMillisUnsafe();
|
|
85
|
+
const elapsed = () => clock.currentTimeMillisUnsafe() - startedAt;
|
|
86
|
+
log.info(`[schedule] ${label} firing (session=${session})`);
|
|
87
|
+
return yield* Effect.tryPromise({
|
|
88
|
+
try: async () => {
|
|
89
|
+
let failed;
|
|
90
|
+
let busy = false;
|
|
91
|
+
// The text deltas are iterated and dropped. Accumulating them would be a second copy of what the session
|
|
92
|
+
// already holds, and no caller here reads it — the outcome is the whole record (docs/design/core.md §8).
|
|
93
|
+
for await (const e of agent.invoke({ session }, { text: prompt })) {
|
|
94
|
+
if (e.type === "failed") {
|
|
95
|
+
failed = e.details;
|
|
96
|
+
busy = e.code === SESSION_BUSY_CODE;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { busy: failed !== undefined && busy, failed };
|
|
100
|
+
},
|
|
101
|
+
catch: (cause) => new PortFailure(cause),
|
|
102
|
+
}).pipe(Effect.match({
|
|
103
|
+
onSuccess: (result) => {
|
|
104
|
+
// BUSY IS NOT A FAILURE, and this line is where that distinction has to land: the claim file and the
|
|
105
|
+
// route's reply both say `skipped`, but `docker logs` / CloudWatch is the first place anyone looks, and
|
|
106
|
+
// it kept saying `ERROR … failed: busy`. "The previous turn is still running" and "the model call died"
|
|
107
|
+
// need different reactions — a session-busy rejection carries `failed` only because that is the SPEC's
|
|
108
|
+
// one terminal event shape, which is a wire detail, not a verdict.
|
|
109
|
+
if (result.busy)
|
|
110
|
+
log.info(`[schedule] ${label}: occurrence skipped (session busy) (${elapsed()}ms)`);
|
|
111
|
+
else if (result.failed)
|
|
112
|
+
log.error(`[schedule] ${label} failed (${elapsed()}ms)${oneLine(result.failed)}`);
|
|
113
|
+
else
|
|
114
|
+
log.info(`[schedule] ${label} completed (${elapsed()}ms)`);
|
|
115
|
+
return { ...result, ms: elapsed() };
|
|
116
|
+
},
|
|
117
|
+
onFailure: (error) => {
|
|
118
|
+
// An iterator throw violates SPEC MUST 2 and is never a replay-safe busy rejection.
|
|
119
|
+
log.error(`[schedule] ${label} errored (${elapsed()}ms)${oneLine(String(error.cause))}`);
|
|
120
|
+
return { busy: false, failed: String(error.cause), ms: elapsed() };
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
106
123
|
});
|
|
107
|
-
return { fired: true, failed: r.failed, ms: r.ms };
|
|
108
124
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const r = await runTurn(agent, label, w.session, wakeEnvelope(w)).finally(workDone);
|
|
152
|
-
// Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
|
|
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
|
|
154
|
-
// would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
|
|
155
|
-
// takeFirstDueWakeup), so a busy occurrence is simply SKIPPED and audited — the next one comes by
|
|
156
|
-
// definition, and never touching the stored entry here is what keeps unwake/cancel race-free.
|
|
157
|
-
let kept = false;
|
|
158
|
-
if (r.busy && !w.cron) {
|
|
159
|
-
kept = deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS));
|
|
160
|
-
if (kept)
|
|
161
|
-
log.info(`[schedule] ${label}: session busy — retrying next poll`);
|
|
162
|
-
else
|
|
163
|
-
log.error(`[schedule] ${label}: dropped after too many busy retries`);
|
|
164
|
-
}
|
|
165
|
-
else if (r.busy && w.cron) {
|
|
166
|
-
log.error(`[schedule] ${label}: occurrence skipped (session busy); next fires per cron`);
|
|
167
|
-
}
|
|
168
|
-
// Audit honesty: `deferred` ONLY when the same occurrence was actually re-scheduled. A busy one-shot
|
|
169
|
-
// dropped at the ceiling, and a busy recurring occurrence (skipped — its recurrence survives), are
|
|
170
|
-
// both FINAL for that occurrence → `failed`.
|
|
171
|
-
appendRun(stateRoot, {
|
|
172
|
-
name: "wake",
|
|
173
|
-
session: w.session,
|
|
174
|
-
firedAt,
|
|
125
|
+
/** Shared resident/external claim → run → settle. */
|
|
126
|
+
export function fireRoutineOnce(opts) {
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
const clock = yield* Clock.Clock;
|
|
129
|
+
const { agent, stateRoot, routine: s, slot, now = () => new Date(clock.currentTimeMillisUnsafe()) } = opts;
|
|
130
|
+
const firedAt = now();
|
|
131
|
+
const skippedReason = yield* Effect.try({
|
|
132
|
+
try: () => {
|
|
133
|
+
// The DECISION to fire is the claim, and it is atomic: several schedulers over one state root — two
|
|
134
|
+
// `start`s, a restart overlapping its predecessor, an external clock racing the resident one — take the
|
|
135
|
+
// same slot, and exactly one of them runs it. It is also the ONLY state write on this path, so a state
|
|
136
|
+
// failure cannot burn a slot that was already claimed.
|
|
137
|
+
const claim = claimSlot(stateRoot, s.name, slot, firedAt);
|
|
138
|
+
if (claim.taken)
|
|
139
|
+
return undefined;
|
|
140
|
+
if (claim.why === "duplicate") {
|
|
141
|
+
// Ordinary: the same delivery arrived twice, or another scheduler has this slot and is running it.
|
|
142
|
+
const reason = `slot ${slot.toISOString()} is already claimed — a duplicate delivery, or another scheduler has it`;
|
|
143
|
+
log.info(`[schedule] ${s.name}: skipping — ${reason}`);
|
|
144
|
+
return reason;
|
|
145
|
+
}
|
|
146
|
+
// Not ordinary: this instant will never run. The common way in is a wall clock that moved backwards (a VM
|
|
147
|
+
// resume, a host clock correction), which keeps producing slots behind the newest claim. No claim was taken,
|
|
148
|
+
// so there is nothing to record: it is a WARN where the benign duplicate is an info line.
|
|
149
|
+
const reason = `slot ${slot.toISOString()} is stale: ${claim.newest} was already claimed, so this instant is skipped`;
|
|
150
|
+
log.warn(`[schedule] ${s.name}: skipping — ${reason}`);
|
|
151
|
+
return reason;
|
|
152
|
+
},
|
|
153
|
+
catch: (cause) => new PortFailure(cause),
|
|
154
|
+
});
|
|
155
|
+
if (skippedReason !== undefined)
|
|
156
|
+
return { fired: false, skippedReason, ms: 0 };
|
|
157
|
+
const r = yield* runTurn(agent, s.name, routineSession(s.name), s.prompt);
|
|
158
|
+
// OVERLAP IS NOT FAILURE. The claim still stands — this occurrence is decided and will not be retried — but
|
|
159
|
+
// what decided it was the previous turn still holding `routine:<name>`, which is the policy every scheduler
|
|
160
|
+
// names (k8s `concurrencyPolicy: Forbid`, Temporal's `Skip`) and none of them reports as an error.
|
|
161
|
+
if (r.busy) {
|
|
162
|
+
settleClaim(stateRoot, s.name, slot, "skipped", r.ms);
|
|
163
|
+
return {
|
|
164
|
+
fired: false,
|
|
165
|
+
skippedReason: `the previous turn of "${s.name}" is still running — this occurrence is skipped, the next fires per cron`,
|
|
166
|
+
skipped: true,
|
|
175
167
|
ms: r.ms,
|
|
176
|
-
|
|
177
|
-
reply: r.failed || r.busy ? undefined : r.reply,
|
|
178
|
-
error: r.busy
|
|
179
|
-
? kept
|
|
180
|
-
? undefined
|
|
181
|
-
: w.cron
|
|
182
|
-
? "occurrence skipped (session busy); the recurrence continues"
|
|
183
|
-
: "dropped after too many busy retries"
|
|
184
|
-
: r.failed,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
/** Drain due wake-ups, then chain the next poll AFTER — never overlapping two drains. TOTAL: a state-IO
|
|
189
|
-
* fault (an unreadable store) is caught + logged and the chain continues, never a crash / a silent stop
|
|
190
|
-
* (this is `void`-scheduled, so an escaping throw would be an unhandled rejection). */
|
|
191
|
-
async function pumpWakeups() {
|
|
192
|
-
if (stopped)
|
|
193
|
-
return;
|
|
194
|
-
try {
|
|
195
|
-
await pollWakeups();
|
|
196
|
-
}
|
|
197
|
-
catch (e) {
|
|
198
|
-
log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(e)}`);
|
|
199
|
-
}
|
|
200
|
-
if (stopped)
|
|
201
|
-
return;
|
|
202
|
-
wakeupTimer = setTimeout(() => void pumpWakeups(), WAKEUP_POLL_MS);
|
|
203
|
-
}
|
|
204
|
-
/** Arm a timer for `at`, capped so a long wait re-checks the wall clock instead of trusting one sleep. */
|
|
205
|
-
function arm(s, at) {
|
|
206
|
-
if (stopped)
|
|
207
|
-
return;
|
|
208
|
-
const delay = Math.min(Math.max(0, at.getTime() - now().getTime()), MAX_WAIT_MS);
|
|
209
|
-
timers.set(s.name, setTimeout(() => {
|
|
210
|
-
if (stopped)
|
|
211
|
-
return;
|
|
212
|
-
if (now().getTime() >= at.getTime())
|
|
213
|
-
void fireThenReArm(s);
|
|
214
|
-
else
|
|
215
|
-
arm(s, at); // woke early (the cap) — keep waiting for the real instant
|
|
216
|
-
}, delay));
|
|
217
|
-
}
|
|
218
|
-
/** Fire, then arm the NEXT run computed from now — so a slow fire never double-fires the same slot and
|
|
219
|
-
* missed slots collapse to the single catch-up already done. TOTAL, like pumpWakeups: this is
|
|
220
|
-
* void-scheduled from a timer, so an escaping throw (a state-IO fault while claiming — an unreadable
|
|
221
|
-
* fires.json, an unpersistable claim) would be an unhandled rejection = the WHOLE service crashing over
|
|
222
|
-
* one skipped fire. Log it and keep the schedule armed instead; boot-time state faults still fail `start()`
|
|
223
|
-
* loudly (an operator fixes those before serving). */
|
|
224
|
-
async function fireThenReArm(s) {
|
|
225
|
-
try {
|
|
226
|
-
await fire(s);
|
|
168
|
+
};
|
|
227
169
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
170
|
+
settleClaim(stateRoot, s.name, slot, r.failed ? "failed" : "completed", r.ms);
|
|
171
|
+
return { fired: true, failed: r.failed, ms: r.ms };
|
|
172
|
+
}).pipe(Effect.uninterruptible);
|
|
173
|
+
}
|
|
174
|
+
/** Wakes re-enter a chat session; identify the agent's own instruction and its cancellation handle. */
|
|
175
|
+
function wakeEnvelope(w) {
|
|
176
|
+
const tag = w.cron
|
|
177
|
+
? `[wake-up ${w.id} fired — YOUR recurring self-scheduled turn (cron "${w.cron}"${w.tz ? ` ${w.tz}` : ""}), not a user message; unwake({ id: "${w.id}" }) stops it]`
|
|
178
|
+
: `[wake-up ${w.id} fired — YOUR self-scheduled turn, not a user message]`;
|
|
179
|
+
return `${tag} ${w.prompt}`;
|
|
180
|
+
}
|
|
181
|
+
export function createScheduler(options) {
|
|
182
|
+
return Effect.gen(function* () {
|
|
183
|
+
const clock = yield* Clock.Clock;
|
|
184
|
+
const fork = Effect.runForkWith(yield* Effect.context());
|
|
185
|
+
const { agent, stateRoot, routines, now = () => new Date(clock.currentTimeMillisUnsafe()), externalClock = false, } = options;
|
|
186
|
+
const loops = new Set();
|
|
187
|
+
let stopped = false;
|
|
188
|
+
const launch = (label, work) => {
|
|
189
|
+
fork(Effect.withFiber((fiber) => {
|
|
190
|
+
// Publish ownership before a synchronous invoke callback can re-enter stop().
|
|
191
|
+
loops.add(fiber);
|
|
192
|
+
return work.pipe(Effect.catchCause((cause) => Effect.sync(() => {
|
|
193
|
+
if (!Cause.hasInterruptsOnly(cause))
|
|
194
|
+
log.error(`[schedule] ${label}: stopped unexpectedly: ${String(Cause.squash(cause))}`);
|
|
195
|
+
})), Effect.ensuring(Effect.sync(() => {
|
|
196
|
+
loops.delete(fiber);
|
|
197
|
+
})));
|
|
198
|
+
}));
|
|
199
|
+
};
|
|
200
|
+
const cronLoop = (s, first) => Effect.gen(function* () {
|
|
201
|
+
let due = first;
|
|
202
|
+
while (due) {
|
|
203
|
+
// SLEEP, THEN LOOK AGAIN, in both directions: forward, a jumped clock is noticed a cap early
|
|
204
|
+
// ({@link MAX_WAIT_MS}); backward — an NTP correction, a VM restored from a snapshot — waking on the
|
|
205
|
+
// timer alone would fire a slot that has not arrived yet and CLAIM it, burning an occurrence that was
|
|
206
|
+
// still ahead.
|
|
207
|
+
while (now().getTime() < due.getTime()) {
|
|
208
|
+
yield* Effect.sleep(Math.min(due.getTime() - now().getTime(), MAX_WAIT_MS));
|
|
209
|
+
}
|
|
210
|
+
// `slot: due` is what two schedulers have in common: both compute the same cron instant, so both try to
|
|
211
|
+
// claim the same name and exactly one wins. Claiming `now()` would give them different names.
|
|
212
|
+
yield* fireRoutineOnce({ agent, stateRoot, routine: s, slot: due, now }).pipe(Effect.catchTag("PortFailure", (error) => Effect.sync(() => {
|
|
213
|
+
// Nothing to record: this fault happens BEFORE the claim exists (that is what keeps the slot
|
|
214
|
+
// unburned), so there is no claim to write an outcome into.
|
|
215
|
+
log.error(`[schedule] ${s.name}: fire failed (skipping this run, schedule stays armed): ${String(error.cause)}`);
|
|
216
|
+
})), Effect.uninterruptible);
|
|
217
|
+
due = s.cron === undefined ? undefined : nextRun(s.cron, s.tz, now());
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
const wakeOnce = Effect.gen(function* () {
|
|
221
|
+
const w = yield* Effect.try({
|
|
222
|
+
try: () => takeFirstDueWakeup(stateRoot, now()),
|
|
223
|
+
catch: (cause) => new PortFailure(cause),
|
|
239
224
|
});
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// never back-fires before the process first booted.
|
|
256
|
-
const lastFired = fires[s.name];
|
|
257
|
-
const anchor = lastFired ? new Date(lastFired) : current;
|
|
258
|
-
const due = nextRun(s.cron, s.tz, anchor);
|
|
259
|
-
if (!due) {
|
|
260
|
-
log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
|
|
261
|
-
continue;
|
|
225
|
+
if (!w)
|
|
226
|
+
return false;
|
|
227
|
+
yield* Effect.acquireUseRelease(Effect.sync(beginWork), () => Effect.gen(function* () {
|
|
228
|
+
const label = `wake ${w.id.slice(0, 8)}`;
|
|
229
|
+
const r = yield* runTurn(agent, label, w.session, wakeEnvelope(w));
|
|
230
|
+
let kept = false;
|
|
231
|
+
if (r.busy && !w.cron) {
|
|
232
|
+
kept = yield* Effect.try({
|
|
233
|
+
try: () => deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS)),
|
|
234
|
+
catch: (cause) => new PortFailure(cause),
|
|
235
|
+
});
|
|
236
|
+
if (kept)
|
|
237
|
+
log.info(`[schedule] ${label}: session busy — retrying next poll`);
|
|
238
|
+
else
|
|
239
|
+
log.error(`[schedule] ${label}: dropped after too many busy retries`);
|
|
262
240
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
241
|
+
else if (r.busy && w.cron) {
|
|
242
|
+
// `runTurn` already said it was skipped for a busy session; this adds the one thing it cannot know —
|
|
243
|
+
// that a recurring wake has a next occurrence, so nothing is deferred and nothing is lost.
|
|
244
|
+
log.info(`[schedule] ${label}: next fires per cron`);
|
|
266
245
|
}
|
|
267
|
-
|
|
268
|
-
|
|
246
|
+
// A wake-up has no claim to settle (it is removed from the store before the turn starts), so its whole
|
|
247
|
+
// record is the log lines above and `runTurn`'s — deferred, dropped, failed, completed.
|
|
248
|
+
}), (done) => Effect.sync(done));
|
|
249
|
+
return true;
|
|
250
|
+
}).pipe(
|
|
251
|
+
// Log storage faults before a pending stop can replace the typed failure with interruption.
|
|
252
|
+
Effect.catchTag("PortFailure", (error) => Effect.sync(() => {
|
|
253
|
+
log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(error.cause)}`);
|
|
254
|
+
return false;
|
|
255
|
+
})), Effect.uninterruptible);
|
|
256
|
+
const wakeLoop = Effect.gen(function* () {
|
|
257
|
+
for (;;) {
|
|
258
|
+
while (yield* wakeOnce) {
|
|
259
|
+
// Claim only after the preceding occurrence settles.
|
|
269
260
|
}
|
|
261
|
+
yield* Effect.sleep(WAKEUP_POLL_MS);
|
|
270
262
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
start() {
|
|
266
|
+
stopped = false;
|
|
267
|
+
// ONE claim read per schedule — the newest — for the two planes that must agree about it: the reconciler
|
|
268
|
+
// (was that fire ever reported?) and catch-up (where does the next run resume?). A read fault on it stays
|
|
269
|
+
// synchronous, before any timers or turns are started: a schedule whose newest claim cannot be read is not
|
|
270
|
+
// one to run blind, it is a boot failure. The rest of the window is history, and history does not gate a
|
|
271
|
+
// boot (`latestFire`).
|
|
272
|
+
const lastFires = new Map();
|
|
273
|
+
if (!externalClock) {
|
|
274
|
+
for (const s of routines) {
|
|
275
|
+
const fire = latestFire(stateRoot, s.name);
|
|
276
|
+
if (fire !== undefined)
|
|
277
|
+
lastFires.set(s.name, fire.firedAt);
|
|
278
|
+
markInterruptedFire(stateRoot, s.name, fire);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const current = now();
|
|
282
|
+
for (const s of externalClock ? [] : routines) {
|
|
283
|
+
if (stopped)
|
|
284
|
+
break;
|
|
285
|
+
// NO CRON, NO CLOCK. A routine without one is not unscheduled by accident — it is reached by name
|
|
286
|
+
// (`POST /run`, `fastagent routine run`), so there is nothing here to arm and nothing to warn about.
|
|
287
|
+
if (s.cron === undefined)
|
|
288
|
+
continue;
|
|
289
|
+
// FROM THE LAST FIRE, or from now when there was none. A routine that has never fired does not catch
|
|
290
|
+
// up the occurrence it missed while the process was down: nothing recorded that it was ever armed then,
|
|
291
|
+
// so "missed" is not a fact this process has. Every boot after the first resumes from the claim.
|
|
292
|
+
const last = lastFires.get(s.name);
|
|
293
|
+
const due = nextRun(s.cron, s.tz, last ? new Date(last) : current);
|
|
294
|
+
if (!due) {
|
|
295
|
+
log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (due.getTime() <= current.getTime())
|
|
299
|
+
log.info(`[schedule] ${s.name}: catching up a missed run`);
|
|
300
|
+
launch(s.name, cronLoop(s, due));
|
|
301
|
+
}
|
|
302
|
+
if (!stopped)
|
|
303
|
+
launch("wake-up poll", wakeLoop);
|
|
304
|
+
},
|
|
305
|
+
stop() {
|
|
306
|
+
stopped = true;
|
|
307
|
+
for (const loop of loops)
|
|
308
|
+
loop.interruptUnsafe();
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
});
|
|
286
312
|
}
|