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.
Files changed (194) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +8 -3
  5. package/package.json +4 -2
  6. package/prompts/system/agent-brief.md +20 -3
  7. package/src/app.ts +13 -0
  8. package/src/backend/claude-sdk/handler.ts +4 -4
  9. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  10. package/src/backend/claude-sdk/stream.ts +2 -2
  11. package/src/backend/codex/auth.ts +1 -1
  12. package/src/backend/codex/handler/message.ts +11 -11
  13. package/src/backend/codex/init.ts +4 -9
  14. package/src/backend/codex/mcp-config.ts +1 -2
  15. package/src/backend/codex/oauth-incompat.ts +8 -4
  16. package/src/backend/codex/one-shot.ts +1 -1
  17. package/src/backend/openai-agents/builtins.ts +55 -27
  18. package/src/backend/openai-agents/factory.ts +3 -3
  19. package/src/backend/openai-agents/handler/message.ts +3 -5
  20. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  21. package/src/backend/remote-server/chat-turn.ts +6 -6
  22. package/src/backend/remote-server/events.ts +1 -5
  23. package/src/backend/remote-server/index.ts +0 -1
  24. package/src/backend/remote-server/messages.ts +3 -7
  25. package/src/backend/remote-server/one-shot.ts +1 -3
  26. package/src/backend/remote-server/session-helpers.ts +1 -4
  27. package/src/backend/remote-server/sse-stream.ts +8 -9
  28. package/src/backend/runtime/metrics.ts +7 -13
  29. package/src/backend/runtime/sleep.ts +1 -2
  30. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  31. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  32. package/src/bootstrap.ts +9 -1
  33. package/src/cli/doctor.ts +3 -0
  34. package/src/cli/index.ts +8 -10
  35. package/src/cli/logs.ts +148 -9
  36. package/src/cli/setup.ts +9 -11
  37. package/src/cli/status.ts +29 -0
  38. package/src/core/agent-runtime/README.md +5 -19
  39. package/src/core/agent-runtime/events.ts +3 -39
  40. package/src/core/agent-runtime/model-ref.ts +0 -8
  41. package/src/core/agents/registry.ts +49 -2
  42. package/src/core/auth/expiry-monitor.ts +9 -1
  43. package/src/core/auth/login-flow.ts +9 -1
  44. package/src/core/auth/status.ts +31 -3
  45. package/src/core/background/cron/scheduler.ts +25 -5
  46. package/src/core/background/dream/index.ts +29 -10
  47. package/src/core/background/failure-backoff.ts +30 -0
  48. package/src/core/background/heartbeat/agent.ts +2 -27
  49. package/src/core/background/heartbeat/index.ts +0 -2
  50. package/src/core/background/heartbeat/scheduler.ts +21 -13
  51. package/src/core/background/heartbeat/state.ts +10 -2
  52. package/src/core/background/isolated-agent.ts +6 -2
  53. package/src/core/background/pulse/pulse.ts +9 -0
  54. package/src/core/background/triggers/exit.ts +54 -0
  55. package/src/core/background/triggers/index.ts +1 -3
  56. package/src/core/background/triggers/resume.ts +2 -4
  57. package/src/core/backup/archive/tar.ts +14 -4
  58. package/src/core/backup/restore.ts +21 -13
  59. package/src/core/backup/scheduler.ts +28 -9
  60. package/src/core/backup/snapshot.ts +43 -10
  61. package/src/core/backup/store.ts +7 -19
  62. package/src/core/backup/targets.ts +69 -17
  63. package/src/core/config/index.ts +14 -0
  64. package/src/core/daemon/crash-marker.ts +141 -0
  65. package/src/core/daemon/crash.ts +9 -2
  66. package/src/core/daemon/handoff.ts +15 -0
  67. package/src/core/daemon/health-alerts.ts +297 -0
  68. package/src/core/daemon/log-reader.ts +289 -0
  69. package/src/core/doctor/index.ts +18 -2
  70. package/src/core/doctor/logs.ts +124 -0
  71. package/src/core/doctor/types.ts +1 -1
  72. package/src/core/engine/backend-controller/index.ts +1 -13
  73. package/src/core/engine/backend-router/router.ts +1 -1
  74. package/src/core/engine/dispatcher.ts +55 -2
  75. package/src/core/engine/fault-text.ts +40 -0
  76. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  77. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  78. package/src/core/engine/gateway-actions/history.ts +2 -4
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway.ts +60 -1
  81. package/src/core/engine/turn-health.ts +222 -0
  82. package/src/core/errors.ts +2 -2
  83. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  84. package/src/core/frontend-runtime/alerts.ts +130 -0
  85. package/src/core/mcp-hub/children.ts +78 -29
  86. package/src/core/mcp-hub/index.ts +21 -18
  87. package/src/core/mcp-hub/proxy-server.ts +8 -4
  88. package/src/core/mcp-hub/talon-server.ts +5 -12
  89. package/src/core/mesh/credentials/store.ts +16 -1
  90. package/src/core/mesh/devices/service.ts +26 -15
  91. package/src/core/mesh/devices/teleport.ts +14 -2
  92. package/src/core/mesh/links/node-binaries.ts +13 -6
  93. package/src/core/mesh/persist.ts +22 -10
  94. package/src/core/mesh/transfers/device-files.ts +5 -23
  95. package/src/core/mesh/transfers/transfers.ts +16 -3
  96. package/src/core/models/active-model.ts +2 -55
  97. package/src/core/plugin/actions.ts +19 -20
  98. package/src/core/plugin/builtins.ts +80 -90
  99. package/src/core/plugin/index.ts +1 -4
  100. package/src/core/plugin/loader.ts +25 -33
  101. package/src/core/plugin/mcp.ts +3 -5
  102. package/src/core/plugin/registry.ts +19 -35
  103. package/src/core/plugin/types.ts +2 -5
  104. package/src/core/prompt/assemble.ts +15 -3
  105. package/src/core/scripts/lua.ts +6 -2
  106. package/src/core/tasks/table.ts +8 -2
  107. package/src/core/tools/bridge.ts +2 -4
  108. package/src/core/tools/chat/cross-send.ts +1 -1
  109. package/src/core/tools/chat/messaging.ts +1 -1
  110. package/src/core/tools/index.ts +2 -2
  111. package/src/core/tools/mcp-env.ts +2 -59
  112. package/src/core/tools/ops/agents.ts +22 -1
  113. package/src/core/tools/schemas.ts +4 -9
  114. package/src/core/vfs/fusefs.ts +0 -5
  115. package/src/core/vfs/index.ts +9 -2
  116. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  117. package/src/core/vfs/mounts/proc.ts +17 -1
  118. package/src/core/vfs/workspace.ts +7 -3
  119. package/src/core/weaver/shuttle.ts +8 -1
  120. package/src/core/weaver/turn-log.ts +320 -0
  121. package/src/core/weaver/weaver.ts +48 -5
  122. package/src/frontend/discord/actions/index.ts +8 -1
  123. package/src/frontend/discord/diagnostics.ts +82 -6
  124. package/src/frontend/discord/handlers/index.ts +0 -2
  125. package/src/frontend/discord/middleware.ts +7 -13
  126. package/src/frontend/discord/runtime.ts +1 -3
  127. package/src/frontend/health/delivery.ts +115 -0
  128. package/src/frontend/health/outage.ts +116 -0
  129. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  130. package/src/frontend/native/bridge/server.ts +106 -21
  131. package/src/frontend/native/index.ts +1 -1
  132. package/src/frontend/native/media/media.ts +5 -1
  133. package/src/frontend/native/runtime.ts +12 -7
  134. package/src/frontend/native/surface/handlers.ts +1 -1
  135. package/src/frontend/native/surface/memory.ts +1 -1
  136. package/src/frontend/native/surface/models.ts +3 -3
  137. package/src/frontend/native/surface/settings.ts +20 -8
  138. package/src/frontend/native/turn/context.ts +6 -8
  139. package/src/frontend/native/turn/turn-meta.ts +2 -5
  140. package/src/frontend/native/turn/turn.ts +8 -10
  141. package/src/frontend/presentation/format.ts +2 -4
  142. package/src/frontend/presentation/session-status.ts +2 -6
  143. package/src/frontend/teams/actions.ts +8 -1
  144. package/src/frontend/teams/graph.ts +0 -1
  145. package/src/frontend/teams/index.ts +1 -4
  146. package/src/frontend/teams/poll.ts +40 -2
  147. package/src/frontend/teams/runtime.ts +14 -5
  148. package/src/frontend/telegram/actions/index.ts +4 -1
  149. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  150. package/src/frontend/telegram/actions/send.ts +12 -0
  151. package/src/frontend/telegram/handlers/context.ts +13 -2
  152. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  153. package/src/frontend/telegram/handlers/index.ts +0 -2
  154. package/src/frontend/telegram/index.ts +35 -9
  155. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  156. package/src/frontend/telegram/userbot.ts +147 -41
  157. package/src/frontend/terminal/builtins/session.ts +2 -2
  158. package/src/frontend/terminal/index.ts +1 -3
  159. package/src/frontend/terminal/renderer.ts +2 -18
  160. package/src/frontend/whatsapp/actions/index.ts +12 -1
  161. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  162. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  163. package/src/frontend/whatsapp/connection/health.ts +89 -0
  164. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  165. package/src/frontend/whatsapp/runtime.ts +11 -5
  166. package/src/native/blake3.ts +28 -2
  167. package/src/native/fusefs.ts +23 -5
  168. package/src/native/registry.ts +1 -1
  169. package/src/native/warden.ts +33 -5
  170. package/src/plugins/github/index.ts +0 -1
  171. package/src/plugins/mempalace/index.ts +9 -3
  172. package/src/plugins/playwright/index.ts +2 -4
  173. package/src/plugins/playwright/provision.ts +12 -4
  174. package/src/storage/chat-settings.ts +6 -1
  175. package/src/storage/cron.ts +29 -4
  176. package/src/storage/daily-log.ts +43 -47
  177. package/src/storage/db.ts +61 -33
  178. package/src/storage/history.ts +6 -1
  179. package/src/storage/journal.ts +9 -2
  180. package/src/storage/kv.ts +19 -6
  181. package/src/storage/media-index.ts +28 -6
  182. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  183. package/src/storage/repositories/sessions-repo.ts +10 -3
  184. package/src/storage/scripts.ts +24 -13
  185. package/src/storage/sessions.ts +11 -2
  186. package/src/storage/skills.ts +21 -2
  187. package/src/storage/stickers.ts +17 -3
  188. package/src/storage/triggers.ts +8 -3
  189. package/src/storage/turn-meta.ts +25 -7
  190. package/src/util/log.ts +189 -6
  191. package/src/util/logging/turn-scope.ts +85 -0
  192. package/src/util/time.ts +3 -3
  193. package/src/util/watchdog.ts +30 -0
  194. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -24,6 +24,7 @@ import type { Backend } from "../../agent-runtime/capabilities.js";
24
24
  import { taskTable } from "../../tasks/index.js";
25
25
  import { resolveBackgroundEffort } from "../effort.js";
26
26
  import { FailureBackoff } from "../failure-backoff.js";
27
+ import { faultText } from "../../engine/fault-text.js";
27
28
 
28
29
  // ── Types ────────────────────────────────────────────────────────────────────
29
30
 
@@ -61,7 +62,10 @@ let dreaming = false; // in-process guard (one dream at a time)
61
62
  * every message (observed live: a model outage produced 403 identical
62
63
  * consolidation failures). Exported for tests.
63
64
  */
64
- export const dreamFailureBackoff = new FailureBackoff();
65
+ export const dreamFailureBackoff = new FailureBackoff({
66
+ key: "dream.failing",
67
+ label: "Dream (memory consolidation)",
68
+ });
65
69
  let configRef: {
66
70
  model?: string;
67
71
  dreamModel?: string;
@@ -141,6 +145,16 @@ export async function forceDream(): Promise<void> {
141
145
  await executeDream("forced");
142
146
  }
143
147
 
148
+ /** Arm the failure backoff so maybeStartDream stays quiet for a while. */
149
+ function backOff(err: unknown): void {
150
+ const until = dreamFailureBackoff.fail(err);
151
+ logWarn(
152
+ "dream",
153
+ `dream.backoff failures=${dreamFailureBackoff.failures} ` +
154
+ `until=${new Date(until).toISOString()} error="${faultText(err)}"`,
155
+ );
156
+ }
157
+
144
158
  /** Shared dream execution — claims lock, runs agent, releases lock. */
145
159
  async function executeDream(trigger: "auto" | "forced"): Promise<void> {
146
160
  const state = readDreamState();
@@ -153,8 +167,13 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
153
167
 
154
168
  try {
155
169
  const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
156
- writeDreamState({ last_run: Date.now(), status: "idle" });
157
- dreamFailureBackoff.succeed();
170
+ // A run whose last_run didn't stick still looks overdue, and
171
+ // maybeStartDream would run it all again on the very next message.
172
+ if (writeDreamState({ last_run: Date.now(), status: "idle" })) {
173
+ dreamFailureBackoff.succeed();
174
+ } else {
175
+ backOff(new Error("dream state was not persisted"));
176
+ }
158
177
  log(
159
178
  "dream",
160
179
  `Memory consolidation complete (${trigger}), log: ${dreamLogPath}`,
@@ -164,12 +183,7 @@ async function executeDream(trigger: "auto" | "forced"): Promise<void> {
164
183
  writeDreamState({ last_run: state?.last_run ?? 0, status: "idle" });
165
184
  // A failed dream keeps the old last_run, so it would re-fire on the very
166
185
  // next invocation — back off instead (forceDream bypasses the window).
167
- const until = dreamFailureBackoff.fail(err);
168
- logWarn(
169
- "dream",
170
- `Backing off until ${new Date(until).toISOString()} ` +
171
- `after ${dreamFailureBackoff.failures} consecutive failure(s)`,
172
- );
186
+ backOff(err);
173
187
  if (trigger === "forced") throw err;
174
188
  } finally {
175
189
  dreaming = false;
@@ -427,10 +441,15 @@ function readDreamState(): DreamState | null {
427
441
  return parsed;
428
442
  }
429
443
 
430
- function writeDreamState(state: DreamState): void {
444
+ /**
445
+ * Persist the state. Returns whether last_run actually landed: kvSet logs
446
+ * and swallows a failed write (full disk), so the store is read back.
447
+ */
448
+ function writeDreamState(state: DreamState): boolean {
431
449
  const enriched: DreamState = {
432
450
  ...state,
433
451
  last_run_at: new Date(state.last_run).toISOString(),
434
452
  };
435
453
  kvSet(DREAM_STATE_KEY, enriched);
454
+ return readDreamState()?.last_run === state.last_run;
436
455
  }
@@ -15,8 +15,16 @@
15
15
  *
16
16
  * Backoff state is in-memory only: a process restart forgets it, which is
17
17
  * fine — the first post-restart attempt either works or re-arms the window.
18
+ *
19
+ * A holder built with an `alert` also tells the operator: once the streak
20
+ * reaches its threshold (2 by default — one failure is a blip, a second in
21
+ * a row after the backoff is a broken component) it raises `alert.key`
22
+ * with the error, and the next success resolves it.
18
23
  */
19
24
 
25
+ import { faultText } from "../engine/fault-text.js";
26
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
27
+
20
28
  const FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
21
29
  const FAILURE_BACKOFF_MAX_MS = 60 * 60 * 1000;
22
30
  /** Safety margin added past a parsed limit-reset time (clock skew, rollout). */
@@ -70,6 +78,16 @@ export function failureBackoffUntil(
70
78
  return now + exp;
71
79
  }
72
80
 
81
+ /** Operator alert a backoff holder raises while its component keeps failing. */
82
+ type BackoffAlert = {
83
+ /** Stable alert key, e.g. "heartbeat.failing". */
84
+ key: string;
85
+ /** What failed, for the message: "The heartbeat", "Dream (memory consolidation)". */
86
+ label: string;
87
+ /** Consecutive failures before raising. Default 2. */
88
+ threshold?: number;
89
+ };
90
+
73
91
  /**
74
92
  * Per-component backoff holder. Cadence checks gate on `active()`; run
75
93
  * completions call `succeed()` / `fail(err)`.
@@ -78,6 +96,8 @@ export class FailureBackoff {
78
96
  private consecutiveFailures = 0;
79
97
  private until = 0;
80
98
 
99
+ constructor(private readonly alert?: BackoffAlert) {}
100
+
81
101
  /** True while inside the backoff window — the caller should skip firing. */
82
102
  active(now = Date.now()): boolean {
83
103
  return now < this.until;
@@ -87,6 +107,14 @@ export class FailureBackoff {
87
107
  fail(err: unknown, now = Date.now()): number {
88
108
  this.consecutiveFailures += 1;
89
109
  this.until = failureBackoffUntil(err, this.consecutiveFailures, now);
110
+ const alert = this.alert;
111
+ if (alert && this.consecutiveFailures >= (alert.threshold ?? 2)) {
112
+ const retryAt = new Date(this.until).toISOString().slice(11, 16);
113
+ raiseAlert(
114
+ alert.key,
115
+ `${alert.label} has failed ${this.consecutiveFailures} times in a row: ${faultText(err)}. Next attempt after ${retryAt} UTC.`,
116
+ );
117
+ }
90
118
  return this.until;
91
119
  }
92
120
 
@@ -94,6 +122,8 @@ export class FailureBackoff {
94
122
  succeed(): void {
95
123
  this.consecutiveFailures = 0;
96
124
  this.until = 0;
125
+ if (this.alert)
126
+ resolveAlert(this.alert.key, `${this.alert.label} is running again.`);
97
127
  }
98
128
 
99
129
  get failures(): number {
@@ -22,6 +22,7 @@ import {
22
22
  resolveRoutedModel,
23
23
  } from "../../engine/backend-router/index.js";
24
24
  import { resolveBackgroundEffort } from "../effort.js";
25
+ import { raceWithTimeout } from "../isolated-agent.js";
25
26
  import { hb } from "./state.js";
26
27
 
27
28
  const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute soft cap
@@ -88,7 +89,7 @@ export function buildHeartbeatSystemPrompt(): string {
88
89
  /**
89
90
  * Render the open-goal listing for the heartbeat prompt. Cross-chat by design:
90
91
  * the heartbeat is a global agent, so it sees every chat's open goals (with
91
- * chat ids for routing updates back). Exported for tests.
92
+ * chat ids for routing updates back).
92
93
  */
93
94
  function renderGoalsBlock(): { text: string; count: number } {
94
95
  let text = "(no open goals)";
@@ -486,32 +487,6 @@ export async function runHeartbeatAgent(
486
487
  return heartbeatLogFile;
487
488
  }
488
489
 
489
- /**
490
- * Race a promise against a timeout. Returns the promise's resolved value, or
491
- * the sentinel `"timed_out"` if the timeout fires first.
492
- *
493
- * NOTE: if `p` rejects before the timeout fires, that rejection propagates —
494
- * callers that need a never-throwing race should `.catch()` the input promise
495
- * themselves.
496
- */
497
- async function raceWithTimeout<T>(
498
- p: Promise<T>,
499
- ms: number,
500
- ): Promise<T | "timed_out"> {
501
- let t: ReturnType<typeof setTimeout> | null = null;
502
- try {
503
- return await Promise.race([
504
- p,
505
- new Promise<"timed_out">((resolve) => {
506
- t = setTimeout(() => resolve("timed_out"), ms);
507
- t.unref();
508
- }),
509
- ]);
510
- } finally {
511
- if (t) clearTimeout(t);
512
- }
513
- }
514
-
515
490
  // ── Logging helpers ─────────────────────────────────────────────────────────
516
491
 
517
492
  async function createHeartbeatLogFile(): Promise<string> {
@@ -9,8 +9,6 @@
9
9
  * - `state` — shared run-guard/timers/config + persisted-state I/O
10
10
  * - `agent` — prompt building + the one-shot agent run + log helpers
11
11
  * - `scheduler` — init, the cadence timer, run guard, force/await/status API
12
- *
13
- * Re-exports the same public surface the old single-file module exposed.
14
12
  */
15
13
 
16
14
  export {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { log, logError, logWarn } from "../../../util/log.js";
8
+ import { faultText } from "../../engine/fault-text.js";
8
9
  import {
9
10
  catchupRunCount,
10
11
  missedRunCount,
@@ -147,6 +148,16 @@ export function getHeartbeatStatus(): HeartbeatState | null {
147
148
  return readHeartbeatState();
148
149
  }
149
150
 
151
+ /** Arm the failure backoff so the due check stops re-firing for a while. */
152
+ function backOff(err: unknown): void {
153
+ const until = hb.failureBackoff.fail(err);
154
+ logWarn(
155
+ "heartbeat",
156
+ `heartbeat.backoff failures=${hb.failureBackoff.failures} ` +
157
+ `until=${new Date(until).toISOString()} error="${faultText(err)}"`,
158
+ );
159
+ }
160
+
150
161
  async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
151
162
  if (hb.running) return;
152
163
 
@@ -175,13 +186,16 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
175
186
  previousRunCount + 1,
176
187
  );
177
188
  // Only update last_run and increment run_count on success
178
- writeHeartbeatState({
189
+ const persisted = writeHeartbeatState({
179
190
  last_run: Date.now(),
180
191
  last_started: now,
181
192
  status: "idle",
182
193
  run_count: previousRunCount + 1,
183
194
  });
184
- hb.failureBackoff.succeed();
195
+ // A run whose last_run didn't stick looks overdue to the next due
196
+ // check, which would run the whole agent again every minute.
197
+ if (persisted) hb.failureBackoff.succeed();
198
+ else backOff(new Error("heartbeat state was not persisted"));
185
199
  log(
186
200
  "heartbeat",
187
201
  `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
@@ -197,23 +211,17 @@ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
197
211
  // re-triggering against the same `last_run` forever. Non-timeout errors
198
212
  // retry from the previous successful run (no budget consumed).
199
213
  const isTimeout = err instanceof HeartbeatTimeoutError;
200
- writeHeartbeatState({
214
+ const persisted = writeHeartbeatState({
201
215
  last_run: isTimeout ? Date.now() : previousLastRun,
202
216
  last_started: now,
203
217
  status: "idle",
204
218
  run_count: isTimeout ? previousRunCount + 1 : previousRunCount,
205
219
  });
206
220
  // Timeouts advance last_run (budget consumed), so the cadence itself
207
- // spaces the next attempt. Every other failure retries against the same
208
- // last_run — back off so the due check doesn't hammer it every minute.
209
- if (!isTimeout) {
210
- const until = hb.failureBackoff.fail(err);
211
- logWarn(
212
- "heartbeat",
213
- `Backing off until ${new Date(until).toISOString()} ` +
214
- `after ${hb.failureBackoff.failures} consecutive failure(s)`,
215
- );
216
- }
221
+ // spaces the next attempt — if that write landed. Every other failure
222
+ // retries against the same last_run — back off so the due check
223
+ // doesn't hammer it every minute.
224
+ if (!isTimeout || !persisted) backOff(err);
217
225
  if (trigger === "forced") throw err;
218
226
  } finally {
219
227
  hb.running = false;
@@ -100,7 +100,10 @@ export const hb: {
100
100
  intervalMinutesRef: 60,
101
101
  config: null,
102
102
  logFileSequence: 0,
103
- failureBackoff: new FailureBackoff(),
103
+ failureBackoff: new FailureBackoff({
104
+ key: "heartbeat.failing",
105
+ label: "The heartbeat",
106
+ }),
104
107
  };
105
108
 
106
109
  // ── State-file I/O ───────────────────────────────────────────────────────────
@@ -160,7 +163,11 @@ export function readHeartbeatState(): HeartbeatState | null {
160
163
  return normalizeHeartbeatState(kvGet(HEARTBEAT_STATE_KEY));
161
164
  }
162
165
 
163
- export function writeHeartbeatState(state: HeartbeatState): void {
166
+ /**
167
+ * Persist the state. Returns whether last_run actually landed: kvSet logs
168
+ * and swallows a failed write (full disk), so the store is read back.
169
+ */
170
+ export function writeHeartbeatState(state: HeartbeatState): boolean {
164
171
  // Re-derive last_run_at from last_run so the persisted ISO stamp can
165
172
  // never drift from the millisecond field; omit it on the sentinel
166
173
  // last_run === 0 (never-run) to match the pre-SQLite file format.
@@ -172,4 +179,5 @@ export function writeHeartbeatState(state: HeartbeatState): void {
172
179
  : {}),
173
180
  };
174
181
  kvSet(HEARTBEAT_STATE_KEY, enriched);
182
+ return readHeartbeatState()?.last_run === state.last_run;
175
183
  }
@@ -50,8 +50,12 @@ export interface IsolatedRunOptions {
50
50
  readonly logCategory?: LogComponent;
51
51
  }
52
52
 
53
- /** Resolves to the value, or the string "timed_out" if `ms` elapses first. */
54
- async function raceWithTimeout<T>(
53
+ /**
54
+ * Resolves to the value, or the string "timed_out" if `ms` elapses first.
55
+ * A rejection of `promise` inside the window propagates — `.catch()` it
56
+ * first for a never-throwing race.
57
+ */
58
+ export async function raceWithTimeout<T>(
55
59
  promise: Promise<T>,
56
60
  ms: number,
57
61
  ): Promise<T | "timed_out"> {
@@ -27,6 +27,8 @@ import { formatSmartTimestamp } from "../../../util/time.js";
27
27
  let timer: ReturnType<typeof setInterval> | null = null;
28
28
  const registeredChats = new Set<string>();
29
29
  const lastCheckMessageId = new Map<string, number>();
30
+ /** Chats whose pulse turn is still queued or running. */
31
+ const inFlight = new Set<string>();
30
32
 
31
33
  const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
32
34
  let activeIntervalMs = DEFAULT_INTERVAL_MS;
@@ -126,6 +128,10 @@ async function runPulse(): Promise<void> {
126
128
  }
127
129
 
128
130
  async function pulseChat(chatId: string): Promise<void> {
131
+ // The pulse turn queues behind the chat's other turns, so it can outlast
132
+ // the interval. Until it settles the checkpoint hasn't moved, and the next
133
+ // tick would dispatch the same unread messages a second time.
134
+ if (inFlight.has(chatId)) return;
129
135
  const numericChatId = parseInt(chatId, 10);
130
136
  if (isNaN(numericChatId)) {
131
137
  logError("pulse", `Invalid chatId: ${chatId}`);
@@ -157,6 +163,7 @@ async function pulseChat(chatId: string): Promise<void> {
157
163
  })
158
164
  .join("\n");
159
165
 
166
+ inFlight.add(chatId);
160
167
  try {
161
168
  const prompt =
162
169
  `[System: Pulse check — ${unread.length} new message(s) since last check. ` +
@@ -179,5 +186,7 @@ async function pulseChat(chatId: string): Promise<void> {
179
186
  } catch (err) {
180
187
  logError("pulse", `Chat ${chatId} failed`, err);
181
188
  // Don't update lastCheckMessageId — messages will be retried next pulse
189
+ } finally {
190
+ inFlight.delete(chatId);
182
191
  }
183
192
  }
@@ -25,6 +25,54 @@ import {
25
25
  WARDEN_GRACE_SLACK_MS,
26
26
  } from "./state.js";
27
27
  import { bufferAsPayload, fireWake } from "./output.js";
28
+ import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
29
+ import { faultText } from "../../engine/fault-text.js";
30
+
31
+ /** Exit codes that mean the command itself could not run. */
32
+ const CANNOT_RUN_CODES: Readonly<Record<number, string>> = {
33
+ 126: "command not executable",
34
+ 127: "command not found",
35
+ };
36
+
37
+ /**
38
+ * Tell the operator about a trigger that broke rather than finished: its
39
+ * command could not run (exit 126/127) or the process died on a signal
40
+ * nobody sent it (a crash, the OOM killer — Talon's own kills set the
41
+ * status before the exit lands, so they never reach `errored`). An
42
+ * ordinary non-zero exit is the script's own verdict and already reaches
43
+ * the chat as a wake. A clean fire clears an earlier alert for the id.
44
+ */
45
+ function alertOnExit(
46
+ t: Trigger,
47
+ status: TriggerStatus,
48
+ code: number | null,
49
+ signal: NodeJS.Signals | null,
50
+ output: string[],
51
+ ): void {
52
+ const key = `trigger.${t.id}`;
53
+ if (status === "fired") {
54
+ resolveAlert(key, `Trigger "${t.name}" is firing normally again.`);
55
+ return;
56
+ }
57
+ if (status !== "errored") return;
58
+ const cause =
59
+ signal !== null
60
+ ? `crashed (${signal})`
61
+ : code !== null && CANNOT_RUN_CODES[code]
62
+ ? `could not run its command (exit ${code}: ${CANNOT_RUN_CODES[code]})`
63
+ : null;
64
+ if (!cause) return;
65
+ const last = output.at(-1);
66
+ log(
67
+ "triggers",
68
+ `trigger.broken id=${t.id} name="${t.name}" code=${code} signal=${signal} cause="${cause}"`,
69
+ );
70
+ raiseAlert(
71
+ key,
72
+ `Trigger "${t.name}" [${t.id}] ${cause}${last ? `: ${faultText(last)}` : "."}`,
73
+ { severity: "warn" },
74
+ );
75
+ }
28
76
 
29
77
  export function handleTimeout(trigger: Trigger): void {
30
78
  timeouts.delete(trigger.id);
@@ -177,6 +225,7 @@ export async function finalizeExit(
177
225
  "triggers",
178
226
  `Exited "${t.name}" [${id}] code=${code} signal=${signal} → ${status}`,
179
227
  );
228
+ alertOnExit(t, status, code, signal, buffered);
180
229
 
181
230
  appendDailyLog(
182
231
  "Triggers",
@@ -211,6 +260,11 @@ export async function finalizeExit(
211
260
 
212
261
  export function failTrigger(t: Trigger, message: string): void {
213
262
  logError("triggers", `Failed to spawn ${t.id}: ${message}`);
263
+ raiseAlert(
264
+ `trigger.${t.id}`,
265
+ `Trigger "${t.name}" [${t.id}] failed to start: ${faultText(message)}`,
266
+ { severity: "warn" },
267
+ );
214
268
  updateTrigger(t.id, {
215
269
  status: "errored",
216
270
  lastError: message,
@@ -15,7 +15,7 @@
15
15
  * Knows nothing about backend or frontend — dependencies are injected.
16
16
  */
17
17
 
18
- import { children, timeouts, logStreams, wardened } from "./state.js";
18
+ import { children, timeouts } from "./state.js";
19
19
  import { commandForLanguage } from "./command.js";
20
20
  import { handleStdoutLine } from "./output.js";
21
21
  import { handleTimeout, finalizeExit } from "./exit.js";
@@ -35,8 +35,6 @@ export { resumeAfterRestart } from "./resume.js";
35
35
  export const _internals = {
36
36
  children,
37
37
  timeouts,
38
- logStreams,
39
- wardened,
40
38
  handleStdoutLine,
41
39
  handleTimeout,
42
40
  finalizeExit,
@@ -49,7 +49,7 @@ export async function resumeAfterRestart(): Promise<void> {
49
49
  continue;
50
50
  }
51
51
  // Late death notice. Two cases earn one:
52
- // - never fired at all (the old rule) — the chat heard nothing
52
+ // - never fired at all — the chat heard nothing
53
53
  // from this trigger, so its termination is news; and
54
54
  // - killed by THIS restart — either the crash path
55
55
  // (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
@@ -58,9 +58,7 @@ export async function resumeAfterRestart(): Promise<void> {
58
58
  // backend pool was already going away). Even a multi-fire
59
59
  // watcher that signalled mid-run was still an active promise
60
60
  // when the process died, and without this wake the chat never
61
- // learns its watcher is gone. (Previously gated on
62
- // lastFireAt === undefined alone, which silently dropped
63
- // exactly those watchers.)
61
+ // learns its watcher is gone.
64
62
  // Triggers that exited on their own already fired their terminal
65
63
  // wake (lastFireAt set, no marker) — they stay silent here.
66
64
  if (
@@ -22,8 +22,8 @@
22
22
  * `~/.ssh/authorized_keys` is not a safety net.
23
23
  */
24
24
 
25
- import { createReadStream, createWriteStream } from "node:fs";
26
- import { mkdir, symlink, utimes } from "node:fs/promises";
25
+ import { createWriteStream } from "node:fs";
26
+ import { mkdir, open, symlink, utimes } from "node:fs/promises";
27
27
  import { once } from "node:events";
28
28
  import { dirname, isAbsolute, relative, resolve } from "node:path";
29
29
  import type { Writable } from "node:stream";
@@ -262,6 +262,9 @@ export class TarWriter {
262
262
  * Stream a file from disk. `size` is the length recorded in the header:
263
263
  * a file that changes under us is truncated or zero-padded to it, because
264
264
  * a tar whose payload length disagrees with its header is unreadable.
265
+ * The source is opened before the header goes out, so a file that
266
+ * vanished or became unreadable since it was listed throws with nothing
267
+ * written — the archive is still whole and the caller may carry on.
265
268
  */
266
269
  async addFile(
267
270
  path: string,
@@ -270,9 +273,16 @@ export class TarWriter {
270
273
  mtime: number,
271
274
  size: number,
272
275
  ): Promise<void> {
273
- await this.writeHeaders({ path, type: "file", mode, mtime, size });
276
+ const handle = await open(source, "r");
277
+ try {
278
+ await this.writeHeaders({ path, type: "file", mode, mtime, size });
279
+ } catch (err) {
280
+ await handle.close();
281
+ throw err;
282
+ }
274
283
  let written = 0;
275
- const stream = createReadStream(source);
284
+ // Owns the handle from here: closed when the stream ends or is destroyed.
285
+ const stream = handle.createReadStream();
276
286
  for await (const chunk of stream) {
277
287
  const buf = chunk as Buffer;
278
288
  const room = size - written;
@@ -63,13 +63,19 @@ import {
63
63
  rewriteConfigForClone,
64
64
  type CloneTarget,
65
65
  } from "./sources/relocate.js";
66
- import { isSnapshotId, partPath, readManifest, snapshotDir } from "./store.js";
66
+ import { pathExists } from "./sources/sessions.js";
67
+ import {
68
+ DB_MEMBER,
69
+ isSnapshotId,
70
+ partPath,
71
+ readManifest,
72
+ snapshotDir,
73
+ } from "./store.js";
67
74
  import type { BackupTarget } from "./targets.js";
68
75
  import type { BackupSettings, Manifest, SnapshotPart } from "./types.js";
69
76
 
70
77
  /** A staged request older than this is stale and ignored. */
71
78
  export const RESTORE_PENDING_MAX_AGE_MS = 10 * 60_000;
72
- const DB_MEMBER = "db/talon.db";
73
79
 
74
80
  export type RestorePending = {
75
81
  id: string;
@@ -165,15 +171,6 @@ export async function readRestorePending(
165
171
 
166
172
  // ── Parts ───────────────────────────────────────────────────────────────────
167
173
 
168
- async function isLocal(path: string): Promise<boolean> {
169
- try {
170
- await stat(path);
171
- return true;
172
- } catch {
173
- return false;
174
- }
175
- }
176
-
177
174
  /** The parts of this snapshot that are not on local disk. */
178
175
  async function missingParts(
179
176
  manifest: Manifest,
@@ -181,7 +178,7 @@ async function missingParts(
181
178
  ): Promise<SnapshotPart[]> {
182
179
  const missing: SnapshotPart[] = [];
183
180
  for (const part of manifest.parts) {
184
- if (!(await isLocal(partPath(manifest.id, part.name, home)))) {
181
+ if (!(await pathExists(partPath(manifest.id, part.name, home)))) {
185
182
  missing.push(part);
186
183
  }
187
184
  }
@@ -218,7 +215,18 @@ async function ensureParts(
218
215
  const path = partPath(manifest.id, part.name, home);
219
216
  log("backup", `Downloading ${part.name} from ${target.id}…`);
220
217
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
221
- await target.download(manifest.id, part.name, path);
218
+ // Into a side file, renamed only once complete: a download cut short
219
+ // under the part's own name would count as present next time, and
220
+ // every later restore would fail its checksum instead of refetching.
221
+ const partial = `${path}.partial`;
222
+ await rm(partial, { force: true });
223
+ try {
224
+ await target.download(manifest.id, part.name, partial);
225
+ await rename(partial, path);
226
+ } catch (err) {
227
+ await rm(partial, { force: true });
228
+ throw err;
229
+ }
222
230
  }
223
231
  return manifest.parts.filter((part) => !skipped.has(part.name));
224
232
  }
@@ -26,6 +26,8 @@
26
26
  import { FailureBackoff } from "../background/failure-backoff.js";
27
27
  import { TalonError } from "../errors.js";
28
28
  import { notifyAdmin } from "../frontend-runtime/admin-notify.js";
29
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
30
+ import { faultText } from "../engine/fault-text.js";
29
31
  import { bus } from "../bus/index.js";
30
32
  import { log, logError } from "../../util/log.js";
31
33
  import { dirs } from "../../util/paths.js";
@@ -117,6 +119,7 @@ export function firstRunDelayMs(
117
119
  // ── Runs ────────────────────────────────────────────────────────────────────
118
120
 
119
121
  const backoff = new FailureBackoff();
122
+ const BACKUP_ALERT = "backup.failing";
120
123
 
121
124
  async function executeRun(request: RunRequest): Promise<Manifest> {
122
125
  const settings = state.settings;
@@ -163,6 +166,7 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
163
166
  }
164
167
  }
165
168
  backoff.succeed();
169
+ resolveAlert(BACKUP_ALERT, "Backups are succeeding again.");
166
170
  return manifest;
167
171
  } catch (err) {
168
172
  const message = err instanceof Error ? err.message : String(err);
@@ -177,21 +181,36 @@ async function executeRun(request: RunRequest): Promise<Manifest> {
177
181
  logError("backup", `Run failed (${request.trigger})`, err);
178
182
  // Once per streak: the second identical failure tells the admin nothing
179
183
  // the first one did not.
180
- if (backoff.failures === 1) {
181
- await state
182
- .notify(
183
- `⚠️ Backup failed: ${message}\nRetrying after ${new Date(until).toISOString().slice(11, 16)} UTC.`,
184
- )
185
- .catch(() => {
186
- /* the notifier logs its own failures */
187
- });
188
- }
184
+ if (backoff.failures === 1) await reportFailure(message, until);
189
185
  throw err;
190
186
  } finally {
191
187
  state.running = false;
192
188
  }
193
189
  }
194
190
 
191
+ /**
192
+ * Tell the admin a backup streak started. The default route is an operator
193
+ * alert (`backup.failing`), so the recovery is announced too; a deployment
194
+ * that routes backup notices to its own chat (`backup.notifyChatId`) keeps
195
+ * getting the plain notice there.
196
+ */
197
+ async function reportFailure(message: string, until: number): Promise<void> {
198
+ const retryAt = new Date(until).toISOString().slice(11, 16);
199
+ if (state.notify === notifyAdmin) {
200
+ raiseAlert(
201
+ BACKUP_ALERT,
202
+ `Backup failed: ${faultText(message, 300)}. Retrying after ${retryAt} UTC.`,
203
+ { severity: "warn" },
204
+ );
205
+ return;
206
+ }
207
+ await state
208
+ .notify(`⚠️ Backup failed: ${message}\nRetrying after ${retryAt} UTC.`)
209
+ .catch(() => {
210
+ /* the notifier logs its own failures */
211
+ });
212
+ }
213
+
195
214
  /**
196
215
  * Take a snapshot. Requests queue behind whatever is already running, so
197
216
  * this resolves with THIS request's snapshot, not someone else's.