talon-agent 5.18.2 → 5.19.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.
Files changed (195) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -2
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/one-shot.ts +32 -2
  8. package/src/backend/claude-sdk/stream.ts +2 -2
  9. package/src/backend/codex/auth.ts +1 -1
  10. package/src/backend/codex/handler/message.ts +11 -11
  11. package/src/backend/codex/init.ts +4 -9
  12. package/src/backend/codex/mcp-config.ts +1 -2
  13. package/src/backend/codex/oauth-incompat.ts +8 -4
  14. package/src/backend/codex/one-shot.ts +1 -1
  15. package/src/backend/openai-agents/builtins.ts +55 -27
  16. package/src/backend/openai-agents/factory.ts +3 -3
  17. package/src/backend/openai-agents/handler/message.ts +3 -5
  18. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  19. package/src/backend/remote-server/chat-turn.ts +6 -6
  20. package/src/backend/remote-server/events.ts +1 -5
  21. package/src/backend/remote-server/index.ts +0 -1
  22. package/src/backend/remote-server/messages.ts +3 -7
  23. package/src/backend/remote-server/one-shot.ts +1 -3
  24. package/src/backend/remote-server/session-helpers.ts +1 -4
  25. package/src/backend/remote-server/sse-stream.ts +8 -9
  26. package/src/backend/runtime/metrics.ts +7 -13
  27. package/src/backend/runtime/sleep.ts +1 -2
  28. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  29. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  30. package/src/bootstrap.ts +9 -1
  31. package/src/cli/doctor.ts +3 -0
  32. package/src/cli/index.ts +8 -10
  33. package/src/cli/logs.ts +148 -9
  34. package/src/cli/setup.ts +9 -11
  35. package/src/cli/status.ts +29 -0
  36. package/src/core/agent-runtime/README.md +5 -19
  37. package/src/core/agent-runtime/events.ts +3 -39
  38. package/src/core/agent-runtime/model-ref.ts +0 -8
  39. package/src/core/agents/registry.ts +65 -2
  40. package/src/core/auth/expiry-monitor.ts +9 -1
  41. package/src/core/auth/login-flow.ts +9 -1
  42. package/src/core/auth/status.ts +31 -3
  43. package/src/core/background/cron/scheduler.ts +25 -5
  44. package/src/core/background/dream/index.ts +29 -10
  45. package/src/core/background/failure-backoff.ts +30 -0
  46. package/src/core/background/heartbeat/agent.ts +2 -27
  47. package/src/core/background/heartbeat/index.ts +0 -2
  48. package/src/core/background/heartbeat/scheduler.ts +21 -13
  49. package/src/core/background/heartbeat/state.ts +10 -2
  50. package/src/core/background/isolated-agent.ts +6 -2
  51. package/src/core/background/pulse/pulse.ts +9 -0
  52. package/src/core/background/triggers/exit.ts +54 -0
  53. package/src/core/background/triggers/index.ts +1 -3
  54. package/src/core/background/triggers/resume.ts +2 -4
  55. package/src/core/backup/archive/tar.ts +14 -4
  56. package/src/core/backup/plan.ts +1 -0
  57. package/src/core/backup/restore.ts +21 -13
  58. package/src/core/backup/scheduler.ts +28 -9
  59. package/src/core/backup/snapshot.ts +43 -10
  60. package/src/core/backup/store.ts +7 -19
  61. package/src/core/backup/targets.ts +69 -17
  62. package/src/core/config/index.ts +14 -0
  63. package/src/core/daemon/crash-marker.ts +141 -0
  64. package/src/core/daemon/crash.ts +9 -2
  65. package/src/core/daemon/handoff.ts +15 -0
  66. package/src/core/daemon/health-alerts.ts +297 -0
  67. package/src/core/daemon/log-reader.ts +289 -0
  68. package/src/core/doctor/index.ts +18 -2
  69. package/src/core/doctor/logs.ts +124 -0
  70. package/src/core/doctor/types.ts +1 -1
  71. package/src/core/engine/backend-controller/index.ts +1 -13
  72. package/src/core/engine/backend-router/router.ts +1 -1
  73. package/src/core/engine/dispatcher.ts +55 -2
  74. package/src/core/engine/fault-text.ts +40 -0
  75. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  76. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  77. package/src/core/engine/gateway-actions/history.ts +2 -4
  78. package/src/core/engine/gateway-actions/mesh.ts +26 -14
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  81. package/src/core/engine/gateway.ts +60 -1
  82. package/src/core/engine/turn-health.ts +222 -0
  83. package/src/core/errors.ts +2 -2
  84. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  85. package/src/core/frontend-runtime/alerts.ts +130 -0
  86. package/src/core/mcp-hub/children.ts +78 -29
  87. package/src/core/mcp-hub/index.ts +21 -18
  88. package/src/core/mcp-hub/proxy-server.ts +8 -4
  89. package/src/core/mcp-hub/talon-server.ts +5 -12
  90. package/src/core/mesh/credentials/store.ts +16 -1
  91. package/src/core/mesh/devices/registry.ts +1 -17
  92. package/src/core/mesh/devices/service.ts +49 -17
  93. package/src/core/mesh/devices/teleport.ts +14 -2
  94. package/src/core/mesh/links/node-binaries.ts +13 -6
  95. package/src/core/mesh/persist.ts +22 -10
  96. package/src/core/mesh/transfers/device-files.ts +5 -23
  97. package/src/core/mesh/transfers/transfers.ts +16 -3
  98. package/src/core/models/active-model.ts +2 -55
  99. package/src/core/plugin/actions.ts +19 -20
  100. package/src/core/plugin/builtins.ts +80 -90
  101. package/src/core/plugin/index.ts +1 -4
  102. package/src/core/plugin/loader.ts +25 -33
  103. package/src/core/plugin/mcp.ts +3 -5
  104. package/src/core/plugin/registry.ts +19 -35
  105. package/src/core/plugin/types.ts +2 -5
  106. package/src/core/prompt/assemble.ts +15 -3
  107. package/src/core/scripts/lua.ts +6 -2
  108. package/src/core/tasks/table.ts +8 -2
  109. package/src/core/tools/bridge.ts +2 -4
  110. package/src/core/tools/chat/cross-send.ts +1 -1
  111. package/src/core/tools/chat/messaging.ts +1 -1
  112. package/src/core/tools/index.ts +2 -2
  113. package/src/core/tools/mcp-env.ts +2 -59
  114. package/src/core/tools/ops/agents.ts +22 -1
  115. package/src/core/tools/schemas.ts +4 -9
  116. package/src/core/vfs/fusefs.ts +0 -5
  117. package/src/core/vfs/index.ts +9 -2
  118. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  119. package/src/core/vfs/mounts/proc.ts +17 -1
  120. package/src/core/vfs/workspace.ts +7 -3
  121. package/src/core/weaver/shuttle.ts +8 -1
  122. package/src/core/weaver/turn-log.ts +320 -0
  123. package/src/core/weaver/weaver.ts +48 -5
  124. package/src/frontend/discord/actions/index.ts +8 -1
  125. package/src/frontend/discord/diagnostics.ts +82 -6
  126. package/src/frontend/discord/handlers/index.ts +0 -2
  127. package/src/frontend/discord/middleware.ts +7 -13
  128. package/src/frontend/discord/runtime.ts +1 -3
  129. package/src/frontend/health/delivery.ts +115 -0
  130. package/src/frontend/health/outage.ts +116 -0
  131. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  132. package/src/frontend/native/bridge/server.ts +106 -21
  133. package/src/frontend/native/index.ts +1 -1
  134. package/src/frontend/native/media/media.ts +5 -1
  135. package/src/frontend/native/runtime.ts +12 -7
  136. package/src/frontend/native/surface/handlers.ts +1 -1
  137. package/src/frontend/native/surface/memory.ts +1 -1
  138. package/src/frontend/native/surface/models.ts +3 -3
  139. package/src/frontend/native/surface/settings.ts +20 -8
  140. package/src/frontend/native/turn/context.ts +6 -8
  141. package/src/frontend/native/turn/turn-meta.ts +2 -5
  142. package/src/frontend/native/turn/turn.ts +8 -10
  143. package/src/frontend/presentation/format.ts +2 -4
  144. package/src/frontend/presentation/session-status.ts +2 -6
  145. package/src/frontend/teams/actions.ts +8 -1
  146. package/src/frontend/teams/graph.ts +0 -1
  147. package/src/frontend/teams/index.ts +1 -4
  148. package/src/frontend/teams/poll.ts +40 -2
  149. package/src/frontend/teams/runtime.ts +14 -5
  150. package/src/frontend/telegram/actions/index.ts +4 -1
  151. package/src/frontend/telegram/actions/send.ts +8 -0
  152. package/src/frontend/telegram/handlers/context.ts +13 -2
  153. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  154. package/src/frontend/telegram/handlers/index.ts +0 -2
  155. package/src/frontend/telegram/index.ts +35 -9
  156. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  157. package/src/frontend/telegram/userbot.ts +100 -36
  158. package/src/frontend/terminal/builtins/session.ts +2 -2
  159. package/src/frontend/terminal/index.ts +1 -3
  160. package/src/frontend/terminal/renderer.ts +2 -18
  161. package/src/frontend/whatsapp/actions/index.ts +12 -1
  162. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  163. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  164. package/src/frontend/whatsapp/connection/health.ts +89 -0
  165. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  166. package/src/frontend/whatsapp/runtime.ts +11 -5
  167. package/src/native/blake3.ts +28 -2
  168. package/src/native/fusefs.ts +23 -5
  169. package/src/native/registry.ts +1 -1
  170. package/src/native/warden.ts +33 -5
  171. package/src/plugins/github/index.ts +0 -1
  172. package/src/plugins/mempalace/index.ts +9 -3
  173. package/src/plugins/playwright/index.ts +2 -4
  174. package/src/plugins/playwright/provision.ts +12 -4
  175. package/src/storage/chat-settings.ts +6 -1
  176. package/src/storage/cron.ts +29 -4
  177. package/src/storage/daily-log.ts +43 -47
  178. package/src/storage/db.ts +61 -33
  179. package/src/storage/history.ts +6 -1
  180. package/src/storage/journal.ts +9 -2
  181. package/src/storage/kv.ts +19 -6
  182. package/src/storage/media-index.ts +28 -6
  183. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  184. package/src/storage/repositories/sessions-repo.ts +10 -3
  185. package/src/storage/scripts.ts +24 -13
  186. package/src/storage/sessions.ts +11 -2
  187. package/src/storage/skills.ts +21 -2
  188. package/src/storage/stickers.ts +17 -3
  189. package/src/storage/triggers.ts +8 -3
  190. package/src/storage/turn-meta.ts +25 -7
  191. package/src/util/log.ts +189 -6
  192. package/src/util/logging/turn-scope.ts +85 -0
  193. package/src/util/time.ts +3 -3
  194. package/src/util/watchdog.ts +30 -0
  195. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -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
+ }
@@ -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,
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Daemon health alerts — the system-level producers for
3
+ * core/frontend-runtime/alerts.ts. Per-subsystem faults (a frontend that
4
+ * lost its connection, a backend that lost its login) are raised by their
5
+ * owners; what lives here is what no single subsystem can see:
6
+ *
7
+ * - `disk.low` — free space under the Talon root. On 2026-09-18 the disk
8
+ * filled and every subsystem failed on its own, silently: the log sink
9
+ * paused, SQLite and the session stores threw ENOSPC, the crash
10
+ * handler died inside itself. One probe that says so is worth more
11
+ * than any of their errors.
12
+ * - `errors.spike` — a burst of `logError` calls, whatever the source.
13
+ * - `daemon.unhandled` — repeated unhandled promise rejections (logged,
14
+ * never fatal, so otherwise invisible).
15
+ * - `daemon.crash` — the previous process died; announced from the
16
+ * marker it left (./crash-marker.ts) once this one can talk.
17
+ *
18
+ * Started by app.ts once the frontends are up, stopped in its shutdown.
19
+ */
20
+
21
+ import { statfs } from "node:fs/promises";
22
+ import { dirs } from "../../util/paths.js";
23
+ import {
24
+ log,
25
+ logDebug,
26
+ logWarn,
27
+ onLogError,
28
+ type LogComponent,
29
+ } from "../../util/log.js";
30
+ import {
31
+ raiseAlert,
32
+ resolveAlert,
33
+ type AlertSeverity,
34
+ } from "../frontend-runtime/alerts.js";
35
+ import {
36
+ claimCrashAnnouncement,
37
+ takeCrashMarker,
38
+ type CrashMarker,
39
+ } from "./crash-marker.js";
40
+
41
+ const MIN = 60_000;
42
+ const MIB = 1024 * 1024;
43
+ const GIB = 1024 * MIB;
44
+
45
+ // ── Rate alarms ──────────────────────────────────────────────────────────────
46
+
47
+ type RateAlarmOptions<T> = {
48
+ key: string;
49
+ severity: AlertSeverity;
50
+ /** Raise at this many samples inside `windowMs`. */
51
+ threshold: number;
52
+ windowMs: number;
53
+ /** Resolve once the rate has stayed under threshold this long. */
54
+ quietMs: number;
55
+ describe: (samples: readonly T[]) => string;
56
+ recovered: string;
57
+ };
58
+
59
+ /** Hard cap on retained samples, so an error storm can't grow memory. */
60
+ const MAX_SAMPLES = 500;
61
+ const QUIET_CHECK_MS = MIN;
62
+
63
+ /**
64
+ * "N of these within a window" → one alert, resolved after a quiet spell.
65
+ * Raises once per episode: repeats while hot only move the quiet clock.
66
+ */
67
+ class RateAlarm<T> {
68
+ private samples: { at: number; value: T }[] = [];
69
+ private lastHotAt = 0;
70
+ private quietTimer: ReturnType<typeof setInterval> | null = null;
71
+
72
+ constructor(private readonly opts: RateAlarmOptions<T>) {}
73
+
74
+ record(value: T): void {
75
+ const now = Date.now();
76
+ const cutoff = now - this.opts.windowMs;
77
+ this.samples.push({ at: now, value });
78
+ const firstLive = this.samples.findIndex((s) => s.at > cutoff);
79
+ const drop = Math.max(firstLive, this.samples.length - MAX_SAMPLES);
80
+ if (drop > 0) this.samples.splice(0, drop);
81
+ if (this.samples.length < this.opts.threshold) return;
82
+ this.lastHotAt = now;
83
+ if (this.quietTimer) return;
84
+ raiseAlert(
85
+ this.opts.key,
86
+ this.opts.describe(this.samples.map((s) => s.value)),
87
+ { severity: this.opts.severity },
88
+ );
89
+ this.quietTimer = setInterval(() => this.checkQuiet(), QUIET_CHECK_MS);
90
+ this.quietTimer.unref();
91
+ }
92
+
93
+ private checkQuiet(): void {
94
+ if (Date.now() - this.lastHotAt < this.opts.quietMs) return;
95
+ this.stop();
96
+ resolveAlert(this.opts.key, this.opts.recovered);
97
+ }
98
+
99
+ stop(): void {
100
+ if (this.quietTimer) clearInterval(this.quietTimer);
101
+ this.quietTimer = null;
102
+ this.samples = [];
103
+ }
104
+ }
105
+
106
+ type LoggedError = { component: LogComponent; text: string };
107
+
108
+ function errorText(message: string, err: unknown): string {
109
+ if (err === undefined) return message;
110
+ const detail = err instanceof Error ? err.message : String(err);
111
+ return `${message}: ${detail}`;
112
+ }
113
+
114
+ function describeErrorSpike(samples: readonly LoggedError[]): string {
115
+ const counts = new Map<string, number>();
116
+ for (const s of samples)
117
+ counts.set(s.component, (counts.get(s.component) ?? 0) + 1);
118
+ const top = [...counts]
119
+ .sort((a, b) => b[1] - a[1])
120
+ .slice(0, 3)
121
+ .map(([component, n]) => `${component} ×${n}`)
122
+ .join(", ");
123
+ const latest = samples[samples.length - 1];
124
+ const latestText = latest
125
+ ? `${latest.component}: ${latest.text}`.slice(0, 200)
126
+ : "";
127
+ return (
128
+ `Talon logged ${samples.length} errors in 5 min (${top}). ` +
129
+ `Latest: ${latestText}`
130
+ );
131
+ }
132
+
133
+ const errorSpike = new RateAlarm<LoggedError>({
134
+ key: "errors.spike",
135
+ severity: "warn",
136
+ threshold: 20,
137
+ windowMs: 5 * MIN,
138
+ quietMs: 15 * MIN,
139
+ describe: describeErrorSpike,
140
+ recovered: "Error rate is back to normal.",
141
+ });
142
+
143
+ const unhandled = new RateAlarm<string>({
144
+ key: "daemon.unhandled",
145
+ severity: "error",
146
+ threshold: 3,
147
+ windowMs: 10 * MIN,
148
+ quietMs: 10 * MIN,
149
+ describe: (samples) =>
150
+ `Talon hit ${samples.length} unhandled promise rejections in 10 min — ` +
151
+ `an error some code path never caught. Latest: ` +
152
+ `${samples[samples.length - 1]?.slice(0, 200) ?? ""}`,
153
+ recovered: "No unhandled promise rejections for 10 min.",
154
+ });
155
+
156
+ /** Feed one unhandled rejection to the alarm (core/daemon/crash.ts). */
157
+ export function noteUnhandledRejection(reason: unknown): void {
158
+ unhandled.record(
159
+ reason instanceof Error ? reason.message : String(reason ?? "unknown"),
160
+ );
161
+ }
162
+
163
+ // ── Disk space ───────────────────────────────────────────────────────────────
164
+
165
+ const DISK_PROBE_MS = 5 * MIN;
166
+ const DISK_LOW_BYTES = GIB;
167
+ const DISK_LOW_PERCENT = 5;
168
+ const DISK_CRITICAL_BYTES = 256 * MIB;
169
+ // Hysteresis: a disk hovering at the line must not flap raise/resolve.
170
+ const DISK_CLEAR_BYTES = 1.5 * GIB;
171
+ const DISK_CLEAR_PERCENT = 7.5;
172
+
173
+ type StatFs = (path: string) => Promise<{
174
+ bavail: number;
175
+ bsize: number;
176
+ blocks: number;
177
+ }>;
178
+
179
+ function formatBytes(bytes: number): string {
180
+ return bytes >= GIB
181
+ ? `${(bytes / GIB).toFixed(1)} GiB`
182
+ : `${Math.round(bytes / MIB)} MiB`;
183
+ }
184
+
185
+ /** One statfs of `path` → raise, resolve, or (in the hysteresis band) hold. */
186
+ async function probeDisk(path: string, stat: StatFs = statfs): Promise<void> {
187
+ let free: number;
188
+ let percent: number;
189
+ try {
190
+ const s = await stat(path);
191
+ free = s.bavail * s.bsize;
192
+ const total = s.blocks * s.bsize;
193
+ percent = total > 0 ? (free / total) * 100 : 100;
194
+ } catch (err) {
195
+ logDebug(
196
+ "watchdog",
197
+ `disk.probe path=${path} failed: ${err instanceof Error ? err.message : err}`,
198
+ );
199
+ return;
200
+ }
201
+ const pct = percent.toFixed(1);
202
+ logDebug("watchdog", `disk.probe path=${path} free=${free} pct=${pct}`);
203
+ if (free < DISK_LOW_BYTES || percent < DISK_LOW_PERCENT) {
204
+ raiseAlert(
205
+ "disk.low",
206
+ `Disk almost full: ${formatBytes(free)} free (${pct}%) on ${path}. ` +
207
+ `Talon fails when it runs out — logs, sessions and the database stop saving.`,
208
+ { severity: free < DISK_CRITICAL_BYTES ? "critical" : "error" },
209
+ );
210
+ } else if (free >= DISK_CLEAR_BYTES && percent >= DISK_CLEAR_PERCENT) {
211
+ resolveAlert(
212
+ "disk.low",
213
+ `Disk space recovered: ${formatBytes(free)} free on ${path}.`,
214
+ );
215
+ }
216
+ }
217
+
218
+ // ── Crash announcement ───────────────────────────────────────────────────────
219
+
220
+ /** A crash loop alerts once per this, across restarts. */
221
+ const CRASH_ALERT_COOLDOWN_MS = 30 * MIN;
222
+ /** Up this long after a crash restart → the crash alert resolves. */
223
+ const CRASH_STABLE_MS = 30 * MIN;
224
+
225
+ function crashMessage(marker: CrashMarker, held: number): string {
226
+ const when = `${marker.at.slice(0, 16).replace("T", " ")} UTC`;
227
+ const frame = marker.stack[0] ? ` (${marker.stack[0]})` : "";
228
+ const more = held > 0 ? ` ${held} more crash(es) since the last alert.` : "";
229
+ switch (marker.kind) {
230
+ case "startup":
231
+ return `Talon failed to start at ${when}: ${marker.message}${frame}. It is running again now.${more}`;
232
+ case "handoff":
233
+ return `A Talon restart at ${when} didn't come up (${marker.message}); it was started again.${more}`;
234
+ default:
235
+ return `Talon restarted after a crash at ${when}: ${marker.message}${frame}${more}`;
236
+ }
237
+ }
238
+
239
+ let crashStableTimer: ReturnType<typeof setTimeout> | null = null;
240
+
241
+ /**
242
+ * Turn the previous process's crash marker, if any, into a `daemon.crash`
243
+ * alert. Call once the admin notifier and frontends are up.
244
+ */
245
+ export function announceLastCrash(
246
+ markerPath?: string,
247
+ ledgerPath?: string,
248
+ ): void {
249
+ const marker = takeCrashMarker(markerPath);
250
+ if (!marker) return;
251
+ log(
252
+ "bot",
253
+ `crash.marker kind=${marker.kind} at=${marker.at} pid=${marker.pid} ` +
254
+ `error=${marker.message.slice(0, 200)}`,
255
+ );
256
+ const held = claimCrashAnnouncement(CRASH_ALERT_COOLDOWN_MS, ledgerPath);
257
+ if (held === null) {
258
+ logWarn("bot", "crash.marker held back — crash loop inside the cooldown");
259
+ return;
260
+ }
261
+ raiseAlert("daemon.crash", crashMessage(marker, held), { severity: "error" });
262
+ crashStableTimer = setTimeout(() => {
263
+ crashStableTimer = null;
264
+ resolveAlert("daemon.crash", "Talon has stayed up since the crash restart");
265
+ }, CRASH_STABLE_MS);
266
+ crashStableTimer.unref();
267
+ }
268
+
269
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
270
+
271
+ let diskTimer: ReturnType<typeof setInterval> | null = null;
272
+
273
+ /** Start the probes and the error-rate listener. Idempotent. */
274
+ export function startHealthAlerts(
275
+ opts: { dataDir?: string; statfs?: StatFs } = {},
276
+ ): void {
277
+ if (diskTimer) return;
278
+ const dir = opts.dataDir ?? dirs.root;
279
+ const probe = (): void => void probeDisk(dir, opts.statfs);
280
+ probe();
281
+ diskTimer = setInterval(probe, DISK_PROBE_MS);
282
+ diskTimer.unref();
283
+ onLogError((component, message, err) =>
284
+ errorSpike.record({ component, text: errorText(message, err) }),
285
+ );
286
+ }
287
+
288
+ /** Stop every timer and detach the listener. Safe to call twice. */
289
+ export function stopHealthAlerts(): void {
290
+ if (diskTimer) clearInterval(diskTimer);
291
+ diskTimer = null;
292
+ if (crashStableTimer) clearTimeout(crashStableTimer);
293
+ crashStableTimer = null;
294
+ onLogError(null);
295
+ errorSpike.stop();
296
+ unhandled.stop();
297
+ }