talon-agent 5.18.1 → 5.19.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/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +8 -3
- package/package.json +4 -2
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +49 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/service.ts +26 -15
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +12 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +147 -41
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
type SourceContext,
|
|
73
73
|
} from "./sources/sessions.js";
|
|
74
74
|
import {
|
|
75
|
+
DB_MEMBER,
|
|
75
76
|
STATE_PART,
|
|
76
77
|
indexSnapshot,
|
|
77
78
|
linkOrCopy,
|
|
@@ -88,8 +89,6 @@ import type {
|
|
|
88
89
|
SnapshotPart,
|
|
89
90
|
} from "./types.js";
|
|
90
91
|
|
|
91
|
-
/** Where the database copy lands inside the archive. */
|
|
92
|
-
const DB_MEMBER = "db/talon.db";
|
|
93
92
|
/** The part that holds WhatsApp auth and the userbot session. */
|
|
94
93
|
const LOGINS_PART = "logins.tar.zst";
|
|
95
94
|
/** How a clone reinstalls fetched plugins (see sources/plugins.ts). */
|
|
@@ -120,10 +119,18 @@ export type BuildOptions = {
|
|
|
120
119
|
|
|
121
120
|
// ── Archive writing ─────────────────────────────────────────────────────────
|
|
122
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Open failures that mean "this file is gone or locked since the walk":
|
|
124
|
+
* traces and backend transcripts churn while a snapshot runs, and one of
|
|
125
|
+
* them disappearing must not cost the whole backup (see collectTree).
|
|
126
|
+
*/
|
|
127
|
+
const SKIPPABLE_OPEN_ERRORS = new Set(["ENOENT", "EACCES", "EPERM"]);
|
|
128
|
+
|
|
123
129
|
async function addEntries(
|
|
124
130
|
writer: TarWriter,
|
|
125
131
|
entries: readonly SourceEntry[],
|
|
126
132
|
): Promise<void> {
|
|
133
|
+
const skipped: string[] = [];
|
|
127
134
|
for (const entry of entries) {
|
|
128
135
|
if (entry.type === "dir") {
|
|
129
136
|
await writer.addDirectory(entry.archivePath, entry.mode, entry.mtime);
|
|
@@ -135,15 +142,27 @@ async function addEntries(
|
|
|
135
142
|
entry.mtime,
|
|
136
143
|
);
|
|
137
144
|
} else {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
try {
|
|
146
|
+
await writer.addFile(
|
|
147
|
+
entry.archivePath,
|
|
148
|
+
entry.source,
|
|
149
|
+
entry.mode,
|
|
150
|
+
entry.mtime,
|
|
151
|
+
entry.size,
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const code = (err as NodeJS.ErrnoException).code ?? "";
|
|
155
|
+
if (!SKIPPABLE_OPEN_ERRORS.has(code)) throw err;
|
|
156
|
+
skipped.push(`${entry.source} (${code})`);
|
|
157
|
+
}
|
|
145
158
|
}
|
|
146
159
|
}
|
|
160
|
+
if (skipped.length > 0) {
|
|
161
|
+
logWarn(
|
|
162
|
+
"backup",
|
|
163
|
+
`Skipped ${skipped.length} file(s) that vanished or became unreadable mid-snapshot; first: ${skipped[0]}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
/** A part's file name: `.enc` marks one written through the encryptor. */
|
|
@@ -171,6 +190,11 @@ async function writePart(
|
|
|
171
190
|
const flushed = passphrase
|
|
172
191
|
? pipeline(compressor, await createEncryptor(passphrase), tap, out)
|
|
173
192
|
: pipeline(compressor, tap, out);
|
|
193
|
+
// A sink failure (ENOSPC) rejects this while `fill` is still writing, and
|
|
194
|
+
// `fill` then throws the same error — so the await below is never
|
|
195
|
+
// reached. Observe it here, or it surfaces as an unhandled rejection
|
|
196
|
+
// that kills the CLI before the cleanup runs.
|
|
197
|
+
flushed.catch(() => {});
|
|
174
198
|
try {
|
|
175
199
|
const writer = new TarWriter(compressor);
|
|
176
200
|
await fill(writer);
|
|
@@ -312,11 +336,20 @@ async function palaceFingerprint(
|
|
|
312
336
|
const files: TreeFile[] = [];
|
|
313
337
|
for (const entry of entries) {
|
|
314
338
|
if (entry.type !== "file") continue;
|
|
339
|
+
let sha256: string;
|
|
340
|
+
try {
|
|
341
|
+
sha256 = await sha256File(entry.source);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Gone since the walk: the part will skip it too (see addEntries).
|
|
344
|
+
const code = (err as NodeJS.ErrnoException).code ?? "";
|
|
345
|
+
if (SKIPPABLE_OPEN_ERRORS.has(code)) continue;
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
315
348
|
files.push({
|
|
316
349
|
path: entry.archivePath,
|
|
317
350
|
size: entry.size,
|
|
318
351
|
mtime: entry.mtime,
|
|
319
|
-
sha256
|
|
352
|
+
sha256,
|
|
320
353
|
});
|
|
321
354
|
}
|
|
322
355
|
return treeHash(files);
|
package/src/core/backup/store.ts
CHANGED
|
@@ -17,15 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomBytes } from "node:crypto";
|
|
20
|
-
import {
|
|
21
|
-
link,
|
|
22
|
-
copyFile,
|
|
23
|
-
mkdir,
|
|
24
|
-
readdir,
|
|
25
|
-
readFile,
|
|
26
|
-
rm,
|
|
27
|
-
stat,
|
|
28
|
-
} from "node:fs/promises";
|
|
20
|
+
import { link, copyFile, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
29
21
|
import { join, resolve } from "node:path";
|
|
30
22
|
import writeFileAtomic from "write-file-atomic";
|
|
31
23
|
import { dirs } from "../../util/paths.js";
|
|
@@ -40,6 +32,7 @@ import {
|
|
|
40
32
|
recordBackup,
|
|
41
33
|
updateBackupManifest,
|
|
42
34
|
} from "../../storage/backup/index.js";
|
|
35
|
+
import { pathExists } from "./sources/sessions.js";
|
|
43
36
|
import type {
|
|
44
37
|
Manifest,
|
|
45
38
|
RemoteState,
|
|
@@ -51,6 +44,8 @@ import type {
|
|
|
51
44
|
const SNAPSHOT_ID_RE = /^\d{8}T\d{6}Z-[0-9a-f]{6}$/;
|
|
52
45
|
const MANIFEST_NAME = "manifest.json";
|
|
53
46
|
export const STATE_PART = "state.tar.zst";
|
|
47
|
+
/** Where the database copy lands inside the state part. */
|
|
48
|
+
export const DB_MEMBER = "db/talon.db";
|
|
54
49
|
|
|
55
50
|
function backupsRoot(home: string = dirs.root): string {
|
|
56
51
|
return join(home, "backups");
|
|
@@ -212,22 +207,15 @@ export async function listSnapshots(
|
|
|
212
207
|
pinned: record.pinned,
|
|
213
208
|
createdAt: record.createdAt,
|
|
214
209
|
sizeBytes: record.sizeBytes,
|
|
215
|
-
local: await
|
|
210
|
+
local: await pathExists(
|
|
211
|
+
join(snapshotDir(record.id, home), MANIFEST_NAME),
|
|
212
|
+
),
|
|
216
213
|
remote,
|
|
217
214
|
});
|
|
218
215
|
}
|
|
219
216
|
return summaries;
|
|
220
217
|
}
|
|
221
218
|
|
|
222
|
-
async function exists(path: string): Promise<boolean> {
|
|
223
|
-
try {
|
|
224
|
-
await stat(path);
|
|
225
|
-
return true;
|
|
226
|
-
} catch {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
219
|
/** Pin or unpin a snapshot, on disk and in the index. Returns false if unknown. */
|
|
232
220
|
export async function setSnapshotPinned(
|
|
233
221
|
id: string,
|
|
@@ -30,6 +30,17 @@ import type { Manifest, SnapshotPart } from "./types.js";
|
|
|
30
30
|
/** The daemon's chat id for plugin actions that belong to no conversation. */
|
|
31
31
|
const SYSTEM_CHAT = "system";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How long a target may take to answer. A plugin that never answers would
|
|
35
|
+
* otherwise hold the backup queue forever — every later snapshot, and the
|
|
36
|
+
* pre-update checkpoint `/update` waits on, queued behind it. Part uploads
|
|
37
|
+
* get a base allowance plus time for the bytes at a slow uplink's pace.
|
|
38
|
+
*/
|
|
39
|
+
const CONTROL_DEADLINE_MS = 10 * 60_000;
|
|
40
|
+
const TRANSFER_BASE_MS = 30 * 60_000;
|
|
41
|
+
/** 64 KiB/s — well under any link a remote backup is usable over. */
|
|
42
|
+
const TRANSFER_FLOOR_BYTES_PER_MS = 64;
|
|
43
|
+
|
|
33
44
|
/** A part as the upload call describes it: metadata plus where to read it. */
|
|
34
45
|
type UploadPart = SnapshotPart & { path: string };
|
|
35
46
|
|
|
@@ -101,6 +112,37 @@ function targetError(
|
|
|
101
112
|
});
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
/** setTimeout's ceiling; a longer delay would fire at once. */
|
|
116
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send one body, failing with `target`'s name once `deadlineMs` passes.
|
|
120
|
+
* A null deadline sends it unbounded.
|
|
121
|
+
*/
|
|
122
|
+
async function dispatchWithin(
|
|
123
|
+
deps: TargetDeps,
|
|
124
|
+
plugin: string,
|
|
125
|
+
body: Record<string, unknown>,
|
|
126
|
+
target: string,
|
|
127
|
+
deadlineMs: number | null,
|
|
128
|
+
): Promise<ActionResult | null> {
|
|
129
|
+
if (deadlineMs === null) return deps.dispatch(plugin, body);
|
|
130
|
+
const ms = Math.min(deadlineMs, MAX_TIMER_MS);
|
|
131
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
132
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
133
|
+
timer = setTimeout(() => {
|
|
134
|
+
const action = String(body.action).replace(/^backup\.target\./, "");
|
|
135
|
+
const minutes = Math.round(ms / 60_000);
|
|
136
|
+
reject(targetError(target, action, `no answer within ${minutes} min`));
|
|
137
|
+
}, ms);
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
return await Promise.race([deps.dispatch(plugin, body), deadline]);
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
/** Unwrap `{ ok, data }`, turning every failure shape into one error. */
|
|
105
147
|
function dataOf(
|
|
106
148
|
result: ActionResult | null,
|
|
@@ -128,8 +170,11 @@ class PluginTarget implements BackupTarget {
|
|
|
128
170
|
readonly detail?: string,
|
|
129
171
|
) {}
|
|
130
172
|
|
|
131
|
-
private send(
|
|
132
|
-
|
|
173
|
+
private send(
|
|
174
|
+
body: Record<string, unknown>,
|
|
175
|
+
deadlineMs: number | null = CONTROL_DEADLINE_MS,
|
|
176
|
+
): Promise<ActionResult | null> {
|
|
177
|
+
return dispatchWithin(this.deps, this.plugin, body, this.id, deadlineMs);
|
|
133
178
|
}
|
|
134
179
|
|
|
135
180
|
async upload(
|
|
@@ -138,12 +183,10 @@ class PluginTarget implements BackupTarget {
|
|
|
138
183
|
manifest: Manifest,
|
|
139
184
|
): Promise<{ remoteId: string; deduplicated?: boolean }> {
|
|
140
185
|
const data = dataOf(
|
|
141
|
-
await this.send(
|
|
142
|
-
action: "backup.target.upload",
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
manifest,
|
|
146
|
-
}),
|
|
186
|
+
await this.send(
|
|
187
|
+
{ action: "backup.target.upload", snapshotId, part, manifest },
|
|
188
|
+
TRANSFER_BASE_MS + part.bytes / TRANSFER_FLOOR_BYTES_PER_MS,
|
|
189
|
+
),
|
|
147
190
|
this.id,
|
|
148
191
|
"upload",
|
|
149
192
|
);
|
|
@@ -206,12 +249,17 @@ class PluginTarget implements BackupTarget {
|
|
|
206
249
|
destPath: string,
|
|
207
250
|
): Promise<void> {
|
|
208
251
|
dataOf(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
// Unbounded: only an interactive restore downloads, the size is not
|
|
253
|
+
// known here, and the operator can interrupt it.
|
|
254
|
+
await this.send(
|
|
255
|
+
{
|
|
256
|
+
action: "backup.target.download",
|
|
257
|
+
snapshotId,
|
|
258
|
+
part: { name: partName },
|
|
259
|
+
destPath,
|
|
260
|
+
},
|
|
261
|
+
null,
|
|
262
|
+
),
|
|
215
263
|
this.id,
|
|
216
264
|
"download",
|
|
217
265
|
);
|
|
@@ -231,9 +279,13 @@ export async function discoverTargets(
|
|
|
231
279
|
for (const plugin of deps.plugins()) {
|
|
232
280
|
let result: ActionResult | null;
|
|
233
281
|
try {
|
|
234
|
-
result = await
|
|
235
|
-
|
|
236
|
-
|
|
282
|
+
result = await dispatchWithin(
|
|
283
|
+
deps,
|
|
284
|
+
plugin,
|
|
285
|
+
{ action: "backup.target.describe" },
|
|
286
|
+
plugin,
|
|
287
|
+
CONTROL_DEADLINE_MS,
|
|
288
|
+
);
|
|
237
289
|
} catch (err) {
|
|
238
290
|
logWarn(
|
|
239
291
|
"backup",
|
package/src/core/config/index.ts
CHANGED
|
@@ -478,6 +478,20 @@ const configSchema = z.object({
|
|
|
478
478
|
planAlertThreshold: z.number().int().min(1).max(100).default(80),
|
|
479
479
|
/** Chat that receives plan warnings. Defaults to `adminUserId`. */
|
|
480
480
|
planAlertChatId: z.string().optional(),
|
|
481
|
+
/**
|
|
482
|
+
* Operator alerts (core/frontend-runtime/alerts.ts): faults that need a
|
|
483
|
+
* human — a full disk, a crash, an error spike, a dead frontend — sent
|
|
484
|
+
* to the admin chat, once per fault per `cooldownMinutes`, with a
|
|
485
|
+
* recovery notice when it clears. `enabled: false` keeps them in the
|
|
486
|
+
* log and in `talon status` only.
|
|
487
|
+
*/
|
|
488
|
+
alerts: z
|
|
489
|
+
.object({
|
|
490
|
+
enabled: z.boolean().default(true),
|
|
491
|
+
cooldownMinutes: z.number().int().min(0).max(1440).default(30),
|
|
492
|
+
})
|
|
493
|
+
.strict()
|
|
494
|
+
.optional(),
|
|
481
495
|
/** Background memory-consolidation (dream) runs. Mirrors `pulse`/`heartbeat`. */
|
|
482
496
|
dream: z.boolean().default(true),
|
|
483
497
|
/**
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash marker — how a daemon that died tells the next one.
|
|
3
|
+
*
|
|
4
|
+
* A dying process can't reliably message anyone: the uncaught-exception
|
|
5
|
+
* path runs `process.exit(1)` within the same tick, and an async send to
|
|
6
|
+
* Telegram never leaves. So the crash path writes one small file,
|
|
7
|
+
* synchronously, and the next boot — once the admin notifier and the
|
|
8
|
+
* frontends are up — turns it into a `daemon.crash` alert and deletes it.
|
|
9
|
+
*
|
|
10
|
+
* Three writers: the uncaught-exception handler (./crash.ts), the fatal
|
|
11
|
+
* startup path (app.ts), and the handoff watcher (./handoff.ts) when a
|
|
12
|
+
* `/restart` successor never served. Last write wins; the file is tiny on
|
|
13
|
+
* purpose and never grows. This module imports nothing from core so the
|
|
14
|
+
* handoff watcher's bare runtime can carry it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { dirs } from "../../util/paths.js";
|
|
20
|
+
|
|
21
|
+
export type CrashKind = "uncaught" | "startup" | "handoff";
|
|
22
|
+
|
|
23
|
+
export type CrashMarker = {
|
|
24
|
+
kind: CrashKind;
|
|
25
|
+
/** ISO time of the crash. */
|
|
26
|
+
at: string;
|
|
27
|
+
message: string;
|
|
28
|
+
/** The first few stack lines, frames only. */
|
|
29
|
+
stack: string[];
|
|
30
|
+
pid: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STACK_LINES = 5;
|
|
34
|
+
const MAX_MESSAGE = 500;
|
|
35
|
+
|
|
36
|
+
function crashMarkerPath(): string {
|
|
37
|
+
return resolve(dirs.data, "last-crash.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Error → message plus its top frames, bounded. */
|
|
41
|
+
function describe(err: unknown): { message: string; stack: string[] } {
|
|
42
|
+
if (!(err instanceof Error)) {
|
|
43
|
+
return { message: String(err).slice(0, MAX_MESSAGE), stack: [] };
|
|
44
|
+
}
|
|
45
|
+
const stack = (err.stack ?? "")
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter((line) => line.startsWith("at "))
|
|
49
|
+
.slice(0, STACK_LINES);
|
|
50
|
+
const name = err.name && err.name !== "Error" ? `${err.name}: ` : "";
|
|
51
|
+
return { message: `${name}${err.message}`.slice(0, MAX_MESSAGE), stack };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record a crash. Synchronous by design — the caller is about to exit.
|
|
56
|
+
* Throws on failure (a full disk); crash-path callers wrap it in
|
|
57
|
+
* `crashStep`.
|
|
58
|
+
*/
|
|
59
|
+
export function writeCrashMarker(
|
|
60
|
+
kind: CrashKind,
|
|
61
|
+
err: unknown,
|
|
62
|
+
opts: { path?: string; keepExisting?: boolean } = {},
|
|
63
|
+
): void {
|
|
64
|
+
const marker: CrashMarker = {
|
|
65
|
+
kind,
|
|
66
|
+
at: new Date().toISOString(),
|
|
67
|
+
...describe(err),
|
|
68
|
+
pid: process.pid,
|
|
69
|
+
};
|
|
70
|
+
// keepExisting: the handoff watcher must not clobber the more specific
|
|
71
|
+
// marker a successor that crashed on its own already left.
|
|
72
|
+
writeFileSync(opts.path ?? crashMarkerPath(), JSON.stringify(marker), {
|
|
73
|
+
mode: 0o600,
|
|
74
|
+
flag: opts.keepExisting ? "wx" : "w",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read and delete the marker. Null when there is none; a corrupt marker
|
|
80
|
+
* is deleted and reported as null. Never throws.
|
|
81
|
+
*/
|
|
82
|
+
export function takeCrashMarker(
|
|
83
|
+
path: string = crashMarkerPath(),
|
|
84
|
+
): CrashMarker | null {
|
|
85
|
+
let raw: string;
|
|
86
|
+
try {
|
|
87
|
+
raw = readFileSync(path, "utf-8");
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
unlinkSync(path);
|
|
93
|
+
} catch {
|
|
94
|
+
/* a marker we can't delete would re-announce next boot — acceptable */
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(raw) as Partial<CrashMarker>;
|
|
98
|
+
if (typeof parsed.at !== "string" || typeof parsed.message !== "string")
|
|
99
|
+
return null;
|
|
100
|
+
return {
|
|
101
|
+
kind: parsed.kind ?? "uncaught",
|
|
102
|
+
at: parsed.at,
|
|
103
|
+
message: parsed.message,
|
|
104
|
+
stack: Array.isArray(parsed.stack) ? parsed.stack : [],
|
|
105
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cross-boot throttle. Under systemd's `Restart=on-failure` a crash loop
|
|
114
|
+
* boots a fresh process every few seconds, and each would otherwise send
|
|
115
|
+
* its own alert — the in-process cooldown can't see across restarts.
|
|
116
|
+
* Returns how many crashes were held back since the last announcement
|
|
117
|
+
* (fold them into the message), or null when this one is inside the
|
|
118
|
+
* cooldown and should only be counted. Never throws.
|
|
119
|
+
*/
|
|
120
|
+
export function claimCrashAnnouncement(
|
|
121
|
+
cooldownMs: number,
|
|
122
|
+
path: string = resolve(dirs.data, "crash-announced.json"),
|
|
123
|
+
now: number = Date.now(),
|
|
124
|
+
): number | null {
|
|
125
|
+
let prior = { at: 0, held: 0 };
|
|
126
|
+
try {
|
|
127
|
+
prior = { ...prior, ...JSON.parse(readFileSync(path, "utf-8")) };
|
|
128
|
+
} catch {
|
|
129
|
+
/* first crash, or an unreadable ledger — announce */
|
|
130
|
+
}
|
|
131
|
+
const inCooldown = now - prior.at < cooldownMs;
|
|
132
|
+
const next = inCooldown
|
|
133
|
+
? { at: prior.at, held: prior.held + 1 }
|
|
134
|
+
: { at: now, held: 0 };
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(path, JSON.stringify(next), { mode: 0o600 });
|
|
137
|
+
} catch {
|
|
138
|
+
/* worst case the next crash announces too */
|
|
139
|
+
}
|
|
140
|
+
return inCooldown ? null : prior.held;
|
|
141
|
+
}
|
package/src/core/daemon/crash.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { logError, logWarn } from "../../util/log.js";
|
|
22
|
+
import { writeCrashMarker } from "./crash-marker.js";
|
|
23
|
+
import { noteUnhandledRejection } from "./health-alerts.js";
|
|
22
24
|
import { removePidRecordIfOwnedBy } from "./pidfile.js";
|
|
23
25
|
import { spawnSuccessor } from "./respawn.js";
|
|
24
26
|
|
|
@@ -65,7 +67,9 @@ export function crashCleanup(hooks: CrashHooks): void {
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* `process.on("uncaughtException")` body. Cleanup happens before the
|
|
68
|
-
* crash is reported, never after.
|
|
70
|
+
* crash is reported, never after. The crash marker sits between the two:
|
|
71
|
+
* it is how the operator hears about this crash (the next boot announces
|
|
72
|
+
* it), but it is not worth a pidfile or a successor.
|
|
69
73
|
*/
|
|
70
74
|
export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
|
|
71
75
|
// EPIPE errors from network sockets (e.g. Telegram MTProto) are transient —
|
|
@@ -77,6 +81,7 @@ export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
|
|
|
77
81
|
return;
|
|
78
82
|
}
|
|
79
83
|
crashCleanup(hooks);
|
|
84
|
+
crashStep("crash marker", () => writeCrashMarker("uncaught", err));
|
|
80
85
|
crashStep("crash report", () => logError("bot", "Uncaught exception", err));
|
|
81
86
|
process.exit(1);
|
|
82
87
|
}
|
|
@@ -86,7 +91,8 @@ export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
|
|
|
86
91
|
* the stack: a bare "Unhandled rejection: ENOSPC: no space left on device,
|
|
87
92
|
* write" says nothing about which code path forgot its `.catch()`. Async fs
|
|
88
93
|
* errors carry `path`/`syscall` rather than useful frames, so those ride
|
|
89
|
-
* along in the message too.
|
|
94
|
+
* along in the message too. Repeats raise `daemon.unhandled`
|
|
95
|
+
* (./health-alerts.ts).
|
|
90
96
|
*/
|
|
91
97
|
export function handleUnhandledRejection(reason: unknown): void {
|
|
92
98
|
crashStep("rejection report", () => {
|
|
@@ -102,4 +108,5 @@ export function handleUnhandledRejection(reason: unknown): void {
|
|
|
102
108
|
reason,
|
|
103
109
|
);
|
|
104
110
|
});
|
|
111
|
+
crashStep("rejection alarm", () => noteUnhandledRejection(reason));
|
|
105
112
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { dirname, resolve } from "node:path";
|
|
28
28
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
29
29
|
import { startDaemon, type StartOutcome } from "./control.js";
|
|
30
|
+
import { writeCrashMarker } from "./crash-marker.js";
|
|
30
31
|
import { findRunningInstance, type RunningInstance } from "./discovery.js";
|
|
31
32
|
import { isProcessAlive } from "./pidfile.js";
|
|
32
33
|
|
|
@@ -130,6 +131,19 @@ function toOutcome(started: StartOutcome, why: string): HandoffOutcome {
|
|
|
130
131
|
return { ok: false, reason: `${why}; restart ${describeStart(started)}` };
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Leave a crash marker so whichever daemon comes up next tells the
|
|
136
|
+
* operator the restart failed. A successor that crashed on its own left
|
|
137
|
+
* a more specific marker already; that one is kept.
|
|
138
|
+
*/
|
|
139
|
+
function markHandoffFailure(why: string): void {
|
|
140
|
+
try {
|
|
141
|
+
writeCrashMarker("handoff", why, { keepExisting: true });
|
|
142
|
+
} catch {
|
|
143
|
+
/* EEXIST (the successor's own marker) or a full disk — nothing to add */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
133
147
|
/**
|
|
134
148
|
* Verify the handoff, and repair it if it failed. Never throws: this
|
|
135
149
|
* process exists only to make the outcome known.
|
|
@@ -153,6 +167,7 @@ export async function watchHandoff(
|
|
|
153
167
|
"shutdown",
|
|
154
168
|
`Handoff failed — ${why}; starting Talon the way \`talon start\` does`,
|
|
155
169
|
);
|
|
170
|
+
markHandoffFailure(why);
|
|
156
171
|
const start = opts.start ?? startDaemon;
|
|
157
172
|
const started = await start({
|
|
158
173
|
pkgRoot: opts.pkgRoot,
|