@fastagent-sh/fastagent 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +15 -0
- package/dist/channels/feishu/crypto.js +43 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +46 -0
- package/dist/channels/feishu/feishu.js +472 -0
- package/dist/channels/feishu/invoke-turn.d.ts +65 -0
- package/dist/channels/feishu/invoke-turn.js +157 -0
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +43 -0
- package/dist/channels/feishu/parse.js +65 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +41 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +39 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +8 -1284
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +80 -7
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -6
- package/dist/engines/pi/create.js +52 -16
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +75 -7
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +18 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* cross-instance locking; two processes must not share a state dir). Small JSON files, written
|
|
4
4
|
* atomically (tmp + rename), so a crash leaves the previous version on disk, never a torn file.
|
|
5
5
|
* Writes are synchronous: the files are KB-sized and a write that completes BEFORE the webhook 200
|
|
6
|
-
* is what makes the state actually durable (an ACKed
|
|
6
|
+
* is what makes the state actually durable (an ACKed webhook delivery is not redelivered).
|
|
7
|
+
* Channel-neutral: every stateful channel (telegram, Feishu; Lark compatibility) derives its home from the ctx state root
|
|
8
|
+
* and persists through these three primitives.
|
|
7
9
|
*
|
|
8
10
|
* Failure split: a CORRUPT file (bad JSON) degrades visibly — log.warn + start empty — because channel
|
|
9
11
|
* state is recoverable context, not worth refusing to boot over. An unreadable file (permissions, IO)
|
|
@@ -12,7 +14,7 @@
|
|
|
12
14
|
*/
|
|
13
15
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
16
|
import { dirname, join } from "node:path";
|
|
15
|
-
import { log } from "
|
|
17
|
+
import { log } from "../log.js";
|
|
16
18
|
/** Create the state home and self-ignore it (`.gitignore="*"`): its contents (buffers, downloaded
|
|
17
19
|
* files) can carry chat content and must never be committable. The workspace opener already protects
|
|
18
20
|
* an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
|
|
@@ -38,7 +40,7 @@ export function loadStateFile(path) {
|
|
|
38
40
|
if (e.code === "ENOENT")
|
|
39
41
|
return undefined; // first run — normal
|
|
40
42
|
// Permissions/IO: an environment error — fail the boot loudly rather than run on invisible state.
|
|
41
|
-
throw new Error(`
|
|
43
|
+
throw new Error(`channel state file ${path} is unreadable — fix permissions/disk and restart: ${String(e)}`, {
|
|
42
44
|
cause: e,
|
|
43
45
|
});
|
|
44
46
|
}
|
|
@@ -46,7 +48,7 @@ export function loadStateFile(path) {
|
|
|
46
48
|
return JSON.parse(raw);
|
|
47
49
|
}
|
|
48
50
|
catch (e) {
|
|
49
|
-
log.warn(`[
|
|
51
|
+
log.warn(`[fastagent] corrupt state file ${path} — starting empty: ${String(e)}`);
|
|
50
52
|
return undefined;
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* survives for the next answered turn (a whole-bucket delete would lose it).
|
|
13
13
|
*/
|
|
14
14
|
import { log } from "../../log.js";
|
|
15
|
-
import { loadStateFile, saveStateFile } from "
|
|
15
|
+
import { loadStateFile, saveStateFile } from "../state.js";
|
|
16
16
|
/** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
|
|
17
17
|
* the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
|
|
18
18
|
* sparse-but-relevant lines, a busy burst is capped). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RegistrationOutcome } from "../registration.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
|
|
3
4
|
* REACHABLE first — polling `<baseUrl>/health` — because Telegram VERIFIES the URL when you set it, and a
|
|
@@ -5,9 +6,11 @@
|
|
|
5
6
|
* seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
|
|
6
7
|
* fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
|
|
7
8
|
* manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
|
|
9
|
+
*
|
|
10
|
+
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
8
11
|
*/
|
|
9
12
|
export declare function registerTelegramWebhook(baseUrl: string, opts?: {
|
|
10
13
|
readyTimeoutMs?: number;
|
|
11
14
|
readyIntervalMs?: number;
|
|
12
15
|
retryMs?: number;
|
|
13
|
-
}): Promise<
|
|
16
|
+
}): Promise<RegistrationOutcome>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
7
|
import { log } from "../../log.js";
|
|
8
|
+
import { waitForHealth } from "../wait-health.js";
|
|
8
9
|
import { callApi } from "./telegram-api.js";
|
|
9
10
|
/**
|
|
10
11
|
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
|
|
@@ -13,6 +14,8 @@ import { callApi } from "./telegram-api.js";
|
|
|
13
14
|
* seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
|
|
14
15
|
* fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
|
|
15
16
|
* manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
|
|
17
|
+
*
|
|
18
|
+
* Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
|
|
16
19
|
*/
|
|
17
20
|
export async function registerTelegramWebhook(baseUrl, opts = {}) {
|
|
18
21
|
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
@@ -20,54 +23,42 @@ export async function registerTelegramWebhook(baseUrl, opts = {}) {
|
|
|
20
23
|
const webhookUrl = `${baseUrl}/telegram`;
|
|
21
24
|
if (!botToken || !secret) {
|
|
22
25
|
log.info(`[fastagent] telegram: set TELEGRAM_BOT_TOKEN + TELEGRAM_SECRET_TOKEN in .env, then re-run to auto-register. Webhook URL: ${webhookUrl}`);
|
|
23
|
-
return;
|
|
26
|
+
return "manual";
|
|
24
27
|
}
|
|
25
28
|
// Align registration with the server actually serving. Don't setWebhook against a URL Telegram can't
|
|
26
29
|
// yet reach — it would fail, and a fixed retry window guesses the readiness delay (the deploy race).
|
|
27
30
|
log.info(`[fastagent] telegram: waiting for ${baseUrl} to be reachable before registering the webhook…`);
|
|
28
31
|
const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
|
|
29
32
|
if (!ready) {
|
|
30
|
-
|
|
33
|
+
// Terminal for this run (registration will not be retried) — error, not warn: the webhook is NOT
|
|
34
|
+
// registered and the operator must act. Same taxonomy as the permanent setWebhook failure below.
|
|
35
|
+
log.error(`[fastagent] telegram: ${baseUrl}/health did not come up in time — the app may still be starting. ` +
|
|
31
36
|
`Register the webhook manually once it's up: curl "https://api.telegram.org/bot<token>/setWebhook" -d url=${webhookUrl} -d secret_token=<secret>`);
|
|
32
|
-
return;
|
|
37
|
+
return "failed";
|
|
33
38
|
}
|
|
34
39
|
// Reachable → register. A short retry backstops Telegram's resolver lagging /health by a moment; only
|
|
35
40
|
// network-transient errors retry (a permanent "bad webhook" config error is reported, not retried).
|
|
41
|
+
let lastTransientError = "unknown transport error";
|
|
36
42
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
37
43
|
if (attempt > 0)
|
|
38
44
|
await sleep(opts.retryMs ?? 2000);
|
|
39
45
|
try {
|
|
40
46
|
await callApi("https://api.telegram.org", botToken, "setWebhook", { url: webhookUrl, secret_token: secret });
|
|
41
47
|
log.info(`[fastagent] telegram: webhook registered → ${webhookUrl}`);
|
|
42
|
-
return;
|
|
48
|
+
return "registered";
|
|
43
49
|
}
|
|
44
50
|
catch (e) {
|
|
45
51
|
const error = String(e);
|
|
46
52
|
if (!/resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout/i.test(error)) {
|
|
47
53
|
log.error(`[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
|
|
48
|
-
return;
|
|
54
|
+
return "failed";
|
|
49
55
|
}
|
|
56
|
+
lastTransientError = error;
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
58
|
-
*/
|
|
59
|
-
async function waitForHealth(healthUrl, timeoutMs, intervalMs) {
|
|
60
|
-
const deadline = Date.now() + timeoutMs;
|
|
61
|
-
for (;;) {
|
|
62
|
-
try {
|
|
63
|
-
if ((await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })).ok)
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
/* not routable yet — keep polling until the deadline */
|
|
68
|
-
}
|
|
69
|
-
if (Date.now() >= deadline)
|
|
70
|
-
return false;
|
|
71
|
-
await sleep(intervalMs);
|
|
72
|
-
}
|
|
59
|
+
// Exhausted retries end in the same state as a permanent error (webhook not registered, manual
|
|
60
|
+
// action required) — report at the same level.
|
|
61
|
+
log.error(`[fastagent] telegram: setWebhook still failing after retries (last error: ${lastTransientError}). ` +
|
|
62
|
+
`Register manually with url=${webhookUrl}`);
|
|
63
|
+
return "failed";
|
|
73
64
|
}
|
|
@@ -31,9 +31,9 @@ import { invokeTurn } from "./invoke-turn.js";
|
|
|
31
31
|
import { collectAttachments, createContextBuffer } from "./context-buffer.js";
|
|
32
32
|
import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, } from "./parse.js";
|
|
33
33
|
import { defaultErrorMessage, streamReply } from "./preview.js";
|
|
34
|
-
import { ensureStateHome } from "
|
|
34
|
+
import { ensureStateHome } from "../state.js";
|
|
35
35
|
import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
36
|
-
import { createTurnQueue } from "
|
|
36
|
+
import { createTurnQueue } from "../turn-queue.js";
|
|
37
37
|
import { createTurnStore } from "./turn-store.js";
|
|
38
38
|
// Re-export the public surface authored elsewhere, so `@fastagent-sh/fastagent/telegram` keeps one entry point.
|
|
39
39
|
export { defaultTelegramRoute, telegramEnvelope };
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The telegram-shaped turn store: the channel's persisted record (what its runner needs to re-execute a
|
|
3
|
+
* turn), its IO-boundary shape validator, and its arrival ordering, over the generic L1 turn store
|
|
4
|
+
* (../turn-store.ts — the semantics live there: pre-ACK persist, replay on the next start, the poison-
|
|
5
|
+
* turn execution ceiling, the fail-closed attempt bump).
|
|
6
|
+
*/
|
|
7
|
+
import { type TurnStore } from "../turn-store.ts";
|
|
1
8
|
/** An accepted turn's persisted intent — the SOURCE for the fields a runner needs to re-execute it
|
|
2
9
|
* (telegram.ts's PendingTurn derives from this, so a new execution field added here propagates and
|
|
3
10
|
* cannot silently drop from the persisted record). Minus the live `previewId` (a restart's queue
|
|
@@ -15,24 +22,4 @@ export interface StoredTurn {
|
|
|
15
22
|
fileIds: string[];
|
|
16
23
|
attempts: number;
|
|
17
24
|
}
|
|
18
|
-
export
|
|
19
|
-
/** Persist an accepted turn before the ACK. A failed write throws (→ webhook 500, Telegram redelivers). */
|
|
20
|
-
add(rec: StoredTurn): void;
|
|
21
|
-
/** Remove a finished turn. Post-ACK: a failed write is logged, never thrown (must not abort delivery). */
|
|
22
|
-
remove(id: string): void;
|
|
23
|
-
/** Every persisted turn a crash left behind, in ARRIVAL order, to re-enqueue on the next start. Read-
|
|
24
|
-
* only — the ceiling is enforced per turn at `startAttempt`, so a never-run turn's budget is untouched
|
|
25
|
-
* by a restart. Order matters: the queue rebuilds each session's FIFO chain from re-enqueue order. */
|
|
26
|
-
recover(): StoredTurn[];
|
|
27
|
-
/** Called when a turn is about to RUN (dequeued). Returns:
|
|
28
|
-
* - "run": bumped its persisted execution count; go ahead.
|
|
29
|
-
* - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
|
|
30
|
-
* cause); the record is dropped and the runner notifies the asker.
|
|
31
|
-
* - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
|
|
32
|
-
* would let a poison turn re-run forever); the record stays on disk and replays on the next start
|
|
33
|
-
* (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
|
|
34
|
-
* An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
|
|
35
|
-
* redelivery-double-run tail (see the header's pre-ACK window) lands here. */
|
|
36
|
-
startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
|
|
37
|
-
}
|
|
38
|
-
export declare function createTurnStore(path: string): TurnStore;
|
|
25
|
+
export declare function createTurnStore(path: string): TurnStore<StoredTurn>;
|
|
@@ -1,52 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* the
|
|
5
|
-
*
|
|
6
|
-
* fires at the `completed` event (the session is committed), which is BEFORE streamReply delivers the
|
|
7
|
-
* answer to Telegram: so L1 recovers the ACKed-but-un-COMPLETED window, not un-DELIVERED. A crash in
|
|
8
|
-
* that narrow tail (completed, session-committed, but the message not yet sent) leaves the answer in the
|
|
9
|
-
* session history undelivered and is deliberately NOT replayed — replaying a session-committed turn
|
|
10
|
-
* would double-append it; the asker re-asks (and sees the prior answer in history). L1's scope is the
|
|
11
|
-
* INTERRUPTED-run window (the `finally` never runs); a caught error is NOT retried here — a `failed`
|
|
12
|
-
* event already told the user, and a transport throw is dropped exactly like the pre-L1 in-memory queue
|
|
13
|
-
* did (replaying it could double-send). Only an interrupted run leaves the record on disk — and
|
|
14
|
-
* "interrupted" is not just a rare crash: `runStart` has no graceful drain (cli.ts), so a SIGTERM exits
|
|
15
|
-
* mid-turn too, i.e. EVERY rolling deploy that catches an in-flight turn. Recovery re-enqueues it next start.
|
|
16
|
-
*
|
|
17
|
-
* This recovers the ACKed-but-unfinished window the in-memory turn-queue drops (turn-queue.ts). Weigh
|
|
18
|
-
* the trade before trusting it: the alternative (dropping the turn) fails VISIBLY and self-corrects
|
|
19
|
-
* (the turn vanished, the asker re-asks); replay's sharpest cost is the opposite — invisible. It is
|
|
20
|
-
* at-least-once, not exactly-once:
|
|
21
|
-
* - PRIMARY cost: replay re-runs the WHOLE turn, so every external side effect happens AGAIN — re-sent
|
|
22
|
-
* messages, re-fired tool actions — and nobody may notice (unlike the visible loss it replaces).
|
|
23
|
-
* And the trigger is not rare (above): it fires on every deploy that interrupts an in-flight turn,
|
|
24
|
-
* not just on crashes. So "safe only if the turn's tools are idempotent" is a bar to judge against
|
|
25
|
-
* DEPLOY frequency: it holds for a Q&A bot, and is the gate for adding side-effecting tools. (A
|
|
26
|
-
* mid-stream interruption also leaves an orphan "💭 Thinking…" preview — cosmetic.)
|
|
27
|
-
* - The pre-ACK window overlaps Telegram redelivery: a crash AFTER the persist but BEFORE the 200
|
|
28
|
-
* means recovery replays the turn AND Telegram (never-ACKed) redelivers the same update — same
|
|
29
|
-
* update_id, but the queue does not dedup, so the turn can run twice. Exactly-once (a persisted
|
|
30
|
-
* delivery key) is L2.
|
|
31
|
-
*
|
|
32
|
-
* ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
|
|
33
|
-
* crashes the process would replay forever under a container restart policy. The counter is bumped at
|
|
34
|
-
* `startAttempt` — when a turn is about to RUN, not per restart cycle — so it counts a turn's OWN
|
|
35
|
-
* execution attempts: `recover()` re-enqueues every surviving turn without touching its count, and a
|
|
36
|
-
* poison turn at a session's head is dropped on its own N+1th run WITHOUT penalizing the never-run turns
|
|
37
|
-
* queued behind it (they keep their full budget and get their turn once the poison one is gone). On drop,
|
|
38
|
-
* the runner notifies the asker (the chain's end must get a signal — a log line the user can't see isn't).
|
|
39
|
-
* The bump is the ONE post-ACK write that fails CLOSED (not best-effort): if it can't be persisted the
|
|
40
|
-
* turn is DEFERRED — skipped this cycle and left on disk to replay on the next start — rather than run
|
|
41
|
-
* untracked, because an unpersisted count lets recover() re-run a poison turn every restart (the ceiling
|
|
42
|
-
* would never advance on disk). It replays on the next START — a restart is required; disk recovery alone
|
|
43
|
-
* does not re-run a deferred turn — and the asker is NOT told (a transient system degrade, not a user-
|
|
44
|
-
* actionable failure — telling them "ask again" would double-answer on that replay).
|
|
45
|
-
* Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
|
|
46
|
-
* power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
|
|
2
|
+
* The telegram-shaped turn store: the channel's persisted record (what its runner needs to re-execute a
|
|
3
|
+
* turn), its IO-boundary shape validator, and its arrival ordering, over the generic L1 turn store
|
|
4
|
+
* (../turn-store.ts — the semantics live there: pre-ACK persist, replay on the next start, the poison-
|
|
5
|
+
* turn execution ceiling, the fail-closed attempt bump).
|
|
47
6
|
*/
|
|
48
|
-
import {
|
|
49
|
-
import { loadStateFile, saveStateFile } from "./state.js";
|
|
7
|
+
import { createTurnStore as createGenericTurnStore } from "../turn-store.js";
|
|
50
8
|
/** State files are an IO boundary: valid JSON of the WRONG SHAPE must degrade like a corrupt file
|
|
51
9
|
* (warn + empty), not flow in as trusted data (mirrors context-buffer's isBufferEntry). */
|
|
52
10
|
function isStoredTurn(t) {
|
|
@@ -64,87 +22,10 @@ function isStoredTurn(t) {
|
|
|
64
22
|
typeof r.attempts === "number");
|
|
65
23
|
}
|
|
66
24
|
export function createTurnStore(path) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
log.warn(`[telegram] unexpected shape in ${path} — starting with no pending turns`);
|
|
75
|
-
return new Map();
|
|
76
|
-
};
|
|
77
|
-
const turns = load();
|
|
78
|
-
const persist = () => saveStateFile(path, Object.fromEntries(turns));
|
|
79
|
-
// Post-ACK writes (remove, startAttempt) must not abort a turn: log a failed write, never throw.
|
|
80
|
-
const persistBestEffort = (what) => {
|
|
81
|
-
try {
|
|
82
|
-
persist();
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
log.error(`[telegram] turn-store ${what} write failed post-ACK: ${String(e)}`);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
return {
|
|
89
|
-
add(rec) {
|
|
90
|
-
// Idempotent on re-add: a redelivery (pre-ACK crash → never-ACKed update replayed) re-submits an id
|
|
91
|
-
// already in the store. Skip it — the intent is already durable, and overwriting would RESET this
|
|
92
|
-
// turn's execution count, handing a poison turn a fresh ceiling budget.
|
|
93
|
-
if (turns.has(rec.id))
|
|
94
|
-
return;
|
|
95
|
-
turns.set(rec.id, rec);
|
|
96
|
-
try {
|
|
97
|
-
persist(); // pre-ACK: a throw becomes the webhook's 500 and Telegram redelivers
|
|
98
|
-
}
|
|
99
|
-
catch (e) {
|
|
100
|
-
// Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
|
|
101
|
-
// entry makes the redelivery's `add` short-circuit on `turns.has` — running the turn with its
|
|
102
|
-
// intent never persisted, defeating this module — and gets flushed later by an unrelated persist.
|
|
103
|
-
turns.delete(rec.id);
|
|
104
|
-
throw e;
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
remove(id) {
|
|
108
|
-
if (turns.delete(id))
|
|
109
|
-
persistBestEffort("remove (a restart may replay an answered turn)");
|
|
110
|
-
},
|
|
111
|
-
recover() {
|
|
112
|
-
// Arrival order, so the queue rebuilds each session's FIFO chain correctly. Ids are Telegram
|
|
113
|
-
// update_ids — monotonic — so numeric id order IS arrival order; sort on it explicitly rather than
|
|
114
|
-
// lean on JS object-key enumeration happening to survive the load's JSON round-trip.
|
|
115
|
-
return [...turns.values()].sort((a, b) => Number(a.id) - Number(b.id));
|
|
116
|
-
},
|
|
117
|
-
startAttempt(id, maxAttempts) {
|
|
118
|
-
const rec = turns.get(id);
|
|
119
|
-
if (!rec)
|
|
120
|
-
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
121
|
-
const attempts = rec.attempts + 1;
|
|
122
|
-
if (attempts > maxAttempts) {
|
|
123
|
-
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
124
|
-
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
125
|
-
log.error(`[telegram] dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
126
|
-
`(session=${rec.session}) — it may be crashing the process, or was killed mid-run each time; notifying the asker`);
|
|
127
|
-
turns.delete(id);
|
|
128
|
-
persistBestEffort("drop");
|
|
129
|
-
return "exceeded";
|
|
130
|
-
}
|
|
131
|
-
turns.set(id, { ...rec, attempts });
|
|
132
|
-
try {
|
|
133
|
-
persist();
|
|
134
|
-
}
|
|
135
|
-
catch (e) {
|
|
136
|
-
// The bump MUST be durable, unlike remove/drop: if it isn't, a restart's recover() reads the old
|
|
137
|
-
// count and RE-RUNS this turn — a poison turn would re-execute forever (the ceiling never advances
|
|
138
|
-
// on disk). Fail closed: roll the memory bump back (match disk) and DEFER — the record stays on
|
|
139
|
-
// disk with its old count and replays on the next start (a restart is required; disk recovery
|
|
140
|
-
// alone won't re-run it). Not a drop: under disk failure the removal couldn't persist anyway.
|
|
141
|
-
// The runner skips silently (no notify).
|
|
142
|
-
turns.set(id, rec);
|
|
143
|
-
log.error(`[telegram] cannot persist turn ${id}'s attempt count — deferring it to the next start rather ` +
|
|
144
|
-
`than run it untracked (session=${rec.session}): ${String(e)}`);
|
|
145
|
-
return "defer";
|
|
146
|
-
}
|
|
147
|
-
return "run";
|
|
148
|
-
},
|
|
149
|
-
};
|
|
25
|
+
return createGenericTurnStore(path, {
|
|
26
|
+
label: "[telegram]",
|
|
27
|
+
isRecord: isStoredTurn,
|
|
28
|
+
// Ids are Telegram update_ids — monotonic — so numeric id order IS arrival order.
|
|
29
|
+
order: (a, b) => Number(a.id) - Number(b.id),
|
|
30
|
+
});
|
|
150
31
|
}
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* is the corruption floor beneath this; this adds the group-UX queue (a second summon waits its turn
|
|
5
5
|
* instead of colliding on the lease and being dropped as "busy").
|
|
6
6
|
*
|
|
7
|
-
* Channel-neutral (records are opaque beyond a `session` key)
|
|
8
|
-
*
|
|
7
|
+
* Channel-neutral (records are opaque beyond a `session` key), shared by the stateful chat channels
|
|
8
|
+
* (telegram, Feishu; Lark reuses Feishu). Durability is layered ON TOP by the caller: turn-store.ts
|
|
9
9
|
* persists an accepted turn's intent pre-ACK and replays a crash-surviving one on the next start (L1,
|
|
10
10
|
* process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
|
|
11
11
|
* backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
|
|
12
12
|
*/
|
|
13
|
-
import { log } from "
|
|
13
|
+
import { log } from "../log.js";
|
|
14
14
|
export function createTurnQueue(opts) {
|
|
15
15
|
const { label, run, onQueuedBehind } = opts;
|
|
16
16
|
// Per-session serial chains: a second turn for the same session waits its turn (FIFO) instead of
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
|
|
2
|
+
* chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
|
|
3
|
+
* run; bumped at each `startAttempt`). The channel's record type extends this with everything its
|
|
4
|
+
* runner needs to re-execute the turn — a new execution field added there propagates into the
|
|
5
|
+
* persisted record by construction. */
|
|
6
|
+
export interface TurnRecordBase {
|
|
7
|
+
id: string;
|
|
8
|
+
session: string;
|
|
9
|
+
attempts: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TurnStore<T extends TurnRecordBase> {
|
|
12
|
+
/** Persist an accepted turn before the ACK. A failed write throws (→ webhook 500, the platform redelivers). */
|
|
13
|
+
add(rec: T): void;
|
|
14
|
+
/** Remove a finished turn. Post-ACK: a failed write is logged, never thrown (must not abort delivery). */
|
|
15
|
+
remove(id: string): void;
|
|
16
|
+
/** Every persisted turn a crash left behind, in ARRIVAL order (the channel's `order`), to re-enqueue
|
|
17
|
+
* on the next start. Read-only — the ceiling is enforced per turn at `startAttempt`, so a never-run
|
|
18
|
+
* turn's budget is untouched by a restart. Order matters: the queue rebuilds each session's FIFO
|
|
19
|
+
* chain from re-enqueue order. */
|
|
20
|
+
recover(): T[];
|
|
21
|
+
/** Called when a turn is about to RUN (dequeued). Returns:
|
|
22
|
+
* - "run": bumped its persisted execution count; go ahead.
|
|
23
|
+
* - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
|
|
24
|
+
* cause); the record is dropped and the runner notifies the asker.
|
|
25
|
+
* - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
|
|
26
|
+
* would let a poison turn re-run forever); the record stays on disk and replays on the next start
|
|
27
|
+
* (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
|
|
28
|
+
* An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
|
|
29
|
+
* redelivery-double-run tail (see the header's pre-ACK window) lands here. */
|
|
30
|
+
startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
|
|
31
|
+
}
|
|
32
|
+
export interface TurnStoreOptions<T extends TurnRecordBase> {
|
|
33
|
+
/** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Shape validation at the IO boundary: valid JSON of the WRONG SHAPE must degrade like a corrupt
|
|
36
|
+
* file (warn + empty), not flow in as trusted data. */
|
|
37
|
+
isRecord: (t: unknown) => t is T;
|
|
38
|
+
/** Arrival order for {@link TurnStore.recover} — the channel knows what its ids/fields encode
|
|
39
|
+
* (telegram: numeric update_id; lark: an explicit per-record seq). */
|
|
40
|
+
order: (a: T, b: T) => number;
|
|
41
|
+
}
|
|
42
|
+
export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable turn intent: the L1 half of durable execution (the L2 exactly-once / deterministic
|
|
3
|
+
* step-replay layer is the K-axis backend — docs/design/core.md §11). Persists an accepted turn BEFORE
|
|
4
|
+
* the webhook 200 (pre-ACK, like the telegram context-buffer) and removes it when the turn ENDS — the
|
|
5
|
+
* runner's `finally`: a completed turn OR any caught error both remove it. Precisely, a completed turn's
|
|
6
|
+
* removal fires at the `completed` event (the session is committed), which is BEFORE the reply reaches
|
|
7
|
+
* the chat platform: so L1 recovers the ACKed-but-un-COMPLETED window, not un-DELIVERED. A crash in
|
|
8
|
+
* that narrow tail (completed, session-committed, but the message not yet sent) leaves the answer in the
|
|
9
|
+
* session history undelivered and is deliberately NOT replayed — replaying a session-committed turn
|
|
10
|
+
* would double-append it; the asker re-asks (and sees the prior answer in history). L1's scope is the
|
|
11
|
+
* INTERRUPTED-run window (the `finally` never runs); a caught error is NOT retried here — a `failed`
|
|
12
|
+
* event already told the user, and a transport throw is dropped exactly like the pre-L1 in-memory queue
|
|
13
|
+
* did (replaying it could double-send). Only an interrupted run leaves the record on disk — and
|
|
14
|
+
* "interrupted" is not just a rare crash: `runStart` has no graceful drain (cli.ts), so a SIGTERM exits
|
|
15
|
+
* mid-turn too, i.e. EVERY rolling deploy that catches an in-flight turn. Recovery re-enqueues it next start.
|
|
16
|
+
*
|
|
17
|
+
* This recovers the ACKed-but-unfinished window the in-memory turn-queue drops (turn-queue.ts). Weigh
|
|
18
|
+
* the trade before trusting it: the alternative (dropping the turn) fails VISIBLY and self-corrects
|
|
19
|
+
* (the turn vanished, the asker re-asks); replay's sharpest cost is the opposite — invisible. It is
|
|
20
|
+
* at-least-once, not exactly-once:
|
|
21
|
+
* - PRIMARY cost: replay re-runs the WHOLE turn, so every external side effect happens AGAIN — re-sent
|
|
22
|
+
* messages, re-fired tool actions — and nobody may notice (unlike the visible loss it replaces).
|
|
23
|
+
* And the trigger is not rare (above): it fires on every deploy that interrupts an in-flight turn,
|
|
24
|
+
* not just on crashes. So "safe only if the turn's tools are idempotent" is a bar to judge against
|
|
25
|
+
* DEPLOY frequency: it holds for a Q&A bot, and is the gate for adding side-effecting tools. (A
|
|
26
|
+
* mid-stream interruption also leaves an orphan "💭 Thinking…" preview — cosmetic.)
|
|
27
|
+
* - The pre-ACK window overlaps webhook redelivery: a crash AFTER the persist but BEFORE the 200
|
|
28
|
+
* means recovery replays the turn AND the platform (never-ACKed) redelivers the same event — same
|
|
29
|
+
* id, and unless the channel dedups accepted ids, the turn can run twice. Exactly-once (a persisted
|
|
30
|
+
* delivery key) is L2.
|
|
31
|
+
*
|
|
32
|
+
* Channel-neutral, generic over the record shape: the channel supplies its own record type (everything
|
|
33
|
+
* its runner needs to re-execute the turn), the shape validator (state files are an IO boundary), the
|
|
34
|
+
* arrival ordering for recovery, and its log label. telegram/turn-store.ts and the Feishu channel are the
|
|
35
|
+
* two consumers.
|
|
36
|
+
*
|
|
37
|
+
* ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
|
|
38
|
+
* crashes the process would replay forever under a container restart policy. The counter is bumped at
|
|
39
|
+
* `startAttempt` — when a turn is about to RUN, not per restart cycle — so it counts a turn's OWN
|
|
40
|
+
* execution attempts: `recover()` re-enqueues every surviving turn without touching its count, and a
|
|
41
|
+
* poison turn at a session's head is dropped on its own N+1th run WITHOUT penalizing the never-run turns
|
|
42
|
+
* queued behind it (they keep their full budget and get their turn once the poison one is gone). On drop,
|
|
43
|
+
* the runner notifies the asker (the chain's end must get a signal — a log line the user can't see isn't).
|
|
44
|
+
* The bump is the ONE post-ACK write that fails CLOSED (not best-effort): if it can't be persisted the
|
|
45
|
+
* turn is DEFERRED — skipped this cycle and left on disk to replay on the next start — rather than run
|
|
46
|
+
* untracked, because an unpersisted count lets recover() re-run a poison turn every restart (the ceiling
|
|
47
|
+
* would never advance on disk). It replays on the next START — a restart is required; disk recovery alone
|
|
48
|
+
* does not re-run a deferred turn — and the asker is NOT told (a transient system degrade, not a user-
|
|
49
|
+
* actionable failure — telling them "ask again" would double-answer on that replay).
|
|
50
|
+
* Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
|
|
51
|
+
* power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
|
|
52
|
+
*/
|
|
53
|
+
import { log } from "../log.js";
|
|
54
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
55
|
+
export function createTurnStore(path, opts) {
|
|
56
|
+
const { label, isRecord, order } = opts;
|
|
57
|
+
const load = () => {
|
|
58
|
+
const raw = loadStateFile(path);
|
|
59
|
+
if (raw === undefined)
|
|
60
|
+
return new Map();
|
|
61
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.values(raw).every(isRecord)) {
|
|
62
|
+
return new Map(Object.entries(raw));
|
|
63
|
+
}
|
|
64
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no pending turns`);
|
|
65
|
+
return new Map();
|
|
66
|
+
};
|
|
67
|
+
const turns = load();
|
|
68
|
+
const persist = () => saveStateFile(path, Object.fromEntries(turns));
|
|
69
|
+
// Post-ACK writes (remove, startAttempt) must not abort a turn: log a failed write, never throw.
|
|
70
|
+
const persistBestEffort = (what) => {
|
|
71
|
+
try {
|
|
72
|
+
persist();
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.error(`${label} turn-store ${what} write failed post-ACK: ${String(e)}`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
add(rec) {
|
|
80
|
+
// Idempotent on re-add: a redelivery (pre-ACK crash → never-ACKed event replayed) re-submits an id
|
|
81
|
+
// already in the store. Skip it — the intent is already durable, and overwriting would RESET this
|
|
82
|
+
// turn's execution count, handing a poison turn a fresh ceiling budget.
|
|
83
|
+
if (turns.has(rec.id))
|
|
84
|
+
return;
|
|
85
|
+
turns.set(rec.id, rec);
|
|
86
|
+
try {
|
|
87
|
+
persist(); // pre-ACK: a throw becomes the webhook's 500 and the platform redelivers
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
|
|
91
|
+
// entry makes the redelivery's `add` short-circuit on `turns.has` — running the turn with its
|
|
92
|
+
// intent never persisted, defeating this module — and gets flushed later by an unrelated persist.
|
|
93
|
+
turns.delete(rec.id);
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
remove(id) {
|
|
98
|
+
if (turns.delete(id))
|
|
99
|
+
persistBestEffort("remove (a restart may replay an answered turn)");
|
|
100
|
+
},
|
|
101
|
+
recover() {
|
|
102
|
+
// The channel's arrival order, applied explicitly rather than leaning on JS object-key enumeration
|
|
103
|
+
// happening to survive the load's JSON round-trip.
|
|
104
|
+
return [...turns.values()].sort(order);
|
|
105
|
+
},
|
|
106
|
+
startAttempt(id, maxAttempts) {
|
|
107
|
+
const rec = turns.get(id);
|
|
108
|
+
if (!rec)
|
|
109
|
+
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
110
|
+
const attempts = rec.attempts + 1;
|
|
111
|
+
if (attempts > maxAttempts) {
|
|
112
|
+
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
113
|
+
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
114
|
+
log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
115
|
+
`(session=${rec.session}) — it may be crashing the process, or was killed mid-run each time; notifying the asker`);
|
|
116
|
+
turns.delete(id);
|
|
117
|
+
persistBestEffort("drop");
|
|
118
|
+
return "exceeded";
|
|
119
|
+
}
|
|
120
|
+
turns.set(id, { ...rec, attempts });
|
|
121
|
+
try {
|
|
122
|
+
persist();
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
// The bump MUST be durable, unlike remove/drop: if it isn't, a restart's recover() reads the old
|
|
126
|
+
// count and RE-RUNS this turn — a poison turn would re-execute forever (the ceiling never advances
|
|
127
|
+
// on disk). Fail closed: roll the memory bump back (match disk) and DEFER — the record stays on
|
|
128
|
+
// disk with its old count and replays on the next start (a restart is required; disk recovery
|
|
129
|
+
// alone won't re-run it). Not a drop: under disk failure the removal couldn't persist anyway.
|
|
130
|
+
// The runner skips silently (no notify).
|
|
131
|
+
turns.set(id, rec);
|
|
132
|
+
log.error(`${label} cannot persist turn ${id}'s attempt count — deferring it to the next start rather ` +
|
|
133
|
+
`than run it untracked (session=${rec.session}): ${String(e)}`);
|
|
134
|
+
return "defer";
|
|
135
|
+
}
|
|
136
|
+
return "run";
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
|
|
3
|
+
* settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
|
|
4
|
+
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
5
|
+
*/
|
|
6
|
+
export declare function waitForHealth(healthUrl: string, timeoutMs: number, intervalMs: number): Promise<boolean>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readiness probe shared by the webhook registrars (telegram setWebhook, lark config PATCH): both
|
|
3
|
+
* platforms VERIFY the URL at registration time, and a fresh deploy's container or a fresh tunnel's
|
|
4
|
+
* DNS is not routable for some seconds — registering before the server actually serves would fail.
|
|
5
|
+
* Tracking real readiness (not a fixed timer) is what fixes that race.
|
|
6
|
+
*/
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
|
+
/**
|
|
9
|
+
* Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
|
|
10
|
+
* settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
|
|
11
|
+
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
12
|
+
*/
|
|
13
|
+
export async function waitForHealth(healthUrl, timeoutMs, intervalMs) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
for (;;) {
|
|
16
|
+
try {
|
|
17
|
+
if ((await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })).ok)
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* not routable yet — keep polling until the deadline */
|
|
22
|
+
}
|
|
23
|
+
if (Date.now() >= deadline)
|
|
24
|
+
return false;
|
|
25
|
+
await sleep(intervalMs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ChannelKind } from "../../scaffold/add-channel.ts";
|
|
2
|
+
/** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
|
|
3
|
+
export declare function runAddChannel(channelKind: ChannelKind, dirArg: string, opts: {
|
|
4
|
+
createApp?: boolean;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
7
|
+
export declare function runAddSkill(source: string | undefined, dirArg: string, opts: {
|
|
8
|
+
update?: boolean;
|
|
9
|
+
}): Promise<void>;
|