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/package.json +2 -2
- package/src/app.ts +57 -33
- package/src/core/daemon/crash.ts +82 -0
- package/src/core/daemon/handoff.ts +192 -0
- package/src/core/daemon/respawn.ts +127 -52
- package/src/core/update/self-update.ts +44 -0
- package/src/index.ts +13 -5
- package/src/plugins/playwright/index.ts +39 -3
- package/src/plugins/playwright/provision.ts +21 -0
- package/src/plugins/playwright/version-coupling.ts +195 -0
- package/src/util/log.ts +318 -26
- package/src/util/paths.ts +5 -0
package/src/util/log.ts
CHANGED
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
statSync,
|
|
17
17
|
renameSync,
|
|
18
18
|
unlinkSync,
|
|
19
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/*
|
|
98
|
+
/* no previous generation */
|
|
90
99
|
}
|
|
91
|
-
renameSync(
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 */
|