talon-agent 5.18.2 → 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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +49 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/service.ts +26 -15
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/send.ts +8 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +100 -36
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
package/src/util/log.ts
CHANGED
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
openSync,
|
|
20
20
|
writeSync,
|
|
21
21
|
closeSync,
|
|
22
|
+
fstatSync,
|
|
22
23
|
} from "node:fs";
|
|
23
24
|
import { dirs, files } from "./paths.js";
|
|
25
|
+
import { currentTurnId } from "./logging/turn-scope.js";
|
|
24
26
|
|
|
25
27
|
export type LogComponent =
|
|
26
28
|
| "bot"
|
|
@@ -31,8 +33,10 @@ export type LogComponent =
|
|
|
31
33
|
| "kv"
|
|
32
34
|
| "media"
|
|
33
35
|
| "notify"
|
|
36
|
+
| "alert"
|
|
34
37
|
| "agent"
|
|
35
38
|
| "agents"
|
|
39
|
+
| "tasks"
|
|
36
40
|
| "pulse"
|
|
37
41
|
| "userbot"
|
|
38
42
|
| "users"
|
|
@@ -107,6 +111,47 @@ function rotateIfLarge(path: string): void {
|
|
|
107
111
|
|
|
108
112
|
rotateIfLarge(LOG_FILE);
|
|
109
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Runtime retention for talon.log: once the live file passes the cap the
|
|
116
|
+
* sink shifts it to `talon.log.1` (…`.1` → `.2`, oldest dropped), so a
|
|
117
|
+
* long-running daemon keeps a bounded, numbered history instead of one
|
|
118
|
+
* ever-growing file. The start-time `.old` rule above stays as it was;
|
|
119
|
+
* readers treat `.old` as one more generation.
|
|
120
|
+
*/
|
|
121
|
+
const LOG_ROTATE_KEEP = 5;
|
|
122
|
+
|
|
123
|
+
/** The disk name of rotated generation `n` (1 = newest) of `path`. */
|
|
124
|
+
function rotatedLogPath(path: string, n: number): string {
|
|
125
|
+
return `${path}.${n}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Shift `path` into the numbered generations: drop `.keep`, move each
|
|
130
|
+
* `.n` to `.n+1`, then `path` to `.1`. Every step is one rename, so a
|
|
131
|
+
* crash part-way leaves a gap in the numbering, never a lost line — the
|
|
132
|
+
* only file ever deleted is the oldest generation. Throws on the first
|
|
133
|
+
* failure other than a missing generation.
|
|
134
|
+
*/
|
|
135
|
+
export function shiftLogGenerations(
|
|
136
|
+
path: string,
|
|
137
|
+
keep: number = LOG_ROTATE_KEEP,
|
|
138
|
+
): void {
|
|
139
|
+
const skipMissing = (step: () => void): void => {
|
|
140
|
+
try {
|
|
141
|
+
step();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
skipMissing(() => unlinkSync(rotatedLogPath(path, keep)));
|
|
147
|
+
for (let n = keep - 1; n >= 1; n--) {
|
|
148
|
+
skipMissing(() =>
|
|
149
|
+
renameSync(rotatedLogPath(path, n), rotatedLogPath(path, n + 1)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
renameSync(path, rotatedLogPath(path, 1));
|
|
153
|
+
}
|
|
154
|
+
|
|
110
155
|
// Suppress console output for terminal frontend (stdout belongs to the REPL)
|
|
111
156
|
let quiet = process.env.TALON_QUIET === "1";
|
|
112
157
|
if (!quiet) {
|
|
@@ -169,6 +214,8 @@ const SINK_MAX_RETRY_MS = 5 * 60_000;
|
|
|
169
214
|
export type SyncLogTarget = {
|
|
170
215
|
/** Append one already-serialized line. Throws on failure. */
|
|
171
216
|
write(line: string): void;
|
|
217
|
+
/** Bytes already in the file when it was opened, when known. */
|
|
218
|
+
readonly initialSize?: number;
|
|
172
219
|
/** Release the underlying handle. Never throws. */
|
|
173
220
|
close(): void;
|
|
174
221
|
};
|
|
@@ -182,6 +229,13 @@ export type ResilientFileSinkOptions = {
|
|
|
182
229
|
retryMs?: number;
|
|
183
230
|
/** Backoff ceiling (default 5 min). */
|
|
184
231
|
maxRetryMs?: number;
|
|
232
|
+
/**
|
|
233
|
+
* Rotate the file once it passes this many bytes (default 10 MB);
|
|
234
|
+
* 0 disables runtime rotation.
|
|
235
|
+
*/
|
|
236
|
+
rotateAtBytes?: number;
|
|
237
|
+
/** Numbered generations kept by a rotation (default 5). */
|
|
238
|
+
keep?: number;
|
|
185
239
|
};
|
|
186
240
|
|
|
187
241
|
/**
|
|
@@ -221,6 +275,14 @@ export type ResilientFileSinkOptions = {
|
|
|
221
275
|
* many lines were lost.
|
|
222
276
|
* The console sink keeps working throughout, and carries the two
|
|
223
277
|
* notices.
|
|
278
|
+
*
|
|
279
|
+
* It also owns retention: after a write carries the file past
|
|
280
|
+
* `rotateAtBytes` it shifts the generations (see
|
|
281
|
+
* {@link shiftLogGenerations}) and reopens a fresh file, all inside the
|
|
282
|
+
* same synchronous write — a few renames every 10 MB, no queue, no lost
|
|
283
|
+
* line. The size is re-read from disk before rotating, so a file another
|
|
284
|
+
* process already rotated is not rotated twice; a rotation that fails
|
|
285
|
+
* keeps appending to the current file and tries again one cap later.
|
|
224
286
|
*/
|
|
225
287
|
export class ResilientFileSink {
|
|
226
288
|
private readonly path: string;
|
|
@@ -233,6 +295,12 @@ export class ResilientFileSink {
|
|
|
233
295
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
234
296
|
private droppedWhileDown = 0;
|
|
235
297
|
private down = false;
|
|
298
|
+
private readonly rotateAtBytes: number;
|
|
299
|
+
private readonly keep: number;
|
|
300
|
+
/** Bytes in the current file, as far as this process has seen. */
|
|
301
|
+
private bytes = 0;
|
|
302
|
+
/** Size at which the next rotation check runs. */
|
|
303
|
+
private rotateCheckAt: number;
|
|
236
304
|
|
|
237
305
|
constructor(path: string, opts: ResilientFileSinkOptions = {}) {
|
|
238
306
|
this.path = path;
|
|
@@ -241,6 +309,9 @@ export class ResilientFileSink {
|
|
|
241
309
|
this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
|
|
242
310
|
this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
|
|
243
311
|
this.retryMs = this.baseRetryMs;
|
|
312
|
+
this.rotateAtBytes = opts.rotateAtBytes ?? MAX_LOG_SIZE;
|
|
313
|
+
this.keep = opts.keep ?? LOG_ROTATE_KEEP;
|
|
314
|
+
this.rotateCheckAt = this.rotateAtBytes;
|
|
244
315
|
this.openInner();
|
|
245
316
|
}
|
|
246
317
|
|
|
@@ -269,6 +340,10 @@ export class ResilientFileSink {
|
|
|
269
340
|
return;
|
|
270
341
|
}
|
|
271
342
|
this.markHealthy();
|
|
343
|
+
this.bytes += Buffer.byteLength(line, "utf-8");
|
|
344
|
+
if (this.rotateAtBytes > 0 && this.bytes > this.rotateCheckAt) {
|
|
345
|
+
this.rotate();
|
|
346
|
+
}
|
|
272
347
|
}
|
|
273
348
|
|
|
274
349
|
/** No-op: every write already reached the fd. Part of pino's shape. */
|
|
@@ -283,11 +358,59 @@ export class ResilientFileSink {
|
|
|
283
358
|
private openInner(): void {
|
|
284
359
|
try {
|
|
285
360
|
this.target = this.openTarget(this.path);
|
|
361
|
+
this.bytes = this.target.initialSize ?? 0;
|
|
362
|
+
this.rotateCheckAt = this.rotateAtBytes;
|
|
286
363
|
} catch (err) {
|
|
287
364
|
this.fail(err);
|
|
288
365
|
}
|
|
289
366
|
}
|
|
290
367
|
|
|
368
|
+
/**
|
|
369
|
+
* The current file passed the cap: shift the generations and reopen.
|
|
370
|
+
* Never throws — a failed rename reopens the same file and appends
|
|
371
|
+
* on, a failed reopen goes through the ordinary pause/retry path.
|
|
372
|
+
*/
|
|
373
|
+
private rotate(): void {
|
|
374
|
+
let onDisk: number;
|
|
375
|
+
try {
|
|
376
|
+
onDisk = statSync(this.path).size;
|
|
377
|
+
} catch {
|
|
378
|
+
// Unlinked under us: nothing to shift, just start a fresh file.
|
|
379
|
+
this.detachInner();
|
|
380
|
+
this.openInner();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (onDisk <= this.rotateAtBytes) {
|
|
384
|
+
// Someone else (another Talon process) already rotated it.
|
|
385
|
+
this.bytes = onDisk;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// Close before renaming: a platform that refuses to rename an open
|
|
389
|
+
// file (Windows) then fails the shift cleanly instead of half-way.
|
|
390
|
+
this.detachInner();
|
|
391
|
+
let failure: unknown = null;
|
|
392
|
+
try {
|
|
393
|
+
shiftLogGenerations(this.path, this.keep);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
failure = err;
|
|
396
|
+
}
|
|
397
|
+
this.openInner();
|
|
398
|
+
if (failure !== null) {
|
|
399
|
+
this.rotateCheckAt = this.bytes + this.rotateAtBytes;
|
|
400
|
+
const code = (failure as NodeJS.ErrnoException).code ?? String(failure);
|
|
401
|
+
this.emitNotice(
|
|
402
|
+
"warn",
|
|
403
|
+
`log.rotate failed file=${this.path} code=${code} — still appending`,
|
|
404
|
+
);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.emitNotice(
|
|
408
|
+
"info",
|
|
409
|
+
`log.rotate file=${this.path} bytes=${onDisk} keep=${this.keep} ` +
|
|
410
|
+
`previous=${rotatedLogPath(this.path, 1)}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
291
414
|
/** Give up on the current handle and arm a reopen. Never throws. */
|
|
292
415
|
private fail(err: unknown): void {
|
|
293
416
|
const firstFailure = !this.down;
|
|
@@ -378,7 +501,14 @@ function appendLine(fd: number, line: string): void {
|
|
|
378
501
|
export function openSyncLogFile(path: string): SyncLogTarget {
|
|
379
502
|
// 0600: turns and tool output land here — same sensitivity as history.
|
|
380
503
|
const fd = openSync(path, "a", 0o600);
|
|
504
|
+
let initialSize = 0;
|
|
505
|
+
try {
|
|
506
|
+
initialSize = fstatSync(fd).size;
|
|
507
|
+
} catch {
|
|
508
|
+
/* unknown size: rotation counts from zero */
|
|
509
|
+
}
|
|
381
510
|
return {
|
|
511
|
+
initialSize,
|
|
382
512
|
write: (line) => appendLine(fd, line),
|
|
383
513
|
close: () => {
|
|
384
514
|
try {
|
|
@@ -443,8 +573,57 @@ function emit(write: () => void): void {
|
|
|
443
573
|
}
|
|
444
574
|
}
|
|
445
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Append `turn=<id>` when the line is written from inside a running
|
|
578
|
+
* turn (see logging/turn-scope.ts), unless the caller already named it. The
|
|
579
|
+
* id goes in the message text, not a field, so a plain `grep` over
|
|
580
|
+
* talon.log and the pretty console both show it.
|
|
581
|
+
*/
|
|
582
|
+
function tagTurn(message: string): string {
|
|
583
|
+
const turnId = currentTurnId();
|
|
584
|
+
if (!turnId) return message;
|
|
585
|
+
const tag = `turn=${turnId}`;
|
|
586
|
+
return message.includes(tag) ? message : `${message} ${tag}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
446
589
|
export function log(component: LogComponent, message: string): void {
|
|
447
|
-
emit(() => logger.info({ component }, message));
|
|
590
|
+
emit(() => logger.info({ component }, tagTurn(message)));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
type LogErrorListener = (
|
|
594
|
+
component: LogComponent,
|
|
595
|
+
message: string,
|
|
596
|
+
err?: unknown,
|
|
597
|
+
) => void;
|
|
598
|
+
|
|
599
|
+
let errorListener: LogErrorListener | null = null;
|
|
600
|
+
let inErrorListener = false;
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Register (or clear, with null) the one listener every `logError` call
|
|
604
|
+
* reaches — core's error-rate alarm (core/daemon/health-alerts.ts). util
|
|
605
|
+
* cannot import core, so core registers itself here. The listener runs
|
|
606
|
+
* synchronously and must be cheap; it cannot throw into the caller, and
|
|
607
|
+
* a `logError` it makes itself is not fed back to it.
|
|
608
|
+
*/
|
|
609
|
+
export function onLogError(fn: LogErrorListener | null): void {
|
|
610
|
+
errorListener = fn;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function notifyErrorListener(
|
|
614
|
+
component: LogComponent,
|
|
615
|
+
message: string,
|
|
616
|
+
err: unknown,
|
|
617
|
+
): void {
|
|
618
|
+
if (!errorListener || inErrorListener) return;
|
|
619
|
+
inErrorListener = true;
|
|
620
|
+
try {
|
|
621
|
+
errorListener(component, message, err);
|
|
622
|
+
} catch {
|
|
623
|
+
/* an alarm must never break the error path it watches */
|
|
624
|
+
} finally {
|
|
625
|
+
inErrorListener = false;
|
|
626
|
+
}
|
|
448
627
|
}
|
|
449
628
|
|
|
450
629
|
export function logError(
|
|
@@ -452,26 +631,30 @@ export function logError(
|
|
|
452
631
|
message: string,
|
|
453
632
|
err?: unknown,
|
|
454
633
|
): void {
|
|
634
|
+
notifyErrorListener(component, message, err);
|
|
455
635
|
if (err instanceof Error) {
|
|
456
636
|
// Capture both the concise message (for log consumers that look at `err`)
|
|
457
637
|
// and the full stack (for diagnostics). pino-pretty renders the `stack`
|
|
458
638
|
// field on its own line; JSON consumers can read either field.
|
|
459
639
|
emit(() =>
|
|
460
|
-
logger.error(
|
|
640
|
+
logger.error(
|
|
641
|
+
{ component, err: err.message, stack: err.stack },
|
|
642
|
+
tagTurn(message),
|
|
643
|
+
),
|
|
461
644
|
);
|
|
462
645
|
} else if (err !== undefined) {
|
|
463
|
-
emit(() => logger.error({ component, err: String(err) }, message));
|
|
646
|
+
emit(() => logger.error({ component, err: String(err) }, tagTurn(message)));
|
|
464
647
|
} else {
|
|
465
|
-
emit(() => logger.error({ component }, message));
|
|
648
|
+
emit(() => logger.error({ component }, tagTurn(message)));
|
|
466
649
|
}
|
|
467
650
|
}
|
|
468
651
|
|
|
469
652
|
export function logWarn(component: LogComponent, message: string): void {
|
|
470
|
-
emit(() => logger.warn({ component }, message));
|
|
653
|
+
emit(() => logger.warn({ component }, tagTurn(message)));
|
|
471
654
|
}
|
|
472
655
|
|
|
473
656
|
export function logDebug(component: LogComponent, message: string): void {
|
|
474
|
-
emit(() => logger.debug({ component }, message));
|
|
657
|
+
emit(() => logger.debug({ component }, tagTurn(message)));
|
|
475
658
|
}
|
|
476
659
|
|
|
477
660
|
// Expose logger to plugins running in the same process
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log context — which turn a log line belongs to, carried implicitly.
|
|
3
|
+
*
|
|
4
|
+
* A turn fans out across the weaver, the backend adapter, the event
|
|
5
|
+
* shuttle and the gateway's tool actions; threading an id through every
|
|
6
|
+
* call site would touch dozens of signatures. Instead the Weaver runs
|
|
7
|
+
* each turn inside an AsyncLocalStorage scope and `log.ts` appends
|
|
8
|
+
* `turn=<id>` to every line written while that scope is live. One
|
|
9
|
+
* `grep turn=t-…` over talon.log then reconstructs the whole turn.
|
|
10
|
+
*
|
|
11
|
+
* Two traps shape the API:
|
|
12
|
+
*
|
|
13
|
+
* - **Stale scopes.** Async resources created during a turn (a
|
|
14
|
+
* long-lived subprocess, a timer a tool left behind) keep that turn's
|
|
15
|
+
* scope forever. A scope is therefore closed when its turn settles,
|
|
16
|
+
* and a closed scope tags nothing: a late line goes untagged rather
|
|
17
|
+
* than blamed on a turn that already ended.
|
|
18
|
+
* - **Tool actions arrive out of band.** The MCP subprocess calls the
|
|
19
|
+
* gateway over HTTP, so a tool action starts a fresh async root with
|
|
20
|
+
* no scope. The chat → open-scope map lets the gateway re-enter the
|
|
21
|
+
* chat's running turn by chat id.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
export type TurnLogScope = {
|
|
28
|
+
readonly turnId: string;
|
|
29
|
+
readonly chatId: string;
|
|
30
|
+
/** False once the turn has settled — the scope then tags nothing. */
|
|
31
|
+
open: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const storage = new AsyncLocalStorage<TurnLogScope>();
|
|
35
|
+
/** The running turn per chat (queued turns are not registered). */
|
|
36
|
+
const runningByChat = new Map<string, TurnLogScope>();
|
|
37
|
+
|
|
38
|
+
/** A short, greppable turn id: `t-` + 7 base36 chars. */
|
|
39
|
+
function mintTurnId(): string {
|
|
40
|
+
return `t-${randomBytes(4).readUInt32BE(0).toString(36).padStart(7, "0")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A fresh scope for a turn that has been accepted but not yet started. */
|
|
44
|
+
export function createTurnScope(chatId: string): TurnLogScope {
|
|
45
|
+
return { turnId: mintTurnId(), chatId, open: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run `fn` as the chat's running turn: every log line written from its
|
|
50
|
+
* async chain carries `turn=<id>` until {@link closeTurnScope}.
|
|
51
|
+
*/
|
|
52
|
+
export function runInTurnScope<T>(scope: TurnLogScope, fn: () => T): T {
|
|
53
|
+
runningByChat.set(scope.chatId, scope);
|
|
54
|
+
return storage.run(scope, fn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mark the turn settled: its scope stops tagging lines. Idempotent. */
|
|
58
|
+
export function closeTurnScope(scope: TurnLogScope): void {
|
|
59
|
+
scope.open = false;
|
|
60
|
+
if (runningByChat.get(scope.chatId) === scope) {
|
|
61
|
+
runningByChat.delete(scope.chatId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run `fn` inside the running turn of the first chat id in `chatIds`
|
|
67
|
+
* that has one, or plainly when none does. Used by the gateway so a
|
|
68
|
+
* tool action's lines join the turn that issued it.
|
|
69
|
+
*/
|
|
70
|
+
export function runInChatTurnScope<T>(
|
|
71
|
+
chatIds: readonly (string | null | undefined)[],
|
|
72
|
+
fn: () => T,
|
|
73
|
+
): T {
|
|
74
|
+
for (const chatId of chatIds) {
|
|
75
|
+
const scope = chatId ? runningByChat.get(chatId) : undefined;
|
|
76
|
+
if (scope?.open) return storage.run(scope, fn);
|
|
77
|
+
}
|
|
78
|
+
return fn();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The live turn id for the current async chain, if any. */
|
|
82
|
+
export function currentTurnId(): string | undefined {
|
|
83
|
+
const scope = storage.getStore();
|
|
84
|
+
return scope?.open ? scope.turnId : undefined;
|
|
85
|
+
}
|
package/src/util/time.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Timezone-aware time formatting utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Every helper formats in the IANA timezone set via `setTimezone`
|
|
5
|
+
* (e.g. "Europe/Warsaw"), falling back to the system default.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
let configuredTz: string | undefined;
|
|
@@ -94,7 +94,7 @@ export function formatRelativeAge(ts: number): string {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Full datetime for system prompt injection.
|
|
97
|
-
* Example: "2026-03-21 14:32 (Europe/Warsaw
|
|
97
|
+
* Example: "2026-03-21 14:32 Fri (Europe/Warsaw)"
|
|
98
98
|
*/
|
|
99
99
|
export function formatFullDatetime(): string {
|
|
100
100
|
const now = new Date();
|
package/src/util/watchdog.ts
CHANGED
|
@@ -126,9 +126,33 @@ let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
126
126
|
let stuckWarnBackoffMs = STUCK_WARN_MS;
|
|
127
127
|
let nextStuckWarnAt = 0;
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Who hears about a wedged loop beyond the log. util is a leaf, so the
|
|
131
|
+
* engine registers this (it raises the operator alert and knows what is
|
|
132
|
+
* queued); the watchdog only says when it starts and when it ends.
|
|
133
|
+
*/
|
|
134
|
+
export type StuckLoopListener = {
|
|
135
|
+
onStuck(info: { pendingMins: number; silentMins: number }): void;
|
|
136
|
+
onRecovered(): void;
|
|
137
|
+
};
|
|
138
|
+
let stuckListener: StuckLoopListener | null = null;
|
|
139
|
+
let stuckReported = false;
|
|
140
|
+
|
|
141
|
+
export function setStuckLoopListener(listener: StuckLoopListener | null): void {
|
|
142
|
+
stuckListener = listener;
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
function resetStuckWarnBackoff(): void {
|
|
130
146
|
stuckWarnBackoffMs = STUCK_WARN_MS;
|
|
131
147
|
nextStuckWarnAt = 0;
|
|
148
|
+
if (stuckReported) {
|
|
149
|
+
stuckReported = false;
|
|
150
|
+
try {
|
|
151
|
+
stuckListener?.onRecovered();
|
|
152
|
+
} catch {
|
|
153
|
+
/* a listener fault must not break turn bookkeeping */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
export function startWatchdog(workspaceDir?: string): void {
|
|
@@ -150,6 +174,12 @@ export function startWatchdog(workspaceDir?: string): void {
|
|
|
150
174
|
"watchdog",
|
|
151
175
|
`Message received ${pendingMins} minutes ago is still unprocessed with no turn activity for ${silentMins} minutes — the message loop may be wedged`,
|
|
152
176
|
);
|
|
177
|
+
stuckReported = true;
|
|
178
|
+
try {
|
|
179
|
+
stuckListener?.onStuck({ pendingMins, silentMins });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
logWarn("watchdog", `stuck-loop listener failed: ${String(err)}`);
|
|
182
|
+
}
|
|
153
183
|
nextStuckWarnAt = now + stuckWarnBackoffMs;
|
|
154
184
|
stuckWarnBackoffMs = Math.min(
|
|
155
185
|
stuckWarnBackoffMs * 2,
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Legacy single-active aliases (chat role).
|
|
3
|
-
*
|
|
4
|
-
* Preserved so consumers that pre-date the pool refactor stay green.
|
|
5
|
-
* All route to the chat role — the canonical "default" backend.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
9
|
-
import type { TalonConfig } from "../../config/index.js";
|
|
10
|
-
import { type BackendInitContext } from "../../agent-runtime/backend-registry.js";
|
|
11
|
-
import { log } from "../../../util/log.js";
|
|
12
|
-
import { roleHolder } from "./holders.js";
|
|
13
|
-
import { pool, bindings, ctx, ensurePoolEntry } from "./state.js";
|
|
14
|
-
import {
|
|
15
|
-
getBackendForRole,
|
|
16
|
-
getBackendIdForRole,
|
|
17
|
-
getBackendLabelForRole,
|
|
18
|
-
cleanupBackendPool,
|
|
19
|
-
resetBackendPoolForTest,
|
|
20
|
-
} from "./pool.js";
|
|
21
|
-
import { rebindRole } from "./rebind.js";
|
|
22
|
-
import type { RebindResult } from "./types.js";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Initialise the controller with a single backend bound to the chat
|
|
26
|
-
* role. Convenience for tests; production should use `initBackendPool`.
|
|
27
|
-
*/
|
|
28
|
-
export async function initBackendController(
|
|
29
|
-
id: string,
|
|
30
|
-
config: TalonConfig,
|
|
31
|
-
initContext: BackendInitContext,
|
|
32
|
-
): Promise<Backend> {
|
|
33
|
-
ctx.initCtx = initContext;
|
|
34
|
-
ctx.poolConfig = config;
|
|
35
|
-
const entry = await ensurePoolEntry(id, config);
|
|
36
|
-
const holder = roleHolder("chat");
|
|
37
|
-
entry.holders.add(holder);
|
|
38
|
-
bindings.set(holder, id);
|
|
39
|
-
log("backend-controller", `Active backend (chat): ${entry.label} (${id})`);
|
|
40
|
-
return entry.backend;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Equivalent to `getBackendForRole("chat")`. Legacy alias. */
|
|
44
|
-
export function getActiveBackend(): Backend {
|
|
45
|
-
if (!bindings.has(roleHolder("chat"))) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
"Backend controller not initialised — call initBackendController first",
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return getBackendForRole("chat");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Whether the chat role has been bound. Legacy alias. */
|
|
54
|
-
export function hasActiveBackend(): boolean {
|
|
55
|
-
return bindings.has(roleHolder("chat"));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Like `getActiveBackend` but returns `null` instead of throwing. */
|
|
59
|
-
export function getActiveBackendOrNull(): Backend | null {
|
|
60
|
-
if (!bindings.has(roleHolder("chat"))) return null;
|
|
61
|
-
try {
|
|
62
|
-
return getBackendForRole("chat");
|
|
63
|
-
} catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Id of the chat-role backend. Legacy alias. */
|
|
69
|
-
export function getActiveBackendId(): string {
|
|
70
|
-
if (!bindings.has(roleHolder("chat"))) {
|
|
71
|
-
throw new Error("Backend controller not initialised");
|
|
72
|
-
}
|
|
73
|
-
return getBackendIdForRole("chat");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Label of the chat-role backend. Legacy alias. */
|
|
77
|
-
export function getActiveBackendLabel(): string {
|
|
78
|
-
if (!bindings.has(roleHolder("chat"))) {
|
|
79
|
-
throw new Error("Backend controller not initialised");
|
|
80
|
-
}
|
|
81
|
-
return getBackendLabelForRole("chat");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Rebind the chat role. Legacy alias for `rebindRole("chat", id, config)`. */
|
|
85
|
-
export async function switchBackend(
|
|
86
|
-
id: string,
|
|
87
|
-
config: TalonConfig,
|
|
88
|
-
): Promise<RebindResult> {
|
|
89
|
-
if (!bindings.has(roleHolder("chat"))) {
|
|
90
|
-
return { ok: false, error: "Backend controller not initialised" };
|
|
91
|
-
}
|
|
92
|
-
const current = bindings.get(roleHolder("chat"));
|
|
93
|
-
if (current === id) {
|
|
94
|
-
const entry = pool.get(current);
|
|
95
|
-
return {
|
|
96
|
-
ok: false,
|
|
97
|
-
error: `Already on ${entry?.label ?? current}`,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
return rebindRole("chat", id, config);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Tear down the entire pool. Legacy alias for `cleanupBackendPool`. */
|
|
104
|
-
export async function cleanupBackendController(): Promise<void> {
|
|
105
|
-
await cleanupBackendPool();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Reset state. Test-only. Legacy alias. */
|
|
109
|
-
export function resetBackendControllerForTest(): void {
|
|
110
|
-
resetBackendPoolForTest();
|
|
111
|
-
}
|