talon-agent 5.4.0 → 5.4.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": "5.4.0",
3
+ "version": "5.4.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",
package/src/app.ts CHANGED
@@ -33,6 +33,7 @@ import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
33
33
  import {
34
34
  BOOT_SMOKE_FLAG,
35
35
  BOOT_SMOKE_OK,
36
+ setRespawnShutdown,
36
37
  spawnSuccessor,
37
38
  } from "./core/daemon/respawn.js";
38
39
  import {
@@ -342,6 +343,9 @@ async function gracefulShutdown(signal: string): Promise<void> {
342
343
 
343
344
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
344
345
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
346
+ // /restart and /update come in here directly rather than through a
347
+ // self-sent SIGTERM — see core/daemon/respawn.ts.
348
+ setRespawnShutdown((reason) => void gracefulShutdown(`respawn (${reason})`));
345
349
 
346
350
  // Cleanup runs before the crash is reported (and the EPIPE suppression
347
351
  // is unchanged) — see core/daemon/crash.ts for why the order matters.
@@ -13,9 +13,9 @@
13
13
  * systemd, foreman, pm2, or running under a debugger. Respawning from
14
14
  * our own `process.argv` works regardless of launch method.
15
15
  *
16
- * Ordering matters. `respawnSelf()` only *arms* the handoff and raises
17
- * SIGTERM; the successor is spawned by `spawnSuccessor()` at the tail of
18
- * graceful shutdown, once the frontends have stopped. Spawning up-front
16
+ * Ordering matters. `respawnSelf()` only *arms* the handoff and enters
17
+ * graceful shutdown; the successor is spawned by `spawnSuccessor()` at
18
+ * the tail of it, once the frontends have stopped. Spawning up-front
19
19
  * (the original behaviour) left the successor long-polling `getUpdates`
20
20
  * while the outgoing process was still draining in-flight queries — up
21
21
  * to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
@@ -40,6 +40,16 @@
40
40
  * within a bounded window, and starts the daemon the way `talon
41
41
  * start` does if the successor never comes up. Nothing in the
42
42
  * handoff depends on a process that is about to call process.exit().
43
+ *
44
+ * And one thing 2026-09-20 added: the shutdown is entered directly,
45
+ * through the function app.ts registers with `setRespawnShutdown()`,
46
+ * not by sending ourselves a SIGTERM. The signal round trip bought
47
+ * nothing, and under Bun it lost the whole restart: by the time
48
+ * `/restart` ran, the process had silently lost its OS-level SIGTERM
49
+ * handler (see ./signals.ts), so the signal terminated it on the spot —
50
+ * no shutdown, no successor, no pidfile cleanup, and nothing in any log
51
+ * after "Respawn requested". Only a process that never registered — an
52
+ * embedder, a test — still falls back to the signal.
43
53
  */
44
54
 
45
55
  import { spawn } from "node:child_process";
@@ -47,6 +57,18 @@ import { log, logError, openRespawnLog } from "../../util/log.js";
47
57
  import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
48
58
 
49
59
  let pendingReason: string | null = null;
60
+ let shutdown: ((reason: string) => void) | null = null;
61
+
62
+ /**
63
+ * Register the graceful-shutdown entry a respawn should run. app.ts
64
+ * hands over `gracefulShutdown`; `respawnSelf()` then calls it in-process
65
+ * instead of round-tripping a SIGTERM through the OS. `null` clears it.
66
+ */
67
+ export function setRespawnShutdown(
68
+ fn: ((reason: string) => void) | null,
69
+ ): void {
70
+ shutdown = fn;
71
+ }
50
72
 
51
73
  /**
52
74
  * Argv flag that makes the daemon entry resolve its whole import graph
@@ -84,9 +106,8 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
84
106
  }
85
107
 
86
108
  /**
87
- * Arm a respawn and raise SIGTERM on ourselves so the existing
88
- * graceful-shutdown path cleanly stops the frontends, flushes state,
89
- * and hands off via `spawnSuccessor()`.
109
+ * Arm a respawn and enter graceful shutdown, which stops the frontends,
110
+ * flushes state, and hands off via `spawnSuccessor()`.
90
111
  *
91
112
  * `reason` is logged for operator visibility (e.g. "telegram
92
113
  * /restart"). The function returns immediately; the successor starts
@@ -95,10 +116,13 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
95
116
  export function respawnSelf(reason: string): void {
96
117
  log("shutdown", `Respawn requested (${reason})`);
97
118
  pendingReason = reason;
98
- // SIGTERM triggers the graceful-shutdown handler in src/app.ts,
99
- // which stops the frontends, flushes state, spawns the successor,
100
- // and calls process.exit(0). Don't exit here directly — that would
101
- // skip the flush and leave the PID file dangling.
119
+ // Don't exit here directly — that would skip the flush and leave the
120
+ // PID file dangling. The registered shutdown is the same path a
121
+ // SIGTERM takes, entered without the signal.
122
+ if (shutdown) {
123
+ shutdown(reason);
124
+ return;
125
+ }
102
126
  process.kill(process.pid, "SIGTERM");
103
127
  }
104
128
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Keep OS-level signal handlers armed under Bun.
3
+ *
4
+ * Bun (observed on 1.3.9) uninstalls the process-wide handler for a
5
+ * signal as soon as ANY listener for it is removed — even while other
6
+ * listeners remain:
7
+ *
8
+ * process.on("SIGTERM", a);
9
+ * process.on("SIGTERM", b);
10
+ * process.removeListener("SIGTERM", b);
11
+ * // `a` is still registered in JS, yet SigCgt in /proc/<pid>/status
12
+ * // no longer lists SIGTERM: the next one terminates the process
13
+ * // with the default action, running no JS at all.
14
+ *
15
+ * Node keeps the handler until the last listener goes. Adding any
16
+ * listener makes Bun install the handler again, which is what this
17
+ * guard relies on.
18
+ *
19
+ * Talon hit it through `write-file-atomic`: each call loads signal-exit,
20
+ * which puts its own listeners on the termination signals, and unloads
21
+ * it afterwards, removing them. The daemon writes its pidfile that way
22
+ * while booting — so from that moment every SIGTERM it sent itself
23
+ * (`/restart` and `/update`, before respawn.ts entered the shutdown
24
+ * directly) or received from outside (`talon stop`'s fallback, systemd,
25
+ * docker) killed it outright: no graceful shutdown, no successor, no
26
+ * pidfile cleanup. Every `/update` between 2026-09-18 (the move to Bun)
27
+ * and 2026-09-20 ended this way.
28
+ *
29
+ * The guard wraps `process.removeListener` / `process.off`. After a
30
+ * removal that still leaves real listeners on a signal, it re-adds a
31
+ * no-op sentinel listener (removing any earlier copy first), so the
32
+ * handler is installed again before the caller gets control back. Once
33
+ * the last real listener is gone the sentinel goes too, and the signal
34
+ * falls back to its default action exactly as it would on Node — a
35
+ * lingering no-op listener would otherwise make the process ignore it.
36
+ *
37
+ * Installed by the entry shim (src/index.ts) so every Talon process —
38
+ * daemon, MCP supervisor, handoff watcher — is covered before any
39
+ * library gets a chance to remove a listener. A no-op on other runtimes.
40
+ */
41
+
42
+ import { isBunRuntime } from "../../util/runtime.js";
43
+
44
+ type Listener = (...args: unknown[]) => void;
45
+
46
+ /** The slice of `process` the guard touches; tests pass an EventEmitter. */
47
+ export interface SignalEmitter {
48
+ on(event: string | symbol, listener: Listener): unknown;
49
+ removeListener(event: string | symbol, listener: Listener): unknown;
50
+ off?(event: string | symbol, listener: Listener): unknown;
51
+ listeners(event: string | symbol): readonly unknown[];
52
+ }
53
+
54
+ export interface SignalGuardOptions {
55
+ /** Install even off Bun (tests exercise the wrapper on Node). */
56
+ force?: boolean;
57
+ }
58
+
59
+ const SIGNAL_EVENT = /^SIG[A-Z0-9]+$/;
60
+
61
+ const INSTALLED = Symbol.for("talon.signalListenerGuard");
62
+
63
+ type Guarded = SignalEmitter & { [INSTALLED]?: () => void };
64
+
65
+ /** The sentinel. Its only job is to exist, so Bun keeps the handler. */
66
+ function talonSignalRearm(): void {}
67
+
68
+ /**
69
+ * Wrap the emitter's listener removal so a signal never silently loses
70
+ * its OS handler. Idempotent: a second install returns the first one's
71
+ * uninstall. Production never uninstalls; tests do.
72
+ */
73
+ export function installSignalListenerGuard(
74
+ target: SignalEmitter = process,
75
+ opts: SignalGuardOptions = {},
76
+ ): () => void {
77
+ const emitter = target as Guarded;
78
+ if (!opts.force && !isBunRuntime()) return () => {};
79
+ const existing = emitter[INSTALLED];
80
+ if (existing) return existing;
81
+
82
+ const originalRemove = emitter.removeListener;
83
+ const originalOff = emitter.off;
84
+
85
+ const guardedRemove = function (
86
+ this: Guarded,
87
+ event: string | symbol,
88
+ listener: Listener,
89
+ ): unknown {
90
+ const result = originalRemove.call(this, event, listener);
91
+ if (typeof event !== "string" || !SIGNAL_EVENT.test(event)) return result;
92
+ // Bun has just dropped the handler. What is left decides whether the
93
+ // signal should still be handled at all.
94
+ const remaining = this.listeners(event).filter(
95
+ (fn) => fn !== talonSignalRearm,
96
+ );
97
+ originalRemove.call(this, event, talonSignalRearm);
98
+ if (remaining.length > 0) this.on(event, talonSignalRearm);
99
+ return result;
100
+ };
101
+
102
+ emitter.removeListener = guardedRemove;
103
+ if (originalOff) emitter.off = guardedRemove;
104
+ const uninstall = (): void => {
105
+ emitter.removeListener = originalRemove;
106
+ if (originalOff) emitter.off = originalOff;
107
+ delete emitter[INSTALLED];
108
+ };
109
+ emitter[INSTALLED] = uninstall;
110
+ return uninstall;
111
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * backends/frontends/plugins.
11
11
  */
12
12
 
13
+ import { installSignalListenerGuard } from "./core/daemon/signals.js";
13
14
  import {
14
15
  MCP_LAUNCH_SUBCOMMAND,
15
16
  runSupervisor,
@@ -20,6 +21,12 @@ import {
20
21
  runHandoffWatch,
21
22
  } from "./core/daemon/handoff.js";
22
23
 
24
+ // Before anything can remove a signal listener: under Bun that would
25
+ // silently disarm SIGTERM/SIGINT for the whole process (core/daemon/
26
+ // signals.ts). Every Talon process passes through here, so every one
27
+ // is covered.
28
+ installSignalListenerGuard();
29
+
23
30
  if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
24
31
  await runSupervisor(process.argv.slice(3));
25
32
  } else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {