talon-agent 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -10,6 +10,16 @@
10
10
  * and records why: fuseless hosts get the identical experience minus
11
11
  * live views, never an error at boot.
12
12
  *
13
+ * The degradation is not just a boot-time decision. Once mounted, a
14
+ * health watchdog re-probes the live views on an interval; if the mount
15
+ * goes unhealthy at runtime — the classic case being a `bin/*.node`
16
+ * rebuilt out from under the running daemon, or the mountpoint wedged to
17
+ * ENOTCONN — it tears the dead mount down, restores the symlink farm so
18
+ * file-backed paths keep working, and tries a bounded number of
19
+ * reconnects. A mount that can't come back settles into fuseless for
20
+ * the rest of the run. A broken FUSE layer must never take the daemon
21
+ * down with it; the worst outcome is losing the live views.
22
+ *
13
23
  * Deadlock rule: the daemon must never do SYNCHRONOUS fs I/O under
14
24
  * ~/.talon/ns — a sync call blocks the one JS thread that answers the
15
25
  * bridge, wedging both sides. Async fs is safe (libuv worker blocks,
@@ -38,9 +48,38 @@ export type FuseStatus = {
38
48
  /** How long a post-mount sanity probe may take before we bail out. */
39
49
  const SANITY_TIMEOUT_MS = 3_000;
40
50
 
51
+ /** How often the health watchdog re-probes a live mount. */
52
+ const HEALTH_CHECK_INTERVAL_MS = 30_000;
53
+
54
+ /**
55
+ * How many times the watchdog will try to remount after the mount goes
56
+ * unhealthy before giving up and staying fuseless for the rest of the
57
+ * run. Bounded so a permanently-broken FUSE setup doesn't thrash
58
+ * remount attempts every interval forever.
59
+ */
60
+ const MAX_RECONNECT_ATTEMPTS = 5;
61
+
41
62
  let status: FuseStatus = { mounted: false, reason: "not started" };
42
63
  let activeAddon: NativeFuseFs | null = null;
43
64
 
65
+ /**
66
+ * Everything the watchdog needs to re-probe and reconnect the mount,
67
+ * captured on the last successful mount. `null` until then and after a
68
+ * clean shutdown — the watchdog is a no-op without it.
69
+ */
70
+ type LiveMount = {
71
+ readonly mode: "auto" | "off";
72
+ readonly vfs: Vfs;
73
+ readonly nsRoot: string;
74
+ readonly addon: NativeFuseFs;
75
+ /** First synthetic mount — what the probe reads to prove liveness. */
76
+ readonly probe: string | undefined;
77
+ };
78
+ let live: LiveMount | null = null;
79
+ let healthTimer: ReturnType<typeof setInterval> | null = null;
80
+ let healing = false;
81
+ let reconnectAttempts = 0;
82
+
44
83
  /** Is the FUSE layer live? Consulted by the address/command resolvers. */
45
84
  export function isNamespaceFsMounted(): boolean {
46
85
  return status.mounted;
@@ -56,6 +95,16 @@ export function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
56
95
  status = next;
57
96
  }
58
97
 
98
+ /** Test seam — drive one watchdog tick synchronously (no interval wait). */
99
+ export async function _checkNamespaceFsHealthForTesting(): Promise<void> {
100
+ await checkNamespaceFsHealth();
101
+ }
102
+
103
+ /** Test seam — the watchdog's current reconnect-attempt counter. */
104
+ export function _reconnectAttemptsForTesting(): number {
105
+ return reconnectAttempts;
106
+ }
107
+
59
108
  // ── Bridge: FUSE thread → JS Vfs ─────────────────────────────────────────────
60
109
 
61
110
  const ERRNO: Record<VfsErrorCode, string> = {
@@ -179,19 +228,9 @@ export async function mountNamespaceFs(
179
228
  // Sanity: the mount must actually answer before we advertise it.
180
229
  // Async fs only — see the deadlock rule in the module doc.
181
230
  const probe = synthetic[0];
182
- const healthy = await withTimeout(
183
- (async () => {
184
- const entries = await readdir(nsRoot);
185
- if (probe !== undefined && !entries.includes(probe)) return false;
186
- if (probe !== undefined) {
187
- if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
188
- }
189
- return true;
190
- })(),
191
- SANITY_TIMEOUT_MS,
192
- );
231
+ const healthy = await probeMount(nsRoot, probe);
193
232
  if (healthy !== true) {
194
- await unmountNamespaceFs();
233
+ await teardownAddon();
195
234
  // The failed mount may have shadowed the symlink farm — restore it.
196
235
  try {
197
236
  syncNamespaceDir(options.vfs, nsRoot);
@@ -206,12 +245,43 @@ export async function mountNamespaceFs(
206
245
  }
207
246
 
208
247
  status = { mounted: true };
248
+ live = { mode: options.mode, vfs: options.vfs, nsRoot, addon, probe };
249
+ reconnectAttempts = 0;
250
+ startHealthWatchdog();
209
251
  log("fusefs", `talon:// namespace mounted at ${nsRoot}`);
210
252
  return status;
211
253
  }
212
254
 
213
- /** Tear the layer down (daemon shutdown). Never throws, idempotent. */
214
- export async function unmountNamespaceFs(): Promise<void> {
255
+ /**
256
+ * Probe a live mount: read the root and confirm the first synthetic
257
+ * subtree is a directory the bridge answers. `false` = mounted but not
258
+ * serving live views, `"timeout"` = the mount hangs syscalls (a wedged
259
+ * predecessor). Async fs only — a sync call under nsRoot would block the
260
+ * one JS thread that answers the bridge (see the deadlock rule).
261
+ */
262
+ async function probeMount(
263
+ nsRoot: string,
264
+ probe: string | undefined,
265
+ ): Promise<boolean | "timeout"> {
266
+ return withTimeout(
267
+ (async () => {
268
+ const entries = await readdir(nsRoot);
269
+ if (probe !== undefined && !entries.includes(probe)) return false;
270
+ if (probe !== undefined) {
271
+ if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
272
+ }
273
+ return true;
274
+ })(),
275
+ SANITY_TIMEOUT_MS,
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Tear the addon's mount down without touching the watchdog. Shared by
281
+ * the sanity-rollback path, the self-heal path, and public unmount.
282
+ * Never throws, idempotent.
283
+ */
284
+ async function teardownAddon(): Promise<void> {
215
285
  const addon = activeAddon;
216
286
  activeAddon = null;
217
287
  if (status.mounted) status = { mounted: false, reason: "unmounted" };
@@ -223,6 +293,105 @@ export async function unmountNamespaceFs(): Promise<void> {
223
293
  }
224
294
  }
225
295
 
296
+ /**
297
+ * Tear the layer down for good (daemon shutdown). Stops the watchdog so
298
+ * it can't resurrect the mount mid-shutdown, then unmounts. Never
299
+ * throws, idempotent.
300
+ */
301
+ export async function unmountNamespaceFs(): Promise<void> {
302
+ stopHealthWatchdog();
303
+ live = null;
304
+ await teardownAddon();
305
+ }
306
+
307
+ // ── Health watchdog: reconnect or degrade, never die ─────────────────────────
308
+
309
+ function startHealthWatchdog(): void {
310
+ if (healthTimer) return;
311
+ healthTimer = setInterval(() => {
312
+ void checkNamespaceFsHealth();
313
+ }, HEALTH_CHECK_INTERVAL_MS);
314
+ // The daemon owns the lifecycle; the probe must not keep the loop alive.
315
+ healthTimer.unref();
316
+ }
317
+
318
+ function stopHealthWatchdog(): void {
319
+ if (healthTimer) {
320
+ clearInterval(healthTimer);
321
+ healthTimer = null;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * One watchdog tick. When the live mount stops answering, drop to
327
+ * fuseless immediately (the safe state — file-backed paths keep working
328
+ * via the symlink farm) and then try to reconnect, bounded. Runs to
329
+ * completion under a re-entrancy guard so overlapping ticks can't stack
330
+ * a second teardown/remount on top of one in flight. Never throws.
331
+ */
332
+ async function checkNamespaceFsHealth(): Promise<void> {
333
+ // Snapshot the mount facts: a concurrent shutdown nulls `live` between
334
+ // our awaits, and a tick must finish against one consistent view.
335
+ const mount = live;
336
+ if (healing || mount === null) return;
337
+ healing = true;
338
+ try {
339
+ if (status.mounted) {
340
+ const healthy = await probeMount(mount.nsRoot, mount.probe);
341
+ if (healthy === true) return;
342
+ logWarn(
343
+ "fusefs",
344
+ `mount went unhealthy (${
345
+ healthy === false ? "live views vanished" : "probe timed out"
346
+ }) — degrading to fuseless${
347
+ reconnectAttempts < MAX_RECONNECT_ATTEMPTS ? " and reconnecting" : ""
348
+ }`,
349
+ );
350
+ await teardownAddon();
351
+ // The dead mount may still shadow the mountpoint — restore the farm
352
+ // so file-backed paths survive the fuseless window.
353
+ try {
354
+ syncNamespaceDir(mount.vfs, mount.nsRoot);
355
+ } catch {
356
+ // best effort; the warning above already carries the real failure
357
+ }
358
+ status = {
359
+ mounted: false,
360
+ reason: "mount went unhealthy — degraded to fuseless",
361
+ };
362
+ }
363
+
364
+ // Shutdown raced this tick — nothing to reconnect to.
365
+ if (live === null) return;
366
+
367
+ // Already fuseless (just degraded, or a prior reconnect failed): try
368
+ // to bring the mount back, up to the cap.
369
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
370
+ stopHealthWatchdog();
371
+ status = {
372
+ mounted: false,
373
+ reason: `mount unhealthy; gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts — staying fuseless`,
374
+ };
375
+ logWarn("fusefs", status.reason!);
376
+ return;
377
+ }
378
+ reconnectAttempts += 1;
379
+ const result = await mountNamespaceFs({
380
+ mode: mount.mode,
381
+ vfs: mount.vfs,
382
+ nsRoot: mount.nsRoot,
383
+ addon: mount.addon,
384
+ });
385
+ if (result.mounted) {
386
+ log("fusefs", "reconnected — live views restored");
387
+ }
388
+ } catch (err) {
389
+ logWarn("fusefs", `health check failed: ${message(err)}`);
390
+ } finally {
391
+ healing = false;
392
+ }
393
+ }
394
+
226
395
  /**
227
396
  * A daemon that died without unmounting leaves the mountpoint wedged —
228
397
  * every syscall answers ENOTCONN ("transport endpoint is not
@@ -18,8 +18,9 @@ import {
18
18
  mkdirSync,
19
19
  readdirSync,
20
20
  readlinkSync,
21
- rmSync,
21
+ rmdirSync,
22
22
  symlinkSync,
23
+ unlinkSync,
23
24
  } from "node:fs";
24
25
  import { dirs } from "../../util/paths.js";
25
26
  import type { Vfs } from "./vfs.js";
@@ -69,7 +70,16 @@ export function syncNamespaceDir(
69
70
  desired.delete(entry); // already correct
70
71
  continue;
71
72
  }
72
- rmSync(path);
73
+ // Remove the LINK, never its target. unlink is the precise
74
+ // primitive for that everywhere except Windows, where a
75
+ // directory-symlink is removed with rmdir instead — hence the
76
+ // fallback. (rmSync's contract on symlinks is murkier; these two
77
+ // never touch the target by definition.)
78
+ try {
79
+ unlinkSync(path);
80
+ } catch {
81
+ rmdirSync(path);
82
+ }
73
83
  if (target === undefined) pruned.push(entry);
74
84
  }
75
85