talon-agent 5.2.0 → 5.2.2

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/src/util/log.ts CHANGED
@@ -16,7 +16,9 @@ import {
16
16
  statSync,
17
17
  renameSync,
18
18
  unlinkSync,
19
- createWriteStream,
19
+ openSync,
20
+ writeSync,
21
+ closeSync,
20
22
  } from "node:fs";
21
23
  import { dirs, files } from "./paths.js";
22
24
 
@@ -80,20 +82,29 @@ if (!existsSync(dirs.root)) {
80
82
 
81
83
  // Rotate log file on startup if it exceeds 10MB
82
84
  const MAX_LOG_SIZE = 10 * 1024 * 1024;
83
- try {
84
- if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
85
- const rotated = `${LOG_FILE}.old`;
85
+
86
+ /**
87
+ * Move `path` aside to `path.old` when it has outgrown the cap. Used at
88
+ * import time for talon.log and at handoff time for respawn.log, so both
89
+ * files follow the same one-generation rule. Never throws.
90
+ */
91
+ function rotateIfLarge(path: string): void {
92
+ try {
93
+ if (!existsSync(path) || statSync(path).size <= MAX_LOG_SIZE) return;
94
+ const rotated = `${path}.old`;
86
95
  try {
87
96
  unlinkSync(rotated);
88
97
  } catch {
89
- /* ignore */
98
+ /* no previous generation */
90
99
  }
91
- renameSync(LOG_FILE, rotated);
100
+ renameSync(path, rotated);
101
+ } catch {
102
+ /* a log file we cannot rotate is still a log file we can append to */
92
103
  }
93
- } catch {
94
- /* ignore */
95
104
  }
96
105
 
106
+ rotateIfLarge(LOG_FILE);
107
+
97
108
  // Suppress console output for terminal frontend (stdout belongs to the REPL)
98
109
  let quiet = process.env.TALON_QUIET === "1";
99
110
  if (!quiet) {
@@ -127,23 +138,286 @@ const streams: pino.StreamEntry[] = [];
127
138
 
128
139
  // Console output (disabled in quiet mode), pretty-printed.
129
140
  if (!quiet) {
130
- streams.push({
131
- level: "trace",
132
- stream: prettyStream({
133
- colorize: true,
134
- ignore: "pid,hostname",
135
- translateTime: "HH:MM:ss",
136
- }),
141
+ const consoleStream = prettyStream({
142
+ colorize: true,
143
+ ignore: "pid,hostname",
144
+ translateTime: "HH:MM:ss",
137
145
  });
146
+ // pino-pretty writes through a SonicBoom on fd 1 whose own error
147
+ // handler removes itself after the first non-EPIPE failure
148
+ // (pino-pretty/lib/utils/build-safe-sonic-boom.js). With stdout
149
+ // redirected to a file on a full disk that leaves the pipeline's next
150
+ // error unhandled — i.e. an uncaught exception raised from inside a
151
+ // log call. One permanent listener closes that door; a dead console
152
+ // is not worth a dead daemon.
153
+ consoleStream.on("error", () => {});
154
+ streams.push({ level: "trace", stream: consoleStream });
155
+ }
156
+
157
+ /** How long to wait before the first attempt to reopen a failed log file. */
158
+ const SINK_RETRY_MS = 30_000;
159
+ /** Ceiling for the doubling backoff between reopen attempts. */
160
+ const SINK_MAX_RETRY_MS = 5 * 60_000;
161
+
162
+ /**
163
+ * Where a {@link ResilientFileSink} puts its lines. Writing is
164
+ * synchronous by contract: a line that `write()` returned from is on the
165
+ * fd, not in a userspace queue. See the class doc for why that matters.
166
+ */
167
+ export type SyncLogTarget = {
168
+ /** Append one already-serialized line. Throws on failure. */
169
+ write(line: string): void;
170
+ /** Release the underlying handle. Never throws. */
171
+ close(): void;
172
+ };
173
+
174
+ export type ResilientFileSinkOptions = {
175
+ /** Opens the underlying file. Injection seam for tests. */
176
+ open?: (path: string) => SyncLogTarget;
177
+ /** Where the pause/resume notices go. Defaults to the console sink. */
178
+ notify?: (level: "warn" | "info", message: string) => void;
179
+ /** First backoff step (default 30s). */
180
+ retryMs?: number;
181
+ /** Backoff ceiling (default 5 min). */
182
+ maxRetryMs?: number;
183
+ };
184
+
185
+ /**
186
+ * A log file destination that cannot take the process down, and cannot
187
+ * lose the last thing the process said.
188
+ *
189
+ * Two failures shaped this class, both on 2026-09-18.
190
+ *
191
+ * First, a bare `createWriteStream` handed to `pino.multistream` is a
192
+ * loaded gun: when the disk fills (ENOSPC), or the file is unlinked
193
+ * under a rotation (EBADF), or the fd goes bad (EIO), the stream emits
194
+ * `error`. With no listener that is an uncaught exception — raised from
195
+ * inside a log call, so the crash handler's own `logError` runs on a
196
+ * logger that is already broken. A full disk killed the daemon that
197
+ * way: no shutdown, no pidfile cleanup, no successor.
198
+ *
199
+ * Second — and this is why the writes below are synchronous — a
200
+ * `createWriteStream` buffers in userspace and drains on later ticks,
201
+ * while every terminal log line Talon writes is immediately followed by
202
+ * `process.exit()`: "State saved", "Respawn child started", "Timeout
203
+ * exceeded, forcing exit", "Fatal startup error". `process.exit()`
204
+ * discards whatever is still queued, so precisely the lines that explain
205
+ * a failed handoff were the ones that never reached the file. Measured
206
+ * under Bun 1.3.9: three lines logged and then `process.exit(0)` produced
207
+ * an empty (not even created) log file. A sink that writes with
208
+ * `writeSync` has nothing to flush and nothing to lose — no flush step to
209
+ * remember at any exit site, which is the only version of this that stays
210
+ * fixed.
211
+ *
212
+ * pino only ever sees this object, which never throws and never blocks:
213
+ * - write failures pause file logging and close the broken handle,
214
+ * - lines written while paused are DROPPED and counted (never
215
+ * buffered — the failure mode here is "no space", so growing a
216
+ * buffer is the last thing to do),
217
+ * - an unref'd timer retries the open on a 30s → 5min backoff,
218
+ * - the first write to land again resumes logging and reports how
219
+ * many lines were lost.
220
+ * The console sink keeps working throughout, and carries the two
221
+ * notices.
222
+ */
223
+ export class ResilientFileSink {
224
+ private readonly path: string;
225
+ private readonly openTarget: (path: string) => SyncLogTarget;
226
+ private readonly notify: (level: "warn" | "info", message: string) => void;
227
+ private readonly baseRetryMs: number;
228
+ private readonly maxRetryMs: number;
229
+ private target: SyncLogTarget | null = null;
230
+ private retryMs: number;
231
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
232
+ private droppedWhileDown = 0;
233
+ private down = false;
234
+
235
+ constructor(path: string, opts: ResilientFileSinkOptions = {}) {
236
+ this.path = path;
237
+ this.openTarget = opts.open ?? openSyncLogFile;
238
+ this.notify = opts.notify ?? notifyViaConsoleSink;
239
+ this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
240
+ this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
241
+ this.retryMs = this.baseRetryMs;
242
+ this.openInner();
243
+ }
244
+
245
+ /** Lines discarded since the sink went down; cleared on recovery. */
246
+ get dropped(): number {
247
+ return this.droppedWhileDown;
248
+ }
249
+
250
+ /** True while file logging is paused (the console sink still runs). */
251
+ get isDown(): boolean {
252
+ return this.down;
253
+ }
254
+
255
+ /** pino.multistream's entire contract: one serialized line in. */
256
+ write(line: string): void {
257
+ const target = this.target;
258
+ if (target === null) {
259
+ this.droppedWhileDown++;
260
+ return;
261
+ }
262
+ try {
263
+ target.write(line);
264
+ } catch (err) {
265
+ this.droppedWhileDown++;
266
+ this.fail(err);
267
+ return;
268
+ }
269
+ this.markHealthy();
270
+ }
271
+
272
+ /** No-op: every write already reached the fd. Part of pino's shape. */
273
+ flushSync(): void {}
274
+
275
+ /** Close the file and stop retrying. Part of pino's shape. */
276
+ end(): void {
277
+ this.clearRetry();
278
+ this.detachInner();
279
+ }
280
+
281
+ private openInner(): void {
282
+ try {
283
+ this.target = this.openTarget(this.path);
284
+ } catch (err) {
285
+ this.fail(err);
286
+ }
287
+ }
288
+
289
+ /** Give up on the current handle and arm a reopen. Never throws. */
290
+ private fail(err: unknown): void {
291
+ const firstFailure = !this.down;
292
+ this.detachInner();
293
+ this.down = true;
294
+ const delay = this.retryMs;
295
+ this.retryMs = Math.min(this.retryMs * 2, this.maxRetryMs);
296
+ this.scheduleRetry(delay);
297
+ if (!firstFailure) return; // a failed reopen is not news
298
+ const code =
299
+ (err as NodeJS.ErrnoException | undefined)?.code ??
300
+ (err instanceof Error ? err.message : String(err));
301
+ this.emitNotice(
302
+ "warn",
303
+ `Log file sink failed (${code}) — file logging paused, ` +
304
+ `retrying in ${Math.round(delay / 1000)}s`,
305
+ );
306
+ }
307
+
308
+ /** A write landed: the file is usable again. */
309
+ private markHealthy(): void {
310
+ if (!this.down) return;
311
+ this.down = false;
312
+ this.retryMs = this.baseRetryMs;
313
+ const dropped = this.droppedWhileDown;
314
+ this.droppedWhileDown = 0;
315
+ this.emitNotice(
316
+ "info",
317
+ `Log file sink recovered — file logging resumed ` +
318
+ `(${dropped} line(s) dropped while it was down)`,
319
+ );
320
+ }
321
+
322
+ private detachInner(): void {
323
+ const target = this.target;
324
+ this.target = null;
325
+ if (target === null) return;
326
+ try {
327
+ target.close();
328
+ } catch {
329
+ /* best effort */
330
+ }
331
+ }
332
+
333
+ private scheduleRetry(delay: number): void {
334
+ this.clearRetry();
335
+ const timer = setTimeout(() => {
336
+ this.retryTimer = null;
337
+ this.openInner();
338
+ }, delay);
339
+ // A paused log sink must not hold the event loop open.
340
+ timer.unref();
341
+ this.retryTimer = timer;
342
+ }
343
+
344
+ private clearRetry(): void {
345
+ if (this.retryTimer === null) return;
346
+ clearTimeout(this.retryTimer);
347
+ this.retryTimer = null;
348
+ }
349
+
350
+ /**
351
+ * Notices are deferred: `fail()` can run inside pino's multistream
352
+ * write loop, and logging re-entrantly from there would scramble the
353
+ * metadata pino hangs off the stream for the rest of that loop.
354
+ */
355
+ private emitNotice(level: "warn" | "info", message: string): void {
356
+ queueMicrotask(() => {
357
+ try {
358
+ this.notify(level, message);
359
+ } catch {
360
+ /* the notice is the least important thing here */
361
+ }
362
+ });
363
+ }
364
+ }
365
+
366
+ /** Append `line` to `fd`, looping over a short write. Throws on failure. */
367
+ function appendLine(fd: number, line: string): void {
368
+ const buf = Buffer.from(line, "utf-8");
369
+ let offset = 0;
370
+ while (offset < buf.length) {
371
+ offset += writeSync(fd, buf, offset, buf.length - offset);
372
+ }
373
+ }
374
+
375
+ /** The production {@link SyncLogTarget}: an appended fd, written with writeSync. */
376
+ export function openSyncLogFile(path: string): SyncLogTarget {
377
+ // 0600: turns and tool output land here — same sensitivity as history.
378
+ const fd = openSync(path, "a", 0o600);
379
+ return {
380
+ write: (line) => appendLine(fd, line),
381
+ close: () => {
382
+ try {
383
+ closeSync(fd);
384
+ } catch {
385
+ /* already gone */
386
+ }
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Open ~/.talon/respawn.log for a successor's stdout+stderr.
393
+ *
394
+ * A `/restart` or `/update` handoff spawns the next daemon detached; with
395
+ * `stdio: "ignore"` a successor that dies before its logger exists — a
396
+ * broken import after a dependency install, a fatal bind, a runtime that
397
+ * aborts — leaves no trace anywhere, which is exactly how the 2026-09-18
398
+ * handoff vanished. Handing the child this fd makes that visible. Same
399
+ * one-generation rotation as talon.log.
400
+ *
401
+ * Returns null when the file cannot be opened; the caller falls back to
402
+ * nothing, never to a failed handoff.
403
+ */
404
+ export function openRespawnLog(path: string = files.respawnLog): number | null {
405
+ try {
406
+ rotateIfLarge(path);
407
+ return openSync(path, "a", 0o600);
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ function notifyViaConsoleSink(level: "warn" | "info", message: string): void {
414
+ if (level === "warn") logWarn("file", message);
415
+ else log("file", message);
138
416
  }
139
417
 
140
418
  // JSON file output (always active outside test runs).
141
419
  if (!IS_VITEST) {
142
- streams.push({
143
- level: "trace",
144
- // 0600: turns and tool output land here — same sensitivity as history.
145
- stream: createWriteStream(LOG_FILE, { flags: "a", mode: 0o600 }),
146
- });
420
+ streams.push({ level: "trace", stream: new ResilientFileSink(LOG_FILE) });
147
421
  }
148
422
 
149
423
  const logger =
@@ -151,8 +425,24 @@ const logger =
151
425
  ? pino({ level: "trace" }, pino.multistream(streams))
152
426
  : pino({ level: "silent" });
153
427
 
428
+ /**
429
+ * Emit one record. A logger that throws is worse than a silent one: the
430
+ * throw lands on whoever called log(), which at shutdown is a signal
431
+ * handler or a crash handler, and a throw there takes the daemon down —
432
+ * pino-pretty's SonicBoom, for one, throws "SonicBoom destroyed"
433
+ * synchronously on every write once a failure has destroyed it. Nothing
434
+ * a sink does may escape this module.
435
+ */
436
+ function emit(write: () => void): void {
437
+ try {
438
+ write();
439
+ } catch {
440
+ /* a broken logger must never become a broken daemon */
441
+ }
442
+ }
443
+
154
444
  export function log(component: LogComponent, message: string): void {
155
- logger.info({ component }, message);
445
+ emit(() => logger.info({ component }, message));
156
446
  }
157
447
 
158
448
  export function logError(
@@ -164,20 +454,22 @@ export function logError(
164
454
  // Capture both the concise message (for log consumers that look at `err`)
165
455
  // and the full stack (for diagnostics). pino-pretty renders the `stack`
166
456
  // field on its own line; JSON consumers can read either field.
167
- logger.error({ component, err: err.message, stack: err.stack }, message);
457
+ emit(() =>
458
+ logger.error({ component, err: err.message, stack: err.stack }, message),
459
+ );
168
460
  } else if (err !== undefined) {
169
- logger.error({ component, err: String(err) }, message);
461
+ emit(() => logger.error({ component, err: String(err) }, message));
170
462
  } else {
171
- logger.error({ component }, message);
463
+ emit(() => logger.error({ component }, message));
172
464
  }
173
465
  }
174
466
 
175
467
  export function logWarn(component: LogComponent, message: string): void {
176
- logger.warn({ component }, message);
468
+ emit(() => logger.warn({ component }, message));
177
469
  }
178
470
 
179
471
  export function logDebug(component: LogComponent, message: string): void {
180
- logger.debug({ component }, message);
472
+ emit(() => logger.debug({ component }, message));
181
473
  }
182
474
 
183
475
  // Expose logger to plugins running in the same process
package/src/util/paths.ts CHANGED
@@ -102,6 +102,11 @@ export const files = {
102
102
  config: resolve(TALON_ROOT, "config.json"),
103
103
  /** Structured log: ~/.talon/talon.log */
104
104
  log: resolve(TALON_ROOT, "talon.log"),
105
+ /**
106
+ * Successor stdout+stderr during a `/restart` or `/update` handoff:
107
+ * ~/.talon/respawn.log. See core/daemon/respawn.ts.
108
+ */
109
+ respawnLog: resolve(TALON_ROOT, "respawn.log"),
105
110
  /** Legacy JSON session store (imported into talon.db on first boot) */
106
111
  sessions: resolve(TALON_ROOT, "data", "sessions.json"),
107
112
  /** SQLite database (history, sessions, chat settings, media index): ~/.talon/data/talon.db */