talon-agent 5.2.1 → 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 +16 -1
- 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 +128 -81
- package/src/util/paths.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.2",
|
|
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",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"@openai/agents": "^0.18.0",
|
|
111
111
|
"@openai/codex-sdk": "^0.154.0",
|
|
112
112
|
"@opencode-ai/sdk": "^1.17.4",
|
|
113
|
-
"@playwright/mcp": "0.0.
|
|
113
|
+
"@playwright/mcp": "0.0.56",
|
|
114
114
|
"@types/cross-spawn": "^6.0.6",
|
|
115
115
|
"@types/qrcode": "^1.5.6",
|
|
116
116
|
"baileys": "^7.0.0-rc14",
|
package/src/app.ts
CHANGED
|
@@ -29,7 +29,11 @@ import { shutdownTriggers } from "./core/background/triggers/index.js";
|
|
|
29
29
|
import { shutdownAgents } from "./core/agents/index.js";
|
|
30
30
|
import { pruneSettledTriggers } from "./storage/triggers.js";
|
|
31
31
|
import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
BOOT_SMOKE_FLAG,
|
|
34
|
+
BOOT_SMOKE_OK,
|
|
35
|
+
spawnSuccessor,
|
|
36
|
+
} from "./core/daemon/respawn.js";
|
|
33
37
|
import {
|
|
34
38
|
crashCleanup,
|
|
35
39
|
crashStep,
|
|
@@ -67,6 +71,17 @@ import {
|
|
|
67
71
|
stopResourceSampler,
|
|
68
72
|
} from "./core/daemon/resource-sampler.js";
|
|
69
73
|
|
|
74
|
+
// `/update` runs the freshly installed tree with --boot-smoke before it
|
|
75
|
+
// hands off. Every static import above has just been resolved against
|
|
76
|
+
// the new node_modules — reaching this line is the proof that the tree
|
|
77
|
+
// the successor will run is importable at all. Nothing has booted yet,
|
|
78
|
+
// so this is both the strongest and the last harmless place to say so.
|
|
79
|
+
// See core/update/self-update.ts for what a failure does instead.
|
|
80
|
+
if (process.argv.includes(BOOT_SMOKE_FLAG)) {
|
|
81
|
+
console.log(BOOT_SMOKE_OK);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
const { config } = await bootPhase("bootstrap", () => bootstrap());
|
|
71
86
|
|
|
72
87
|
// Record this process as the daemon. The gateway port is appended once
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff watcher — the process that proves a `/restart` or `/update`
|
|
3
|
+
* actually landed.
|
|
4
|
+
*
|
|
5
|
+
* The outgoing daemon spawns its successor and then calls
|
|
6
|
+
* `process.exit()`. Until 2026-09-18 that was the whole handoff, which
|
|
7
|
+
* means nothing in the system knew whether the successor had come up.
|
|
8
|
+
* On that day one didn't: it was spawned, lived about twenty seconds,
|
|
9
|
+
* never bound its gateway, and died — and because the dying parent was
|
|
10
|
+
* the only party to the handoff, Talon simply stayed down until a human
|
|
11
|
+
* noticed forty-five minutes later.
|
|
12
|
+
*
|
|
13
|
+
* So the handoff gets a witness. `spawnSuccessor()` (./respawn.ts)
|
|
14
|
+
* starts this watcher detached, sharing the successor's respawn.log fd.
|
|
15
|
+
* It polls identity-verified discovery (./discovery.ts — `app: "talon"`,
|
|
16
|
+
* `mode: "daemon"`, matching pid) until the successor answers /health or
|
|
17
|
+
* the window closes. Only a /health answer counts: discovery will also
|
|
18
|
+
* report a daemon whose pid is merely alive, and "the process exists" is
|
|
19
|
+
* exactly the claim that was false for 20 seconds on 2026-09-18. If the
|
|
20
|
+
* successor never serves, the watcher starts the daemon exactly
|
|
21
|
+
* the way `talon start` does (./control.ts — same spawn, same boot
|
|
22
|
+
* verification) and says why in the log. The watcher is tiny on purpose:
|
|
23
|
+
* `src/index.ts` dispatches its subcommand before the app graph loads,
|
|
24
|
+
* so it costs a bare runtime and these three modules.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { dirname, resolve } from "node:path";
|
|
28
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
29
|
+
import { startDaemon, type StartOutcome } from "./control.js";
|
|
30
|
+
import { findRunningInstance, type RunningInstance } from "./discovery.js";
|
|
31
|
+
import { isProcessAlive } from "./pidfile.js";
|
|
32
|
+
|
|
33
|
+
/** Hidden subcommand: `talon _handoff-watch <successor-pid>`. */
|
|
34
|
+
export const HANDOFF_WATCH_SUBCOMMAND = "_handoff-watch";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How long a successor gets to answer /health. Generous on purpose: a
|
|
38
|
+
* cold boot with every plugin and MCP server takes ~10s on the reference
|
|
39
|
+
* host, and `startDaemon()` itself waits 30s before calling a boot late.
|
|
40
|
+
*/
|
|
41
|
+
const HANDOFF_WINDOW_MS = 90_000;
|
|
42
|
+
const POLL_MS = 500;
|
|
43
|
+
|
|
44
|
+
export type HandoffOutcome =
|
|
45
|
+
| { ok: true; via: "successor" | "restart"; pid: number; port?: number }
|
|
46
|
+
| { ok: false; reason: string };
|
|
47
|
+
|
|
48
|
+
export type WatchHandoffOptions = {
|
|
49
|
+
/** The pid `spawnSuccessor()` created. */
|
|
50
|
+
childPid: number;
|
|
51
|
+
/** Repo/package root, for the `talon start` equivalent. */
|
|
52
|
+
pkgRoot: string;
|
|
53
|
+
windowMs?: number;
|
|
54
|
+
pollMs?: number;
|
|
55
|
+
pidfilePath?: string;
|
|
56
|
+
/** Injection seams for tests. */
|
|
57
|
+
find?: typeof findRunningInstance;
|
|
58
|
+
alive?: (pid: number) => boolean;
|
|
59
|
+
start?: typeof startDaemon;
|
|
60
|
+
sleep?: (ms: number) => Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
64
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why the wait ended without a live daemon. */
|
|
68
|
+
type WaitFailure = "successor-exited" | "window-expired";
|
|
69
|
+
|
|
70
|
+
/** Discovery found a process; only a /health answer proves it serves. */
|
|
71
|
+
function isServing(instance: RunningInstance | null): boolean {
|
|
72
|
+
return instance?.health !== undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Poll until a serving daemon answers, the successor process
|
|
77
|
+
* disappears, or the window closes. A daemon that isn't our child still
|
|
78
|
+
* counts: the goal is a live Talon, not a particular pid.
|
|
79
|
+
*/
|
|
80
|
+
async function awaitDaemon(
|
|
81
|
+
opts: WatchHandoffOptions,
|
|
82
|
+
): Promise<RunningInstance | WaitFailure> {
|
|
83
|
+
const find = opts.find ?? findRunningInstance;
|
|
84
|
+
const alive = opts.alive ?? isProcessAlive;
|
|
85
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
86
|
+
const pollMs = opts.pollMs ?? POLL_MS;
|
|
87
|
+
const deadline = Date.now() + (opts.windowMs ?? HANDOFF_WINDOW_MS);
|
|
88
|
+
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
const instance = await find(opts.pidfilePath);
|
|
91
|
+
if (instance && isServing(instance)) return instance;
|
|
92
|
+
if (!alive(opts.childPid)) return "successor-exited";
|
|
93
|
+
await sleep(pollMs);
|
|
94
|
+
}
|
|
95
|
+
return "window-expired";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FAILURE_DETAIL: Record<WaitFailure, string> = {
|
|
99
|
+
"successor-exited": "the successor exited before serving /health",
|
|
100
|
+
"window-expired": "the successor never answered /health in time",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function describeStart(outcome: StartOutcome): string {
|
|
104
|
+
if (outcome.ok) return `started (pid ${outcome.pid})`;
|
|
105
|
+
if (outcome.reason === "already-running") {
|
|
106
|
+
return `already running (pid ${outcome.instance.pid})`;
|
|
107
|
+
}
|
|
108
|
+
if (outcome.reason === "boot-timeout") return "spawned but not yet healthy";
|
|
109
|
+
return `${outcome.reason}${outcome.detail ? `: ${outcome.detail}` : ""}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toOutcome(started: StartOutcome, why: string): HandoffOutcome {
|
|
113
|
+
if (started.ok) {
|
|
114
|
+
return { ok: true, via: "restart", pid: started.pid, port: started.port };
|
|
115
|
+
}
|
|
116
|
+
if (started.reason === "already-running") {
|
|
117
|
+
const inst = started.instance;
|
|
118
|
+
// `talon start` refuses while a pid is alive — right, since a second
|
|
119
|
+
// daemon would fight the first for Telegram's getUpdates. But an
|
|
120
|
+
// alive pid that has never served /health is the failure, not the
|
|
121
|
+
// recovery, so it is reported as one.
|
|
122
|
+
if (!isServing(inst)) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: `${why}; pid ${inst.pid} is alive but not serving — kill it and run \`talon start\``,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, via: "restart", pid: inst.pid, port: inst.port };
|
|
129
|
+
}
|
|
130
|
+
return { ok: false, reason: `${why}; restart ${describeStart(started)}` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Verify the handoff, and repair it if it failed. Never throws: this
|
|
135
|
+
* process exists only to make the outcome known.
|
|
136
|
+
*/
|
|
137
|
+
export async function watchHandoff(
|
|
138
|
+
opts: WatchHandoffOptions,
|
|
139
|
+
): Promise<HandoffOutcome> {
|
|
140
|
+
const result = await awaitDaemon(opts);
|
|
141
|
+
if (typeof result !== "string") {
|
|
142
|
+
const via = result.pid === opts.childPid ? "successor" : "restart";
|
|
143
|
+
log(
|
|
144
|
+
"shutdown",
|
|
145
|
+
`Handoff verified — daemon pid ${result.pid} serving on ` +
|
|
146
|
+
`:${result.port ?? "?"} (${via})`,
|
|
147
|
+
);
|
|
148
|
+
return { ok: true, via, pid: result.pid, port: result.port };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const why = FAILURE_DETAIL[result];
|
|
152
|
+
logWarn(
|
|
153
|
+
"shutdown",
|
|
154
|
+
`Handoff failed — ${why}; starting Talon the way \`talon start\` does`,
|
|
155
|
+
);
|
|
156
|
+
const start = opts.start ?? startDaemon;
|
|
157
|
+
const started = await start({
|
|
158
|
+
pkgRoot: opts.pkgRoot,
|
|
159
|
+
pidfilePath: opts.pidfilePath,
|
|
160
|
+
});
|
|
161
|
+
const outcome = toOutcome(started, why);
|
|
162
|
+
if (outcome.ok)
|
|
163
|
+
log("shutdown", `Handoff recovered — ${describeStart(started)}`);
|
|
164
|
+
else logError("shutdown", `Handoff unrecoverable — ${outcome.reason}`);
|
|
165
|
+
return outcome;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** src/core/daemon/ → the package root. */
|
|
169
|
+
function packageRoot(): string {
|
|
170
|
+
const here = import.meta.dirname ?? process.cwd();
|
|
171
|
+
return here.includes("$bunfs") || here.includes("~BUN")
|
|
172
|
+
? dirname(process.execPath)
|
|
173
|
+
: resolve(here, "..", "..", "..");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Entry point for `talon _handoff-watch <pid>` (src/index.ts). */
|
|
177
|
+
export async function runHandoffWatch(argv: readonly string[]): Promise<void> {
|
|
178
|
+
const childPid = Number.parseInt(argv[0] ?? "", 10);
|
|
179
|
+
if (!Number.isInteger(childPid) || childPid <= 0) {
|
|
180
|
+
logError("shutdown", `Handoff watcher got no successor pid (${argv[0]})`);
|
|
181
|
+
process.exitCode = 2;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
log("shutdown", `Handoff watcher armed for pid ${childPid}`);
|
|
185
|
+
try {
|
|
186
|
+
const outcome = await watchHandoff({ childPid, pkgRoot: packageRoot() });
|
|
187
|
+
process.exitCode = outcome.ok ? 0 : 1;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logError("shutdown", "Handoff watcher crashed", err);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,38 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Self-respawn helper for /restart
|
|
2
|
+
* Self-respawn helper for /restart and /update across frontends.
|
|
3
3
|
*
|
|
4
|
-
* Spawns a fresh copy of the current process — same
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* it.
|
|
4
|
+
* Spawns a fresh copy of the current process — same runtime binary, same
|
|
5
|
+
* `execArgv` (preserving a loader so `.ts` entrypoints still resolve),
|
|
6
|
+
* same script + user args, same cwd + env. The new child is detached so
|
|
7
|
+
* it survives the parent's exit; `unref()` lets the parent exit without
|
|
8
|
+
* waiting on it.
|
|
10
9
|
*
|
|
11
|
-
* Why not call the daemon's `talon restart` CLI? That path assumed
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* of launch method.
|
|
10
|
+
* Why not call the daemon's `talon restart` CLI? That path assumed the
|
|
11
|
+
* bot was started via the daemon (talon.pid managed by `daemonStart()`)
|
|
12
|
+
* and broke for anything else — `npm start`, `npx tsx src/index.ts`,
|
|
13
|
+
* systemd, foreman, pm2, or running under a debugger. Respawning from
|
|
14
|
+
* our own `process.argv` works regardless of launch method.
|
|
17
15
|
*
|
|
18
|
-
* Ordering matters. `respawnSelf()` only *arms* the handoff and
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
19
|
+
* (the original behaviour) left the successor long-polling `getUpdates`
|
|
20
|
+
* while the outgoing process was still draining in-flight queries — up
|
|
21
|
+
* to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
|
|
22
|
+
* them and re-delivers the unconfirmed updates to the other, so a
|
|
23
|
+
* restart mid-turn produced a 409 Conflict on the way out and duplicate
|
|
24
|
+
* replies on the way in. Releasing the poll before the successor binds
|
|
25
|
+
* it removes the overlap rather than relying on grammy's 409 retry to
|
|
26
|
+
* paper over it.
|
|
27
|
+
*
|
|
28
|
+
* Two things the 2026-09-18 outage added, both about the fact that the
|
|
29
|
+
* outgoing process is dying and cannot be the one responsible for the
|
|
30
|
+
* outcome:
|
|
31
|
+
*
|
|
32
|
+
* - The successor's stdout and stderr go to ~/.talon/respawn.log, not
|
|
33
|
+
* to "ignore". A successor that dies before its own logger exists —
|
|
34
|
+
* a broken import after a dependency install, a fatal bind, a
|
|
35
|
+
* runtime that aborts — used to leave no trace in any file, on any
|
|
36
|
+
* process. That is precisely what happened: a successor was spawned,
|
|
37
|
+
* lived ~20s, never bound its gateway, and vanished without a line.
|
|
38
|
+
* - A watcher process is spawned alongside it (./handoff.ts). It
|
|
39
|
+
* outlives us, verifies the successor over identity-checked /health
|
|
40
|
+
* within a bounded window, and starts the daemon the way `talon
|
|
41
|
+
* start` does if the successor never comes up. Nothing in the
|
|
42
|
+
* handoff depends on a process that is about to call process.exit().
|
|
29
43
|
*/
|
|
30
44
|
|
|
31
45
|
import { spawn } from "node:child_process";
|
|
32
|
-
import { log, logError } from "../../util/log.js";
|
|
46
|
+
import { log, logError, openRespawnLog } from "../../util/log.js";
|
|
47
|
+
import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
|
|
33
48
|
|
|
34
49
|
let pendingReason: string | null = null;
|
|
35
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Argv flag that makes the daemon entry resolve its whole import graph
|
|
53
|
+
* and exit 0 without booting (src/app.ts acts on it before the first
|
|
54
|
+
* bootstrap step). `/update` runs the freshly installed tree with this
|
|
55
|
+
* flag before handing off — see core/update/self-update.ts.
|
|
56
|
+
*/
|
|
57
|
+
export const BOOT_SMOKE_FLAG = "--boot-smoke";
|
|
58
|
+
|
|
59
|
+
/** Printed by a successful smoke run; the update step matches on it. */
|
|
60
|
+
export const BOOT_SMOKE_OK = "talon boot-smoke ok";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A bun-compiled binary embeds its source tree: `process.argv[1]` points
|
|
64
|
+
* into the virtual FS ($bunfs / ~BUN) and has no path on disk, so the
|
|
65
|
+
* binary is re-invoked with no script argument at all.
|
|
66
|
+
*/
|
|
67
|
+
function isEmbeddedEntry(entry: string): boolean {
|
|
68
|
+
return entry.includes("$bunfs") || entry.includes("~BUN");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The exact command that re-runs this process. */
|
|
72
|
+
export function successorCommand(): { cmd: string; args: string[] } {
|
|
73
|
+
return {
|
|
74
|
+
cmd: process.argv[0],
|
|
75
|
+
args: [...process.execArgv, ...process.argv.slice(1)],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The same runtime + entry, re-invoked with one of our own subcommands. */
|
|
80
|
+
function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
|
|
81
|
+
const entry = process.argv[1] ?? "";
|
|
82
|
+
const prefix = isEmbeddedEntry(entry) ? [] : [...process.execArgv, entry];
|
|
83
|
+
return { cmd: process.argv[0], args: [...prefix, ...extra] };
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
/**
|
|
37
87
|
* Arm a respawn and raise SIGTERM on ourselves so the existing
|
|
38
88
|
* graceful-shutdown path cleanly stops the frontends, flushes state,
|
|
@@ -57,46 +107,71 @@ export function respawnRequested(): boolean {
|
|
|
57
107
|
return pendingReason !== null;
|
|
58
108
|
}
|
|
59
109
|
|
|
110
|
+
export type SpawnFn = typeof spawn;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start the watcher that outlives this process and answers the only
|
|
114
|
+
* question that matters: did the successor actually come up? Never
|
|
115
|
+
* throws — a missing watcher must not cost us the successor itself.
|
|
116
|
+
*/
|
|
117
|
+
function spawnHandoffWatcher(
|
|
118
|
+
childPid: number,
|
|
119
|
+
fd: number | null,
|
|
120
|
+
spawnFn: SpawnFn,
|
|
121
|
+
): void {
|
|
122
|
+
try {
|
|
123
|
+
const { cmd, args } = selfInvocation([
|
|
124
|
+
HANDOFF_WATCH_SUBCOMMAND,
|
|
125
|
+
String(childPid),
|
|
126
|
+
]);
|
|
127
|
+
const watcher = spawnFn(cmd, args, {
|
|
128
|
+
cwd: process.cwd(),
|
|
129
|
+
detached: true,
|
|
130
|
+
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
131
|
+
env: { ...process.env },
|
|
132
|
+
});
|
|
133
|
+
watcher.once("error", (err) => {
|
|
134
|
+
logError("shutdown", "Handoff watcher failed to start", err);
|
|
135
|
+
});
|
|
136
|
+
watcher.unref();
|
|
137
|
+
log("shutdown", `Handoff watcher started (pid ${watcher.pid})`);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logError("shutdown", "Handoff watcher failed to start", err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
60
143
|
/**
|
|
61
|
-
* Spawn the successor process. Called at the end of graceful
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
144
|
+
* Spawn the successor process. Called at the end of graceful shutdown,
|
|
145
|
+
* after the frontends have stopped — so the incoming process binds
|
|
146
|
+
* Telegram's long-poll only once this one has let go of it. No-op unless
|
|
147
|
+
* `respawnSelf()` armed a handoff.
|
|
65
148
|
*
|
|
66
149
|
* Never throws: a failed handoff must not prevent this process from
|
|
67
|
-
* exiting.
|
|
68
|
-
* terminal) can pick things up.
|
|
150
|
+
* exiting. The watcher is the safety net, not an external supervisor.
|
|
69
151
|
*/
|
|
70
|
-
export function spawnSuccessor(): void {
|
|
152
|
+
export function spawnSuccessor(spawnFn: SpawnFn = spawn): void {
|
|
71
153
|
if (pendingReason === null) return;
|
|
72
154
|
const reason = pendingReason;
|
|
73
155
|
pendingReason = null;
|
|
74
156
|
|
|
157
|
+
// One fd for both streams, shared with the watcher: the successor's
|
|
158
|
+
// boot output and the watcher's verdict land in the same file, in
|
|
159
|
+
// order, even when the successor never gets far enough to log.
|
|
160
|
+
const fd = openRespawnLog();
|
|
75
161
|
try {
|
|
76
|
-
const child =
|
|
77
|
-
process.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
stdio: "ignore",
|
|
83
|
-
env: { ...process.env },
|
|
84
|
-
},
|
|
85
|
-
);
|
|
162
|
+
const child = spawnFn(process.argv[0], successorCommand().args, {
|
|
163
|
+
cwd: process.cwd(),
|
|
164
|
+
detached: true,
|
|
165
|
+
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
166
|
+
env: { ...process.env },
|
|
167
|
+
});
|
|
86
168
|
child.once("error", (err) => {
|
|
87
|
-
logError(
|
|
88
|
-
"shutdown",
|
|
89
|
-
`Respawn failed; exiting without a successor — restart manually`,
|
|
90
|
-
err,
|
|
91
|
-
);
|
|
169
|
+
logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
|
|
92
170
|
});
|
|
93
171
|
child.unref();
|
|
94
172
|
log("shutdown", `Respawn child started (pid ${child.pid}) — ${reason}`);
|
|
173
|
+
if (child.pid) spawnHandoffWatcher(child.pid, fd, spawnFn);
|
|
95
174
|
} catch (err) {
|
|
96
|
-
logError(
|
|
97
|
-
"shutdown",
|
|
98
|
-
`Respawn failed; exiting without a successor — restart manually`,
|
|
99
|
-
err,
|
|
100
|
-
);
|
|
175
|
+
logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
|
|
101
176
|
}
|
|
102
177
|
}
|
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
* source tree on disk, so {@link getRepoRoot} returns `null` and the
|
|
9
9
|
* `/update` command is never registered.
|
|
10
10
|
*
|
|
11
|
+
* The install is verified before anyone hands off to it. `npm install`
|
|
12
|
+
* rewrites node_modules underneath the still-running process, so a bad
|
|
13
|
+
* dependency resolution does not surface until the *successor* imports
|
|
14
|
+
* the tree — detached, with its output going nowhere, at the one moment
|
|
15
|
+
* the daemon has no one left to report to. On 2026-09-18 that cost a
|
|
16
|
+
* 45-minute outage. The last step of an update therefore runs the new
|
|
17
|
+
* tree in a child with {@link BOOT_SMOKE_FLAG}: it resolves the daemon's
|
|
18
|
+
* entire import graph and exits without booting. If it fails, the update
|
|
19
|
+
* fails — the caller reports it to the chat and the current process, the
|
|
20
|
+
* one that still works, keeps running.
|
|
21
|
+
*
|
|
11
22
|
* The update force-syncs the checkout to the remote branch with
|
|
12
23
|
* `git reset --hard` (plus `git clean -fd`), discarding any local edits
|
|
13
24
|
* or diverged commits. A bot host is meant to mirror the remote exactly,
|
|
@@ -22,6 +33,11 @@ import { execFile } from "node:child_process";
|
|
|
22
33
|
import { existsSync } from "node:fs";
|
|
23
34
|
import { dirname, join } from "node:path";
|
|
24
35
|
import { fileURLToPath } from "node:url";
|
|
36
|
+
import {
|
|
37
|
+
BOOT_SMOKE_FLAG,
|
|
38
|
+
BOOT_SMOKE_OK,
|
|
39
|
+
successorCommand,
|
|
40
|
+
} from "../daemon/respawn.js";
|
|
25
41
|
|
|
26
42
|
/** Tuning knobs for {@link runSelfUpdate}. */
|
|
27
43
|
export interface UpdateOptions {
|
|
@@ -36,6 +52,11 @@ export interface UpdateOptions {
|
|
|
36
52
|
setup?: readonly string[];
|
|
37
53
|
/** Override the repo root (tests). Defaults to {@link getRepoRoot}. */
|
|
38
54
|
repoRoot?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The command that re-runs this process, used for the post-install
|
|
57
|
+
* import check. Defaults to our own argv (tests override it).
|
|
58
|
+
*/
|
|
59
|
+
entry?: { cmd: string; args: readonly string[] };
|
|
39
60
|
/** Injectable command runner (tests). */
|
|
40
61
|
runner?: CommandRunner;
|
|
41
62
|
}
|
|
@@ -70,6 +91,8 @@ export type CommandRunner = (
|
|
|
70
91
|
) => Promise<{ ok: boolean; output: string }>;
|
|
71
92
|
|
|
72
93
|
const GIT_TIMEOUT_MS = 60_000;
|
|
94
|
+
/** A cold import of the whole daemon graph, on a busy host. */
|
|
95
|
+
const VERIFY_TIMEOUT_MS = 180_000;
|
|
73
96
|
const INSTALL_TIMEOUT_MS = 300_000;
|
|
74
97
|
const SETUP_TIMEOUT_MS = 300_000;
|
|
75
98
|
const MAX_OUTPUT_BUFFER = 16 * 1024 * 1024;
|
|
@@ -265,5 +288,26 @@ export async function runSelfUpdate(
|
|
|
265
288
|
}
|
|
266
289
|
}
|
|
267
290
|
|
|
291
|
+
const entry = opts.entry ?? successorCommand();
|
|
292
|
+
const verify = await record(
|
|
293
|
+
"verify import",
|
|
294
|
+
entry.cmd,
|
|
295
|
+
[...entry.args, BOOT_SMOKE_FLAG],
|
|
296
|
+
VERIFY_TIMEOUT_MS,
|
|
297
|
+
);
|
|
298
|
+
if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
repoRoot,
|
|
302
|
+
steps,
|
|
303
|
+
before,
|
|
304
|
+
after,
|
|
305
|
+
changed,
|
|
306
|
+
error:
|
|
307
|
+
`the updated tree does not import — not restarting into it. ` +
|
|
308
|
+
`Still running ${before}; the checkout is at ${after}.`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
268
312
|
return { ok: true, repoRoot, steps, before, after, changed: true };
|
|
269
313
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Talon entry shim.
|
|
3
3
|
*
|
|
4
|
-
* Dispatches the hidden `_mcp-launch` (MCP supervisor)
|
|
5
|
-
* (WASM Lua trigger runner)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Dispatches the hidden `_mcp-launch` (MCP supervisor), `_lua-run`
|
|
5
|
+
* (WASM Lua trigger runner) and `_handoff-watch` (restart/update
|
|
6
|
+
* witness) subcommands BEFORE the app graph loads — all three are Talon
|
|
7
|
+
* re-invoking itself (see core/mcp-hub/launcher.ts, core/daemon/
|
|
8
|
+
* handoff.ts). The dynamic import keeps these helper processes light:
|
|
9
|
+
* they evaluate this shim and their own module, never the
|
|
10
|
+
* backends/frontends/plugins.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import {
|
|
@@ -13,11 +15,17 @@ import {
|
|
|
13
15
|
runSupervisor,
|
|
14
16
|
} from "./core/mcp-hub/launcher.js";
|
|
15
17
|
import { LUA_RUN_SUBCOMMAND, runLuaMain } from "./core/scripts/lua.js";
|
|
18
|
+
import {
|
|
19
|
+
HANDOFF_WATCH_SUBCOMMAND,
|
|
20
|
+
runHandoffWatch,
|
|
21
|
+
} from "./core/daemon/handoff.js";
|
|
16
22
|
|
|
17
23
|
if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
|
|
18
24
|
await runSupervisor(process.argv.slice(3));
|
|
19
25
|
} else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
|
|
20
26
|
await runLuaMain(process.argv.slice(3));
|
|
27
|
+
} else if (process.argv[2] === HANDOFF_WATCH_SUBCOMMAND) {
|
|
28
|
+
await runHandoffWatch(process.argv.slice(3));
|
|
21
29
|
} else {
|
|
22
30
|
await import("./app.js");
|
|
23
31
|
}
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
* with "428 Precondition Required". @playwright/mcp is therefore pinned
|
|
26
26
|
* exactly in package.json (0.0.56 → playwright 1.58.x, matching python
|
|
27
27
|
* playwright 1.58 which hosts Camoufox — camoufox itself caps playwright at
|
|
28
|
-
* <1.61, so the node client cannot chase latest).
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* <1.61, so the node client cannot chase latest). The pin is enforced, not
|
|
29
|
+
* just documented: see version-coupling.ts (unit test that fails CI on a
|
|
30
|
+
* bump, validateConfig refusal, and a live handshake probe at init).
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -35,6 +35,12 @@ import { dirname, resolve } from "node:path";
|
|
|
35
35
|
import type { TalonPlugin } from "../../core/plugin/types.js";
|
|
36
36
|
import { files } from "../../util/paths.js";
|
|
37
37
|
import { log } from "../../util/log.js";
|
|
38
|
+
import {
|
|
39
|
+
ENDPOINT_PLAYWRIGHT_MINOR,
|
|
40
|
+
bundledPlaywrightVersion,
|
|
41
|
+
couplingError,
|
|
42
|
+
probeEndpoint,
|
|
43
|
+
} from "./version-coupling.js";
|
|
38
44
|
|
|
39
45
|
export function createPlaywrightPlugin(config: {
|
|
40
46
|
browser?: string;
|
|
@@ -141,6 +147,13 @@ export function createPlaywrightPlugin(config: {
|
|
|
141
147
|
);
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
// Endpoint mode: refuse to start on a client/server minor mismatch
|
|
151
|
+
// rather than fail every tool call later (see version-coupling.ts).
|
|
152
|
+
if (endpoint) {
|
|
153
|
+
const coupling = couplingError(bundledPlaywrightVersion());
|
|
154
|
+
if (coupling) errors.push(coupling);
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
return errors.length > 0 ? errors : undefined;
|
|
145
158
|
},
|
|
146
159
|
|
|
@@ -149,6 +162,29 @@ export function createPlaywrightPlugin(config: {
|
|
|
149
162
|
"playwright",
|
|
150
163
|
`Ready (${endpoint ? `Camoufox @ ${endpoint}` : `${browser}, headless=${headless}`})`,
|
|
151
164
|
);
|
|
165
|
+
if (!endpoint) return;
|
|
166
|
+
// Live handshake with the bundled client version: catches a drifted
|
|
167
|
+
// server (the static check only knows the expected minor).
|
|
168
|
+
const client =
|
|
169
|
+
bundledPlaywrightVersion() ?? `${ENDPOINT_PLAYWRIGHT_MINOR}.0`;
|
|
170
|
+
const probe = await probeEndpoint(endpoint, client);
|
|
171
|
+
switch (probe.state) {
|
|
172
|
+
case "match":
|
|
173
|
+
log("playwright", `Endpoint handshake OK (Playwright ${client})`);
|
|
174
|
+
break;
|
|
175
|
+
case "mismatch":
|
|
176
|
+
log(
|
|
177
|
+
"playwright",
|
|
178
|
+
`ERROR: endpoint ${endpoint} is on Playwright ${probe.server} but the bundled client is ${probe.client} — every browser tool call will fail with 428. Align the python playwright hosting Camoufox with ENDPOINT_PLAYWRIGHT_MINOR (${ENDPOINT_PLAYWRIGHT_MINOR}) or re-pin @playwright/mcp.`,
|
|
179
|
+
);
|
|
180
|
+
break;
|
|
181
|
+
case "unreachable":
|
|
182
|
+
log(
|
|
183
|
+
"playwright",
|
|
184
|
+
`Warning: endpoint ${endpoint} did not answer the handshake probe (${probe.reason}); browser tools will fail until it is up.`,
|
|
185
|
+
);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
152
188
|
},
|
|
153
189
|
};
|
|
154
190
|
}
|
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
type ProvisionOutcome,
|
|
29
29
|
} from "../../core/plugin/provision.js";
|
|
30
30
|
import { dirs } from "../../util/paths.js";
|
|
31
|
+
import {
|
|
32
|
+
ENDPOINT_PLAYWRIGHT_MINOR,
|
|
33
|
+
bundledPlaywrightVersion,
|
|
34
|
+
couplingError,
|
|
35
|
+
} from "./version-coupling.js";
|
|
31
36
|
|
|
32
37
|
/** Engines whose builds Playwright manages (vs system channels). */
|
|
33
38
|
const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
|
|
@@ -54,6 +59,8 @@ export interface PlaywrightProvisionDeps {
|
|
|
54
59
|
listDir?: (p: string) => string[];
|
|
55
60
|
/** playwright-core's browser registry (default: browsers.json beside the CLI). */
|
|
56
61
|
registry?: BrowserDescriptor[];
|
|
62
|
+
/** Bundled playwright-core version (default: read from node_modules). */
|
|
63
|
+
bundledPlaywrightVersion?: string;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/** One entry of playwright-core's browsers.json. */
|
|
@@ -287,12 +294,26 @@ export function inspectPlaywright(
|
|
|
287
294
|
deps: PlaywrightProvisionDeps = {},
|
|
288
295
|
): DoctorCheck[] {
|
|
289
296
|
if (section.endpoint ?? section.endpointFile) {
|
|
297
|
+
const bundled = deps.bundledPlaywrightVersion ?? bundledPlaywrightVersion();
|
|
298
|
+
const coupling = couplingError(bundled);
|
|
290
299
|
return [
|
|
291
300
|
{
|
|
292
301
|
label: "Playwright: remote endpoint mode",
|
|
293
302
|
status: "info",
|
|
294
303
|
detail: "browser lives on the remote end",
|
|
295
304
|
},
|
|
305
|
+
coupling
|
|
306
|
+
? {
|
|
307
|
+
label: "Playwright: client/endpoint version mismatch",
|
|
308
|
+
status: "fail",
|
|
309
|
+
detail: coupling,
|
|
310
|
+
issue: true,
|
|
311
|
+
}
|
|
312
|
+
: {
|
|
313
|
+
label: `Playwright: client on endpoint minor ${ENDPOINT_PLAYWRIGHT_MINOR}`,
|
|
314
|
+
status: "ok",
|
|
315
|
+
detail: bundled ? `playwright-core ${bundled}` : undefined,
|
|
316
|
+
},
|
|
296
317
|
];
|
|
297
318
|
}
|
|
298
319
|
const browser = section.browser ?? "chromium";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-mode version coupling — the machinery behind the pin.
|
|
3
|
+
*
|
|
4
|
+
* In endpoint mode the MCP child connects to a remote Playwright server
|
|
5
|
+
* (the python-playwright process hosting Camoufox). Playwright's server
|
|
6
|
+
* refuses the WebSocket upgrade with "428 Precondition Required" when the
|
|
7
|
+
* client's playwright MINOR (sent in the User-Agent, `Playwright/x.y.z`)
|
|
8
|
+
* differs from its own — so a client bump that nobody noticed turns every
|
|
9
|
+
* browser tool call into an opaque error at the worst possible moment.
|
|
10
|
+
*
|
|
11
|
+
* Three guards, all keyed on ENDPOINT_PLAYWRIGHT_MINOR:
|
|
12
|
+
* - a unit test asserts the playwright-core that @playwright/mcp bundles is
|
|
13
|
+
* on that minor, so a dependency bump goes red in CI instead of green;
|
|
14
|
+
* - `validateConfig` refuses to start the plugin on a mismatch, with a
|
|
15
|
+
* message that names both versions and the fix;
|
|
16
|
+
* - `probeEndpoint` performs the real handshake at init and reports the
|
|
17
|
+
* server's verdict, so a drifted *server* is caught too.
|
|
18
|
+
*
|
|
19
|
+
* Bumping: change ENDPOINT_PLAYWRIGHT_MINOR and `@playwright/mcp` in
|
|
20
|
+
* package.json in the same commit, after upgrading the python side
|
|
21
|
+
* (camoufox caps python-playwright, so the node side cannot chase latest).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { request as httpRequest } from "node:http";
|
|
27
|
+
import { request as httpsRequest } from "node:https";
|
|
28
|
+
import { resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Playwright minor of the remote endpoint (python playwright behind
|
|
32
|
+
* Camoufox). Must equal the minor of the playwright-core bundled by the
|
|
33
|
+
* pinned @playwright/mcp — see the header comment before changing it.
|
|
34
|
+
*/
|
|
35
|
+
export const ENDPOINT_PLAYWRIGHT_MINOR = "1.58";
|
|
36
|
+
|
|
37
|
+
/** "1.58.0-alpha-2026-01-16" → "1.58". */
|
|
38
|
+
export function minorOf(version: string): string {
|
|
39
|
+
const m = version.match(/^(\d+)\.(\d+)/);
|
|
40
|
+
return m ? `${m[1]}.${m[2]}` : version;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultModulesRoot(): string {
|
|
44
|
+
return resolve(import.meta.dirname ?? ".", "../../../node_modules");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Version of the playwright-core the MCP child will run with. */
|
|
48
|
+
export function bundledPlaywrightVersion(
|
|
49
|
+
modulesRoot: string = defaultModulesRoot(),
|
|
50
|
+
): string | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(
|
|
53
|
+
readFileSync(
|
|
54
|
+
resolve(modulesRoot, "playwright-core/package.json"),
|
|
55
|
+
"utf-8",
|
|
56
|
+
),
|
|
57
|
+
) as { version?: string };
|
|
58
|
+
return pkg.version;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Static check: does the bundled client sit on the endpoint's minor?
|
|
66
|
+
* Returns an error message, or undefined when coupled (or when the bundle
|
|
67
|
+
* cannot be read — that is reported separately as a missing install).
|
|
68
|
+
*/
|
|
69
|
+
export function couplingError(
|
|
70
|
+
bundled: string | undefined,
|
|
71
|
+
expectedMinor: string = ENDPOINT_PLAYWRIGHT_MINOR,
|
|
72
|
+
): string | undefined {
|
|
73
|
+
if (!bundled) return undefined;
|
|
74
|
+
const got = minorOf(bundled);
|
|
75
|
+
if (got === expectedMinor) return undefined;
|
|
76
|
+
return (
|
|
77
|
+
`@playwright/mcp bundles playwright-core ${bundled} (minor ${got}) but the ` +
|
|
78
|
+
`remote endpoint is on Playwright ${expectedMinor} — every browser tool call ` +
|
|
79
|
+
`would fail with "428 Precondition Required". Pin @playwright/mcp to the ` +
|
|
80
|
+
`release that bundles playwright-core ${expectedMinor}.x, or bump ` +
|
|
81
|
+
`ENDPOINT_PLAYWRIGHT_MINOR together with the python side ` +
|
|
82
|
+
`(src/plugins/playwright/version-coupling.ts).`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type EndpointProbe =
|
|
87
|
+
| { state: "match"; client: string }
|
|
88
|
+
| { state: "mismatch"; client: string; server: string }
|
|
89
|
+
| { state: "unreachable"; client: string; reason: string };
|
|
90
|
+
|
|
91
|
+
/** Parse the body Playwright's server sends with its 428. */
|
|
92
|
+
export function parseMismatch(
|
|
93
|
+
body: string,
|
|
94
|
+
): { server: string; client: string } | undefined {
|
|
95
|
+
const server = body.match(/server version:\s*v?([\d.]+)/);
|
|
96
|
+
const client = body.match(/client version:\s*v?([\d.]+)/);
|
|
97
|
+
return server && client
|
|
98
|
+
? { server: server[1], client: client[1] }
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Perform the WebSocket upgrade the MCP child performs, advertising
|
|
104
|
+
* `clientVersion`, and read the server's verdict. Never throws; never
|
|
105
|
+
* leaves a connection open (a completed upgrade is torn down at once, and
|
|
106
|
+
* Playwright's server treats that as an ordinary client disconnect).
|
|
107
|
+
*/
|
|
108
|
+
export function probeEndpoint(
|
|
109
|
+
endpoint: string,
|
|
110
|
+
clientVersion: string,
|
|
111
|
+
timeoutMs = 3000,
|
|
112
|
+
): Promise<EndpointProbe> {
|
|
113
|
+
return new Promise((settle) => {
|
|
114
|
+
let url: URL;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(endpoint);
|
|
117
|
+
} catch {
|
|
118
|
+
settle({
|
|
119
|
+
state: "unreachable",
|
|
120
|
+
client: clientVersion,
|
|
121
|
+
reason: `invalid endpoint URL: ${endpoint}`,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const secure = url.protocol === "wss:" || url.protocol === "https:";
|
|
126
|
+
const request = secure ? httpsRequest : httpRequest;
|
|
127
|
+
const req = request({
|
|
128
|
+
host: url.hostname,
|
|
129
|
+
port: url.port || (secure ? 443 : 80),
|
|
130
|
+
path: `${url.pathname}${url.search}`,
|
|
131
|
+
method: "GET",
|
|
132
|
+
headers: {
|
|
133
|
+
Connection: "Upgrade",
|
|
134
|
+
Upgrade: "websocket",
|
|
135
|
+
"Sec-WebSocket-Version": "13",
|
|
136
|
+
"Sec-WebSocket-Key": randomBytes(16).toString("base64"),
|
|
137
|
+
"User-Agent": `Playwright/${clientVersion} (talon endpoint probe)`,
|
|
138
|
+
},
|
|
139
|
+
timeout: timeoutMs,
|
|
140
|
+
});
|
|
141
|
+
let settled = false;
|
|
142
|
+
const done = (result: EndpointProbe) => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
req.destroy();
|
|
146
|
+
settle(result);
|
|
147
|
+
};
|
|
148
|
+
req.on("upgrade", (_res, socket) => {
|
|
149
|
+
socket.destroy();
|
|
150
|
+
done({ state: "match", client: clientVersion });
|
|
151
|
+
});
|
|
152
|
+
req.on("response", (res) => {
|
|
153
|
+
let body = "";
|
|
154
|
+
res.setEncoding("utf-8");
|
|
155
|
+
res.on("data", (chunk: string) => {
|
|
156
|
+
body += chunk;
|
|
157
|
+
});
|
|
158
|
+
res.on("end", () => {
|
|
159
|
+
if (res.statusCode === 428) {
|
|
160
|
+
const parsed = parseMismatch(body);
|
|
161
|
+
done(
|
|
162
|
+
parsed
|
|
163
|
+
? {
|
|
164
|
+
state: "mismatch",
|
|
165
|
+
client: clientVersion,
|
|
166
|
+
server: parsed.server,
|
|
167
|
+
}
|
|
168
|
+
: {
|
|
169
|
+
state: "unreachable",
|
|
170
|
+
client: clientVersion,
|
|
171
|
+
reason: "428 without a version box in the body",
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
done({
|
|
177
|
+
state: "unreachable",
|
|
178
|
+
client: clientVersion,
|
|
179
|
+
reason: `HTTP ${res.statusCode ?? "?"} instead of an upgrade`,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
req.on("timeout", () =>
|
|
184
|
+
done({ state: "unreachable", client: clientVersion, reason: "timeout" }),
|
|
185
|
+
);
|
|
186
|
+
req.on("error", (err: Error) =>
|
|
187
|
+
done({
|
|
188
|
+
state: "unreachable",
|
|
189
|
+
client: clientVersion,
|
|
190
|
+
reason: err.message,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
req.end();
|
|
194
|
+
});
|
|
195
|
+
}
|
package/src/util/log.ts
CHANGED
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
statSync,
|
|
17
17
|
renameSync,
|
|
18
18
|
unlinkSync,
|
|
19
|
-
|
|
19
|
+
openSync,
|
|
20
|
+
writeSync,
|
|
21
|
+
closeSync,
|
|
20
22
|
} from "node:fs";
|
|
21
|
-
import { Writable } from "node:stream";
|
|
22
23
|
import { dirs, files } from "./paths.js";
|
|
23
24
|
|
|
24
25
|
export type LogComponent =
|
|
@@ -81,20 +82,29 @@ if (!existsSync(dirs.root)) {
|
|
|
81
82
|
|
|
82
83
|
// Rotate log file on startup if it exceeds 10MB
|
|
83
84
|
const MAX_LOG_SIZE = 10 * 1024 * 1024;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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`;
|
|
87
95
|
try {
|
|
88
96
|
unlinkSync(rotated);
|
|
89
97
|
} catch {
|
|
90
|
-
/*
|
|
98
|
+
/* no previous generation */
|
|
91
99
|
}
|
|
92
|
-
renameSync(
|
|
100
|
+
renameSync(path, rotated);
|
|
101
|
+
} catch {
|
|
102
|
+
/* a log file we cannot rotate is still a log file we can append to */
|
|
93
103
|
}
|
|
94
|
-
} catch {
|
|
95
|
-
/* ignore */
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
rotateIfLarge(LOG_FILE);
|
|
107
|
+
|
|
98
108
|
// Suppress console output for terminal frontend (stdout belongs to the REPL)
|
|
99
109
|
let quiet = process.env.TALON_QUIET === "1";
|
|
100
110
|
if (!quiet) {
|
|
@@ -149,9 +159,21 @@ const SINK_RETRY_MS = 30_000;
|
|
|
149
159
|
/** Ceiling for the doubling backoff between reopen attempts. */
|
|
150
160
|
const SINK_MAX_RETRY_MS = 5 * 60_000;
|
|
151
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
|
+
|
|
152
174
|
export type ResilientFileSinkOptions = {
|
|
153
|
-
/** Opens the underlying file
|
|
154
|
-
open?: (path: string) =>
|
|
175
|
+
/** Opens the underlying file. Injection seam for tests. */
|
|
176
|
+
open?: (path: string) => SyncLogTarget;
|
|
155
177
|
/** Where the pause/resume notices go. Defaults to the console sink. */
|
|
156
178
|
notify?: (level: "warn" | "info", message: string) => void;
|
|
157
179
|
/** First backoff step (default 30s). */
|
|
@@ -161,20 +183,34 @@ export type ResilientFileSinkOptions = {
|
|
|
161
183
|
};
|
|
162
184
|
|
|
163
185
|
/**
|
|
164
|
-
* A log file destination that cannot take the process down
|
|
186
|
+
* A log file destination that cannot take the process down, and cannot
|
|
187
|
+
* lose the last thing the process said.
|
|
165
188
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
|
170
195
|
* inside a log call, so the crash handler's own `logError` runs on a
|
|
171
|
-
* logger that is already broken.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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.
|
|
174
211
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* - write failures pause file logging and destroy the broken stream,
|
|
212
|
+
* pino only ever sees this object, which never throws and never blocks:
|
|
213
|
+
* - write failures pause file logging and close the broken handle,
|
|
178
214
|
* - lines written while paused are DROPPED and counted (never
|
|
179
215
|
* buffered — the failure mode here is "no space", so growing a
|
|
180
216
|
* buffer is the last thing to do),
|
|
@@ -184,26 +220,21 @@ export type ResilientFileSinkOptions = {
|
|
|
184
220
|
* The console sink keeps working throughout, and carries the two
|
|
185
221
|
* notices.
|
|
186
222
|
*/
|
|
187
|
-
export class ResilientFileSink
|
|
223
|
+
export class ResilientFileSink {
|
|
188
224
|
private readonly path: string;
|
|
189
|
-
private readonly
|
|
225
|
+
private readonly openTarget: (path: string) => SyncLogTarget;
|
|
190
226
|
private readonly notify: (level: "warn" | "info", message: string) => void;
|
|
191
227
|
private readonly baseRetryMs: number;
|
|
192
228
|
private readonly maxRetryMs: number;
|
|
193
|
-
private
|
|
229
|
+
private target: SyncLogTarget | null = null;
|
|
194
230
|
private retryMs: number;
|
|
195
231
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
196
232
|
private droppedWhileDown = 0;
|
|
197
233
|
private down = false;
|
|
198
234
|
|
|
199
235
|
constructor(path: string, opts: ResilientFileSinkOptions = {}) {
|
|
200
|
-
// decodeStrings:false keeps pino's serialized lines as strings;
|
|
201
|
-
// autoDestroy:false means a downstream failure can never tear this
|
|
202
|
-
// object down — it is the only thing standing between a broken file
|
|
203
|
-
// and the process.
|
|
204
|
-
super({ decodeStrings: false, autoDestroy: false });
|
|
205
236
|
this.path = path;
|
|
206
|
-
this.
|
|
237
|
+
this.openTarget = opts.open ?? openSyncLogFile;
|
|
207
238
|
this.notify = opts.notify ?? notifyViaConsoleSink;
|
|
208
239
|
this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
|
|
209
240
|
this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
|
|
@@ -221,63 +252,41 @@ export class ResilientFileSink extends Writable {
|
|
|
221
252
|
return this.down;
|
|
222
253
|
}
|
|
223
254
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
): void {
|
|
229
|
-
const inner = this.inner;
|
|
230
|
-
if (inner === null) {
|
|
255
|
+
/** pino.multistream's entire contract: one serialized line in. */
|
|
256
|
+
write(line: string): void {
|
|
257
|
+
const target = this.target;
|
|
258
|
+
if (target === null) {
|
|
231
259
|
this.droppedWhileDown++;
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
inner.write(chunk as string, (err) => {
|
|
235
|
-
if (!err) this.markHealthy();
|
|
236
|
-
});
|
|
237
|
-
} catch (err) {
|
|
238
|
-
// Synchronous throw (write-after-destroy on a stream we have not
|
|
239
|
-
// been told about yet) — same handling as an `error` event.
|
|
240
|
-
this.fail(err);
|
|
241
|
-
}
|
|
260
|
+
return;
|
|
242
261
|
}
|
|
243
|
-
// Always report success: pino must never see this sink fail, and
|
|
244
|
-
// backpressure here would stall whoever called log().
|
|
245
|
-
callback();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
override _final(callback: (error?: Error | null) => void): void {
|
|
249
|
-
this.clearRetry();
|
|
250
262
|
try {
|
|
251
|
-
|
|
252
|
-
} catch {
|
|
253
|
-
|
|
263
|
+
target.write(line);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
this.droppedWhileDown++;
|
|
266
|
+
this.fail(err);
|
|
267
|
+
return;
|
|
254
268
|
}
|
|
255
|
-
|
|
269
|
+
this.markHealthy();
|
|
256
270
|
}
|
|
257
271
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 {
|
|
262
277
|
this.clearRetry();
|
|
263
278
|
this.detachInner();
|
|
264
|
-
callback(null);
|
|
265
279
|
}
|
|
266
280
|
|
|
267
281
|
private openInner(): void {
|
|
268
282
|
try {
|
|
269
|
-
|
|
270
|
-
inner.on("error", (err: Error) => {
|
|
271
|
-
// Ignore errors from a stream we have already given up on.
|
|
272
|
-
if (this.inner === inner) this.fail(err);
|
|
273
|
-
});
|
|
274
|
-
this.inner = inner;
|
|
283
|
+
this.target = this.openTarget(this.path);
|
|
275
284
|
} catch (err) {
|
|
276
285
|
this.fail(err);
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
/** Give up on the current
|
|
289
|
+
/** Give up on the current handle and arm a reopen. Never throws. */
|
|
281
290
|
private fail(err: unknown): void {
|
|
282
291
|
const firstFailure = !this.down;
|
|
283
292
|
this.detachInner();
|
|
@@ -311,15 +320,11 @@ export class ResilientFileSink extends Writable {
|
|
|
311
320
|
}
|
|
312
321
|
|
|
313
322
|
private detachInner(): void {
|
|
314
|
-
const
|
|
315
|
-
this.
|
|
316
|
-
if (
|
|
323
|
+
const target = this.target;
|
|
324
|
+
this.target = null;
|
|
325
|
+
if (target === null) return;
|
|
317
326
|
try {
|
|
318
|
-
|
|
319
|
-
// destroy() can surface one last error — swallow it here rather
|
|
320
|
-
// than let it reach process-level uncaughtException.
|
|
321
|
-
inner.on("error", () => {});
|
|
322
|
-
inner.destroy();
|
|
327
|
+
target.close();
|
|
323
328
|
} catch {
|
|
324
329
|
/* best effort */
|
|
325
330
|
}
|
|
@@ -358,9 +363,51 @@ export class ResilientFileSink extends Writable {
|
|
|
358
363
|
}
|
|
359
364
|
}
|
|
360
365
|
|
|
361
|
-
|
|
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 {
|
|
362
377
|
// 0600: turns and tool output land here — same sensitivity as history.
|
|
363
|
-
|
|
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
|
+
}
|
|
364
411
|
}
|
|
365
412
|
|
|
366
413
|
function notifyViaConsoleSink(level: "warn" | "info", message: string): void {
|
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 */
|