@f5-sales-demo/xcsh 19.58.3 → 19.61.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/package.json +8 -8
- package/src/browser/chat-handler.ts +6 -0
- package/src/cli/chrome-cli.ts +36 -3
- package/src/cli/update-cli.ts +16 -0
- package/src/commands/chrome.ts +1 -1
- package/src/commands/manager-core.ts +64 -1
- package/src/commands/manager.ts +180 -5
- package/src/commands/native-host.ts +110 -5
- package/src/commands/worker.ts +18 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/services/manager-state.ts +48 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.61.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
57
|
"@mozilla/readability": "^0.6",
|
|
58
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
58
|
+
"@f5-sales-demo/xcsh-stats": "19.61.0",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.61.0",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.61.0",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.61.0",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.61.0",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.61.0",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.61.0",
|
|
65
65
|
"@sinclair/typebox": "^0.34",
|
|
66
66
|
"@xterm/headless": "^6.0",
|
|
67
67
|
"ajv": "^8.20",
|
|
@@ -156,6 +156,12 @@ export class ChatHandler {
|
|
|
156
156
|
this.#server.send(frame);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** True while a chat turn is in flight (streaming or an active request). Used by
|
|
160
|
+
* the worker's SIGTERM drain to let a running turn finish before teardown (#1874). */
|
|
161
|
+
get busy(): boolean {
|
|
162
|
+
return this.#activeChats.size > 0 || this.#session.isStreaming;
|
|
163
|
+
}
|
|
164
|
+
|
|
159
165
|
dispose(): void {
|
|
160
166
|
for (const chat of this.#activeChats.values()) {
|
|
161
167
|
this.#sendTerminal(chat, { type: "chat_error", id: chat.id, error: "session disposed" });
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -7,14 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
12
13
|
import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
|
|
13
14
|
import { installNativeHost } from "../services/native-host-install";
|
|
14
15
|
|
|
16
|
+
/** Ask a running manager to step down (control-socket `shutdown` frame). Resolves
|
|
17
|
+
* true if a manager answered the socket, false if none was running. Best-effort. */
|
|
18
|
+
async function requestManagerShutdown(reason: "updated"): Promise<boolean> {
|
|
19
|
+
const sock = process.env.XCSH_MANAGER_SOCK ?? path.join(homedir(), ".xcsh", "manager.sock");
|
|
20
|
+
try {
|
|
21
|
+
const c = await Bun.connect({ unix: sock, socket: { data() {} } });
|
|
22
|
+
c.write(`${JSON.stringify({ type: "shutdown", reason })}\n`);
|
|
23
|
+
await Bun.sleep(50);
|
|
24
|
+
c.end();
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false; // no manager listening
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
type Settings = { get(key: string): unknown };
|
|
16
32
|
|
|
17
|
-
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host";
|
|
33
|
+
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host" | "recycle";
|
|
18
34
|
|
|
19
35
|
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
20
36
|
|
|
@@ -56,10 +72,15 @@ export function nativeHostLaunchCommand(
|
|
|
56
72
|
execPath: string = process.execPath,
|
|
57
73
|
resolveXcshBin: () => string | null = defaultResolveXcshBin,
|
|
58
74
|
): string[] {
|
|
75
|
+
// Prefer a VERSION-STABLE launcher on PATH (e.g. the Homebrew symlink
|
|
76
|
+
// /opt/homebrew/bin/xcsh) for BOTH compiled and bun installs. This keeps the
|
|
77
|
+
// native-messaging wrapper working across `brew upgrade`s instead of pinning
|
|
78
|
+
// the install-time versioned path (…/Cellar/xcsh/<v>/bin/xcsh), which froze
|
|
79
|
+
// the extension on an old version until install-host was re-run (#1874).
|
|
80
|
+
const xcsh = resolveXcshBin();
|
|
81
|
+
if (xcsh) return [xcsh];
|
|
59
82
|
const base = path.basename(execPath).toLowerCase();
|
|
60
83
|
if (base.startsWith("bun")) {
|
|
61
|
-
const xcsh = resolveXcshBin();
|
|
62
|
-
if (xcsh) return [xcsh];
|
|
63
84
|
const script = argv[1];
|
|
64
85
|
if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
|
|
65
86
|
return [execPath, path.resolve(script)];
|
|
@@ -111,6 +132,18 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
111
132
|
const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
|
|
112
133
|
return `Native-messaging host manifest written to:\n ${manifestPath}`;
|
|
113
134
|
}
|
|
135
|
+
if (action === "recycle") {
|
|
136
|
+
// Proactive post-upgrade recycle (#1874 Task 7): refresh the wrapper to the
|
|
137
|
+
// version-stable launcher, then ask any running (old) manager to step down so
|
|
138
|
+
// the new version takes effect NOW instead of on the next Chrome launch. The
|
|
139
|
+
// successor re-adopts live workers (zero-downtime). Both best-effort.
|
|
140
|
+
const launchCommand = nativeHostLaunchCommand();
|
|
141
|
+
installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
|
|
142
|
+
const stepped = await requestManagerShutdown("updated");
|
|
143
|
+
return stepped
|
|
144
|
+
? "Refreshed native-host wrapper; asked the running manager to recycle to this version."
|
|
145
|
+
: "Refreshed native-host wrapper; no manager was running (it will start fresh on next use).";
|
|
146
|
+
}
|
|
114
147
|
// relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
|
|
115
148
|
const { mode } = await acquirePage({ settings, allowRelaunch: true });
|
|
116
149
|
return `Chrome ready (${mode}). Your real, logged-in session is now debuggable for xcsh.`;
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -315,6 +315,10 @@ function updateViaBrew(targetPath: string, expectedVersion: string): void {
|
|
|
315
315
|
console.log(chalk.yellow(`To update to ${expectedVersion}, run:`));
|
|
316
316
|
console.log(chalk.cyan(` brew upgrade ${APP_NAME}`));
|
|
317
317
|
console.log(chalk.dim("\nThis ensures the update goes through your organization's Homebrew tap."));
|
|
318
|
+
// #1874 Task 7: brew upgrade runs out-of-band, so we can't recycle automatically.
|
|
319
|
+
// Nudge the one command that applies it to the Chrome extension immediately.
|
|
320
|
+
console.log(chalk.dim(`Then run ${APP_NAME} chrome recycle to apply it to the extension now`));
|
|
321
|
+
console.log(chalk.dim("(otherwise it takes effect the next time you open Chrome)."));
|
|
318
322
|
}
|
|
319
323
|
|
|
320
324
|
/**
|
|
@@ -413,6 +417,18 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
|
|
|
413
417
|
await updateViaBinaryAt(target.path, release.version);
|
|
414
418
|
break;
|
|
415
419
|
}
|
|
420
|
+
|
|
421
|
+
// #1874 Task 7: the binary just changed under a possibly-running OLD manager.
|
|
422
|
+
// Proactively recycle so the new version takes effect now instead of on the
|
|
423
|
+
// next Chrome launch — refresh the native-host wrapper + step the old manager
|
|
424
|
+
// down (its successor re-adopts live workers, zero-downtime). The just-updated
|
|
425
|
+
// `xcsh` on PATH is the new version. Best-effort, non-blocking; passive
|
|
426
|
+
// supersede covers it if this is skipped.
|
|
427
|
+
try {
|
|
428
|
+
Bun.spawn(["xcsh", "chrome", "recycle"], { stdout: "ignore", stderr: "ignore" });
|
|
429
|
+
} catch {
|
|
430
|
+
/* recycle is a convenience — never fail an update on it */
|
|
431
|
+
}
|
|
416
432
|
} catch (err) {
|
|
417
433
|
console.error(chalk.red(`Update failed: ${err}`));
|
|
418
434
|
process.exit(1);
|
package/src/commands/chrome.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
|
|
|
5
5
|
import { type ChromeAction, runChromeCommand } from "../cli/chrome-cli";
|
|
6
6
|
import { Settings, settings } from "../config/settings";
|
|
7
7
|
|
|
8
|
-
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host"];
|
|
8
|
+
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host", "recycle"];
|
|
9
9
|
|
|
10
10
|
export default class Chrome extends Command {
|
|
11
11
|
static description = "Inspect or arrange the Chrome session xcsh drives for console automation";
|
|
@@ -10,10 +10,26 @@ export interface WorkerRec {
|
|
|
10
10
|
|
|
11
11
|
export type Registry = Map<string, WorkerRec>;
|
|
12
12
|
|
|
13
|
+
/** Reasons a manager is asked to step down (#1874). Closed set — fail closed. */
|
|
14
|
+
export type ShutdownReason = "superseded" | "updated" | "manual";
|
|
15
|
+
const SHUTDOWN_REASONS = new Set<string>(["superseded", "updated", "manual"]);
|
|
16
|
+
|
|
13
17
|
export type ControlMsg =
|
|
14
18
|
| { type: "provision"; sessionId: string; tenant: string }
|
|
15
19
|
| { type: "release"; sessionId: string }
|
|
16
|
-
| { type: "status" }
|
|
20
|
+
| { type: "status" }
|
|
21
|
+
| { type: "hello" }
|
|
22
|
+
| { type: "shutdown"; reason: ShutdownReason };
|
|
23
|
+
|
|
24
|
+
/** The manager's on-disk liveness record (`~/.xcsh/manager.json`), used for
|
|
25
|
+
* observability and as the escalation target (pid) when a superseded manager
|
|
26
|
+
* won't release the socket. The control-socket `hello` answer is authoritative. */
|
|
27
|
+
export interface ManagerState {
|
|
28
|
+
pid: number;
|
|
29
|
+
version: string;
|
|
30
|
+
socket: string;
|
|
31
|
+
startedAt: number;
|
|
32
|
+
}
|
|
17
33
|
|
|
18
34
|
function isTenant(v: unknown): v is string {
|
|
19
35
|
return typeof v === "string" && /^[^|]+\|[^|]+$/.test(v);
|
|
@@ -30,9 +46,56 @@ export function parseControlMsg(raw: unknown): ControlMsg | null {
|
|
|
30
46
|
if (m.type === "provision" && isNonEmpty(m.sessionId) && isTenant(m.tenant))
|
|
31
47
|
return { type: "provision", sessionId: m.sessionId, tenant: m.tenant };
|
|
32
48
|
if (m.type === "release" && isNonEmpty(m.sessionId)) return { type: "release", sessionId: m.sessionId };
|
|
49
|
+
if (m.type === "hello") return { type: "hello" };
|
|
50
|
+
if (m.type === "shutdown" && typeof m.reason === "string" && SHUTDOWN_REASONS.has(m.reason))
|
|
51
|
+
return { type: "shutdown", reason: m.reason as ShutdownReason };
|
|
33
52
|
return null;
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
/** Parse a plain `major.minor.patch` version into a numeric tuple, or null. */
|
|
56
|
+
function parseSemver(v: string | null | undefined): [number, number, number] | null {
|
|
57
|
+
if (typeof v !== "string") return null;
|
|
58
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True iff `ourVersion` is a valid version strictly greater than a valid
|
|
64
|
+
* `runningVersion` — the trigger to replace an older manager. Fails closed
|
|
65
|
+
* (false) on equal/newer/any-unparseable input, so we never downgrade or flap. */
|
|
66
|
+
export function shouldSupersede(runningVersion: string | null, ourVersion: string): boolean {
|
|
67
|
+
const a = parseSemver(runningVersion);
|
|
68
|
+
const b = parseSemver(ourVersion);
|
|
69
|
+
if (!a || !b) return false;
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
if (b[i] > a[i]) return true;
|
|
72
|
+
if (b[i] < a[i]) return false;
|
|
73
|
+
}
|
|
74
|
+
return false; // equal
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serialize the manager liveness record for `~/.xcsh/manager.json`. */
|
|
78
|
+
export function serializeManagerState(s: ManagerState): string {
|
|
79
|
+
return JSON.stringify({ pid: s.pid, version: s.version, socket: s.socket, startedAt: s.startedAt });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse `~/.xcsh/manager.json`; null on corrupt/missing/ill-shaped input
|
|
83
|
+
* (a positive pid, non-empty version + socket, and finite startedAt required). */
|
|
84
|
+
export function parseManagerState(text: string): ManagerState | null {
|
|
85
|
+
let raw: unknown;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!raw || typeof raw !== "object") return null;
|
|
92
|
+
const m = raw as Record<string, unknown>;
|
|
93
|
+
if (typeof m.pid !== "number" || !Number.isInteger(m.pid) || m.pid <= 0) return null;
|
|
94
|
+
if (!isNonEmpty(m.version) || !isNonEmpty(m.socket)) return null;
|
|
95
|
+
if (typeof m.startedAt !== "number" || !Number.isFinite(m.startedAt)) return null;
|
|
96
|
+
return { pid: m.pid, version: m.version, socket: m.socket, startedAt: m.startedAt };
|
|
97
|
+
}
|
|
98
|
+
|
|
36
99
|
/** Idempotency: only provision when there is no live worker for the sessionId. */
|
|
37
100
|
export function needsProvision(reg: Registry, sessionId: string): boolean {
|
|
38
101
|
return !reg.has(sessionId);
|
package/src/commands/manager.ts
CHANGED
|
@@ -20,7 +20,11 @@ import * as fs from "node:fs";
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
22
22
|
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
23
|
+
// Lean subpath (not the package barrel, which pulls the winston logger graph and
|
|
24
|
+
// slows the manager's cold start — keep the daemon's module graph minimal).
|
|
25
|
+
import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
|
|
23
26
|
import { portCandidates } from "../browser/extension-bridge";
|
|
27
|
+
import { removeManagerState, writeManagerState } from "../services/manager-state";
|
|
24
28
|
import { needsProvision, parseControlMsg, pickPort, type Registry, sparesToSpawn, staleKeys } from "./manager-core";
|
|
25
29
|
|
|
26
30
|
/** Reap a worker idle longer than this (ms). */
|
|
@@ -50,6 +54,66 @@ function isPortFree(port: number): boolean {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/** The PID listening on a loopback bridge port, or 0 if unknown (best-effort via
|
|
58
|
+
* lsof). Used only to give a re-adopted worker a reapable pid; 0 means "manage by
|
|
59
|
+
* socket liveness, never signal" (see killPid's pid<=0 guard). */
|
|
60
|
+
function pidListeningOn(port: number): number {
|
|
61
|
+
try {
|
|
62
|
+
const out = Bun.spawnSync(["lsof", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"])
|
|
63
|
+
.stdout.toString()
|
|
64
|
+
.trim()
|
|
65
|
+
.split("\n")[0];
|
|
66
|
+
const pid = Number(out);
|
|
67
|
+
return Number.isInteger(pid) && pid > 0 ? pid : 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return 0; // lsof unavailable — leave unknown
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Complete the extension `hello` handshake against a bridge port (with the
|
|
74
|
+
* origin header the bridge requires), resolving the `hello_ack` frame or null.
|
|
75
|
+
* EXTENSION_ID is lazy-required so it stays off the manager's cold-start path. */
|
|
76
|
+
function bridgeHello(port: number, timeoutMs = 400): Promise<Record<string, unknown> | null> {
|
|
77
|
+
return new Promise(resolve => {
|
|
78
|
+
let done = false;
|
|
79
|
+
const finish = (v: Record<string, unknown> | null) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
done = true;
|
|
82
|
+
resolve(v);
|
|
83
|
+
};
|
|
84
|
+
let ws: WebSocket;
|
|
85
|
+
try {
|
|
86
|
+
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
87
|
+
ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
88
|
+
headers: { Origin: `chrome-extension://${EXTENSION_ID}` },
|
|
89
|
+
} as unknown as string[]);
|
|
90
|
+
} catch {
|
|
91
|
+
return finish(null);
|
|
92
|
+
}
|
|
93
|
+
const close = () => {
|
|
94
|
+
try {
|
|
95
|
+
ws.close();
|
|
96
|
+
} catch {
|
|
97
|
+
/* already closing */
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
ws.onopen = () => ws.send(JSON.stringify({ type: "hello" }));
|
|
101
|
+
ws.onmessage = e => {
|
|
102
|
+
try {
|
|
103
|
+
finish(JSON.parse(String(e.data)) as Record<string, unknown>);
|
|
104
|
+
} catch {
|
|
105
|
+
finish(null);
|
|
106
|
+
}
|
|
107
|
+
close();
|
|
108
|
+
};
|
|
109
|
+
ws.onerror = () => finish(null);
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
close();
|
|
112
|
+
finish(null);
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
53
117
|
/**
|
|
54
118
|
* The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
|
|
55
119
|
*
|
|
@@ -130,6 +194,17 @@ export default class Manager extends Command {
|
|
|
130
194
|
const reg: Registry = new Map();
|
|
131
195
|
const range = portCandidates();
|
|
132
196
|
|
|
197
|
+
// The version this manager advertises (hello ack + manager.json). Overridable
|
|
198
|
+
// via env ONLY so the supersede integration tests can stand up an "old" manager
|
|
199
|
+
// (production always reports the real binary VERSION).
|
|
200
|
+
const selfVersion = process.env.XCSH_MANAGER_VERSION || VERSION;
|
|
201
|
+
|
|
202
|
+
// Lifecycle gate (#1874): once we begin graceful shutdown we stop accepting
|
|
203
|
+
// provisions (the host retries the successor manager) and never double-run
|
|
204
|
+
// the teardown.
|
|
205
|
+
let accepting = true;
|
|
206
|
+
let shuttingDown = false;
|
|
207
|
+
|
|
133
208
|
const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
|
|
134
209
|
interface SpareRec {
|
|
135
210
|
proc: Bun.Subprocess;
|
|
@@ -180,6 +255,34 @@ export default class Manager extends Command {
|
|
|
180
255
|
for (let i = 0; i < n; i++) spawnSpare();
|
|
181
256
|
};
|
|
182
257
|
|
|
258
|
+
/** Zero-downtime handoff (#1874 Task 6): on startup, discover bridge workers a
|
|
259
|
+
* PRIOR (superseded) manager left running and re-register them, so a tab's
|
|
260
|
+
* session survives the manager swap. Probes the whole range in parallel; a
|
|
261
|
+
* bridge answering with a real per-tab sessionId (not the "spare" sentinel) and
|
|
262
|
+
* a tenant+env is re-adopted. Spares/unknowns are ignored (the pool refills
|
|
263
|
+
* them). Best-effort — never throws. */
|
|
264
|
+
const readoptWorkers = async (): Promise<void> => {
|
|
265
|
+
const found = await Promise.all(range.map(async port => ({ port, ack: await bridgeHello(port) })));
|
|
266
|
+
for (const { port, ack } of found) {
|
|
267
|
+
if (!ack) continue;
|
|
268
|
+
const sid = ack.sessionId;
|
|
269
|
+
const tenant = ack.tenant;
|
|
270
|
+
const env = ack.env;
|
|
271
|
+
if (typeof sid !== "string" || sid === "spare" || typeof tenant !== "string" || typeof env !== "string") {
|
|
272
|
+
continue; // spare sentinel or tenant-less bridge — not a re-adoptable session
|
|
273
|
+
}
|
|
274
|
+
if (reg.has(sid)) continue;
|
|
275
|
+
reg.set(sid, {
|
|
276
|
+
sessionId: sid,
|
|
277
|
+
tenant: `${tenant}|${env}`,
|
|
278
|
+
port,
|
|
279
|
+
pid: pidListeningOn(port),
|
|
280
|
+
lastSeen: Date.now(),
|
|
281
|
+
});
|
|
282
|
+
console.error(`[xcsh manager] re-adopted worker ${sid} (${tenant}|${env}) on port ${port}`);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
183
286
|
/** Adopt a warm spare for a provision (bind over IPC). Returns false if none available. */
|
|
184
287
|
const adoptSpare = (msg: { sessionId: string; tenant: string }): boolean => {
|
|
185
288
|
const rec = pool.shift();
|
|
@@ -204,15 +307,51 @@ export default class Manager extends Command {
|
|
|
204
307
|
return true;
|
|
205
308
|
};
|
|
206
309
|
|
|
310
|
+
const killPid = (pid: number): void => {
|
|
311
|
+
if (pid <= 0) return; // 0/negative = unknown (re-adopted worker) — NEVER signal (kill(0) hits the group)
|
|
312
|
+
try {
|
|
313
|
+
process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
|
|
314
|
+
} catch {
|
|
315
|
+
/* already gone */
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
207
319
|
const reap = (sessionId: string): void => {
|
|
208
320
|
const w = reg.get(sessionId);
|
|
209
321
|
if (!w) return;
|
|
322
|
+
killPid(w.pid);
|
|
323
|
+
reg.delete(sessionId);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Graceful teardown (#1874): stop accepting, terminate the (session-less)
|
|
327
|
+
* spare pool at once, drop the socket + liveness record, and exit. Idempotent.
|
|
328
|
+
*
|
|
329
|
+
* Bound workers depend on the reason: on a HANDOFF (superseded/updated — a
|
|
330
|
+
* successor manager is about to bind and re-adopt them), we LEAVE them running
|
|
331
|
+
* so tab sessions survive the swap (zero-downtime, Task 6). Otherwise (manual
|
|
332
|
+
* operator SIGTERM — no successor) we SIGTERM them so they drain + exit rather
|
|
333
|
+
* than leak. The manager frees the socket immediately either way. */
|
|
334
|
+
const gracefulShutdown = (reason: string): void => {
|
|
335
|
+
if (shuttingDown) return;
|
|
336
|
+
shuttingDown = true;
|
|
337
|
+
accepting = false;
|
|
338
|
+
const handoff = reason === "superseded" || reason === "updated";
|
|
339
|
+
console.error(
|
|
340
|
+
`[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s)` +
|
|
341
|
+
(handoff ? `, leaving ${reg.size} worker(s) for re-adoption` : ` + ${reg.size} worker(s)`),
|
|
342
|
+
);
|
|
343
|
+
for (const s of pool.splice(0)) killPid(s.pid);
|
|
344
|
+
if (!handoff) {
|
|
345
|
+
for (const w of reg.values()) killPid(w.pid);
|
|
346
|
+
reg.clear();
|
|
347
|
+
}
|
|
348
|
+
removeManagerState(sockPath);
|
|
210
349
|
try {
|
|
211
|
-
|
|
350
|
+
fs.rmSync(sockPath, { force: true });
|
|
212
351
|
} catch {
|
|
213
|
-
/*
|
|
352
|
+
/* best effort — the OS drops the bound socket on exit anyway */
|
|
214
353
|
}
|
|
215
|
-
|
|
354
|
+
process.exit(0);
|
|
216
355
|
};
|
|
217
356
|
|
|
218
357
|
const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
|
|
@@ -257,7 +396,7 @@ export default class Manager extends Command {
|
|
|
257
396
|
console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
|
|
258
397
|
};
|
|
259
398
|
|
|
260
|
-
const handleFrame = (line: string): void => {
|
|
399
|
+
const handleFrame = (line: string, socket: { write(data: string): void }): void => {
|
|
261
400
|
const trimmed = line.trim();
|
|
262
401
|
if (!trimmed) return;
|
|
263
402
|
let raw: unknown;
|
|
@@ -269,6 +408,15 @@ export default class Manager extends Command {
|
|
|
269
408
|
const msg = parseControlMsg(raw);
|
|
270
409
|
if (!msg) return; // fail closed on unknown/invalid frames
|
|
271
410
|
if (msg.type === "provision") {
|
|
411
|
+
// Draining: refuse new work so the host retries the successor manager.
|
|
412
|
+
if (!accepting) {
|
|
413
|
+
try {
|
|
414
|
+
socket.write(`${JSON.stringify({ type: "draining" })}\n`);
|
|
415
|
+
} catch {
|
|
416
|
+
/* client hung up */
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
272
420
|
if (needsProvision(reg, msg.sessionId)) {
|
|
273
421
|
if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
|
|
274
422
|
}
|
|
@@ -276,6 +424,19 @@ export default class Manager extends Command {
|
|
|
276
424
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
277
425
|
} else if (msg.type === "release") {
|
|
278
426
|
reap(msg.sessionId);
|
|
427
|
+
} else if (msg.type === "shutdown") {
|
|
428
|
+
// A newer native-host (supersede) or the updater asks us to step down.
|
|
429
|
+
gracefulShutdown(msg.reason);
|
|
430
|
+
} else if (msg.type === "hello") {
|
|
431
|
+
// Identity handshake (#1874): a newer native-host reads our version to
|
|
432
|
+
// decide whether to supersede us. Answer over the same connection.
|
|
433
|
+
try {
|
|
434
|
+
socket.write(
|
|
435
|
+
`${JSON.stringify({ type: "manager_hello_ack", version: selfVersion, pid: process.pid })}\n`,
|
|
436
|
+
);
|
|
437
|
+
} catch {
|
|
438
|
+
/* client hung up mid-handshake — ignore */
|
|
439
|
+
}
|
|
279
440
|
}
|
|
280
441
|
// "status" is validated but currently a no-op sink.
|
|
281
442
|
};
|
|
@@ -292,7 +453,7 @@ export default class Manager extends Command {
|
|
|
292
453
|
const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
|
|
293
454
|
const parts = combined.split("\n");
|
|
294
455
|
buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
|
|
295
|
-
for (const line of parts) handleFrame(line);
|
|
456
|
+
for (const line of parts) handleFrame(line, socket as { write(data: string): void });
|
|
296
457
|
},
|
|
297
458
|
close(socket: object): void {
|
|
298
459
|
buffers.delete(socket);
|
|
@@ -331,6 +492,20 @@ export default class Manager extends Command {
|
|
|
331
492
|
}
|
|
332
493
|
console.error(`[xcsh manager] control socket listening at ${sockPath}`);
|
|
333
494
|
|
|
495
|
+
// Publish our liveness record (#1874) so a newer native-host can see which
|
|
496
|
+
// version owns the socket (and, if it must supersede us, find our pid).
|
|
497
|
+
writeManagerState(sockPath, { pid: process.pid, version: selfVersion, socket: sockPath, startedAt: Date.now() });
|
|
498
|
+
|
|
499
|
+
// Clean-signal handling: an operator SIGTERM (or upgrade/recycle) tears us
|
|
500
|
+
// down gracefully instead of orphaning workers + a stale socket/state file.
|
|
501
|
+
process.on("SIGTERM", () => gracefulShutdown("manual"));
|
|
502
|
+
process.on("SIGINT", () => gracefulShutdown("manual"));
|
|
503
|
+
|
|
504
|
+
// Zero-downtime handoff: re-adopt any bound workers a superseded manager left
|
|
505
|
+
// running BEFORE filling the pool (so their ports aren't mistaken for free).
|
|
506
|
+
// Awaited but bounded (~parallel 400ms); the socket already accepts connections.
|
|
507
|
+
await readoptWorkers();
|
|
508
|
+
|
|
334
509
|
// Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
|
|
335
510
|
if (poolTarget > 0) maintainPool();
|
|
336
511
|
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
19
|
+
// Lean subpath (not the barrel) to keep the relay's cold start minimal.
|
|
20
|
+
import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
|
|
19
21
|
import { decodeNm, encodeNm } from "../browser/native-messaging";
|
|
22
|
+
import { readManagerState } from "../services/manager-state";
|
|
20
23
|
import { reexecArgv } from "./manager";
|
|
24
|
+
import { shouldSupersede } from "./manager-core";
|
|
21
25
|
|
|
22
26
|
/** Manager control socket — MUST match `commands/manager.ts` exactly. */
|
|
23
27
|
const SOCKET_PATH = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
|
|
@@ -117,11 +121,24 @@ export default class ChromeHost extends Command {
|
|
|
117
121
|
* Returns the connected socket, or undefined if unreachable after backoff.
|
|
118
122
|
*/
|
|
119
123
|
private async ensureManager(socket: Parameters<typeof Bun.connect>[0]["socket"]): Promise<Sock | undefined> {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
// Version-aware supersede (#1874): if a manager already owns the socket, read
|
|
125
|
+
// its version. If THIS binary is newer (e.g. a `brew upgrade` happened but the
|
|
126
|
+
// old detached manager is still running), ask it to step down and take over —
|
|
127
|
+
// otherwise the extension stays pinned to the stale version forever. Same-or-
|
|
128
|
+
// newer running version → just use it (never downgrade).
|
|
129
|
+
const runningVersion = await this.#managerVersion();
|
|
130
|
+
if (runningVersion !== null && !shouldSupersede(runningVersion, VERSION)) {
|
|
131
|
+
dbg(`manager live @ ${runningVersion} (ours ${VERSION}) — connecting`);
|
|
132
|
+
try {
|
|
133
|
+
return await Bun.connect({ unix: SOCKET_PATH, socket });
|
|
134
|
+
} catch {
|
|
135
|
+
dbg("live manager raced away before relay connect → spawn");
|
|
136
|
+
}
|
|
137
|
+
} else if (runningVersion !== null) {
|
|
138
|
+
dbg(`supersede: running ${runningVersion} < ours ${VERSION} → step down`);
|
|
139
|
+
await this.#stepDownRunningManager();
|
|
140
|
+
} else {
|
|
141
|
+
dbg("no manager reachable → spawn");
|
|
125
142
|
}
|
|
126
143
|
|
|
127
144
|
// Spawn the manager DETACHED and long-lived. It is NOT awaited/retained so
|
|
@@ -145,4 +162,92 @@ export default class ChromeHost extends Command {
|
|
|
145
162
|
}
|
|
146
163
|
return undefined;
|
|
147
164
|
}
|
|
165
|
+
|
|
166
|
+
/** The running manager's advertised version via the `hello` handshake, or null
|
|
167
|
+
* if none is reachable. Uses a throwaway connection (separate from the relay). */
|
|
168
|
+
async #managerVersion(timeoutMs = 1500): Promise<string | null> {
|
|
169
|
+
const ack = await this.#managerRequest({ type: "hello" }, timeoutMs);
|
|
170
|
+
return typeof ack?.version === "string" ? ack.version : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Send one control frame and resolve the manager's first reply line (or null). */
|
|
174
|
+
#managerRequest(frame: unknown, timeoutMs: number): Promise<Record<string, unknown> | null> {
|
|
175
|
+
return new Promise(resolve => {
|
|
176
|
+
let buf = "";
|
|
177
|
+
let done = false;
|
|
178
|
+
const finish = (v: Record<string, unknown> | null) => {
|
|
179
|
+
if (done) return;
|
|
180
|
+
done = true;
|
|
181
|
+
resolve(v);
|
|
182
|
+
};
|
|
183
|
+
Bun.connect({
|
|
184
|
+
unix: SOCKET_PATH,
|
|
185
|
+
socket: {
|
|
186
|
+
open(c) {
|
|
187
|
+
c.write(`${JSON.stringify(frame)}\n`);
|
|
188
|
+
},
|
|
189
|
+
data(c, d) {
|
|
190
|
+
buf += d.toString("utf8");
|
|
191
|
+
const nl = buf.indexOf("\n");
|
|
192
|
+
if (nl < 0) return;
|
|
193
|
+
try {
|
|
194
|
+
finish(JSON.parse(buf.slice(0, nl)) as Record<string, unknown>);
|
|
195
|
+
} catch {
|
|
196
|
+
finish(null);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
c.end();
|
|
200
|
+
} catch {
|
|
201
|
+
/* already closing */
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
error() {
|
|
205
|
+
finish(null);
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}).catch(() => finish(null));
|
|
209
|
+
setTimeout(() => finish(null), timeoutMs);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** True if anything is accepting the control socket right now. */
|
|
214
|
+
async #managerReachable(): Promise<boolean> {
|
|
215
|
+
try {
|
|
216
|
+
const c = await Bun.connect({ unix: SOCKET_PATH, socket: { data() {} } });
|
|
217
|
+
c.end();
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Ask the running manager to step down, then wait (bounded) for the socket to
|
|
225
|
+
* free — escalating to SIGTERM on the recorded pid if it won't release. */
|
|
226
|
+
async #stepDownRunningManager(timeoutMs = 5000): Promise<void> {
|
|
227
|
+
try {
|
|
228
|
+
const c = await Bun.connect({ unix: SOCKET_PATH, socket: { data() {} } });
|
|
229
|
+
c.write(`${JSON.stringify({ type: "shutdown", reason: "superseded" })}\n`);
|
|
230
|
+
await Bun.sleep(50);
|
|
231
|
+
c.end();
|
|
232
|
+
} catch {
|
|
233
|
+
/* already gone */
|
|
234
|
+
}
|
|
235
|
+
const deadline = Date.now() + timeoutMs;
|
|
236
|
+
let escalated = false;
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
if (!(await this.#managerReachable())) return; // socket freed → success
|
|
239
|
+
if (!escalated && Date.now() > deadline - timeoutMs / 2) {
|
|
240
|
+
const st = readManagerState(SOCKET_PATH); // won't drain gracefully → SIGTERM the pid
|
|
241
|
+
if (st?.pid) {
|
|
242
|
+
try {
|
|
243
|
+
process.kill(st.pid);
|
|
244
|
+
} catch {
|
|
245
|
+
/* already gone */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
escalated = true;
|
|
249
|
+
}
|
|
250
|
+
await Bun.sleep(100);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
148
253
|
}
|
package/src/commands/worker.ts
CHANGED
|
@@ -73,6 +73,9 @@ export function sessionInfoForWorker(): {
|
|
|
73
73
|
return { tenant, env, apiUrl, contextBound, sessionId };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Hard ceiling for draining an in-flight chat turn on SIGTERM before teardown (#1874). */
|
|
77
|
+
const WORKER_DRAIN_TIMEOUT_MS = 10_000;
|
|
78
|
+
|
|
76
79
|
/** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
|
|
77
80
|
* With scoped tools the ONLY way to create a resource is the form-driven workflow
|
|
78
81
|
* runner, which is exactly what the human watching the browser wants. */
|
|
@@ -197,11 +200,24 @@ export default class Worker extends Command {
|
|
|
197
200
|
logger.endTiming();
|
|
198
201
|
|
|
199
202
|
let shuttingDown = false;
|
|
203
|
+
const teardown = () => {
|
|
204
|
+
chatHandler.dispose();
|
|
205
|
+
void bridge.close().finally(() => process.exit(0));
|
|
206
|
+
};
|
|
200
207
|
const shutdown = () => {
|
|
201
208
|
if (shuttingDown) return;
|
|
202
209
|
shuttingDown = true;
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
// Bounded drain (#1874): if a chat turn is in flight (e.g. the manager is
|
|
211
|
+
// recycling for an upgrade), let it finish before teardown instead of
|
|
212
|
+
// aborting the running agent turn — with a hard ceiling so a hung turn can
|
|
213
|
+
// never wedge shutdown. An idle worker tears down immediately.
|
|
214
|
+
if (!chatHandler.busy) return teardown();
|
|
215
|
+
const deadline = Date.now() + WORKER_DRAIN_TIMEOUT_MS;
|
|
216
|
+
const tick = () => {
|
|
217
|
+
if (!chatHandler.busy || Date.now() >= deadline) teardown();
|
|
218
|
+
else setTimeout(tick, 100);
|
|
219
|
+
};
|
|
220
|
+
tick();
|
|
205
221
|
};
|
|
206
222
|
process.on("SIGTERM", shutdown);
|
|
207
223
|
process.on("SIGINT", shutdown);
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.61.0",
|
|
21
|
+
"commit": "54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
|
|
22
|
+
"shortCommit": "54faf1b",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.61.0",
|
|
25
|
+
"commitDate": "2026-07-05T20:10:34Z",
|
|
26
|
+
"buildDate": "2026-07-05T20:32:34.362Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/54faf1b4029f7a7a4251c5cc6776c789b3e1af5c",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.61.0"
|
|
33
33
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Filesystem side of the manager liveness record (`manager.json`), kept next to
|
|
2
|
+
* the control socket so a test's temp socket dir gets its own state file. The
|
|
3
|
+
* record is advisory/observability + the escalation target (pid) when a
|
|
4
|
+
* superseded manager won't release the socket; the control-socket `hello` answer
|
|
5
|
+
* is authoritative. Pure (de)serialization lives in commands/manager-core. */
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { type ManagerState, parseManagerState, serializeManagerState } from "../commands/manager-core";
|
|
9
|
+
|
|
10
|
+
/** `manager.json` alongside the given control socket. */
|
|
11
|
+
export function managerStatePathFor(sockPath: string): string {
|
|
12
|
+
return join(dirname(sockPath), "manager.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Atomically write the liveness record (temp + rename so readers never see a
|
|
16
|
+
* torn file). Best-effort: never throws — a failed write must not crash boot. */
|
|
17
|
+
export function writeManagerState(sockPath: string, state: ManagerState): void {
|
|
18
|
+
const p = managerStatePathFor(sockPath);
|
|
19
|
+
try {
|
|
20
|
+
// Restrictive modes: ~/.xcsh holds credentials (contexts/tokens), so the dir
|
|
21
|
+
// must be user-only (0700) if we're the one creating it, and the record
|
|
22
|
+
// user-only (0600) regardless of umask. rename() preserves the tmp's mode.
|
|
23
|
+
fs.mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
24
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
25
|
+
fs.writeFileSync(tmp, serializeManagerState(state), { mode: 0o600 });
|
|
26
|
+
fs.renameSync(tmp, p);
|
|
27
|
+
} catch {
|
|
28
|
+
/* advisory only — the socket is the source of truth */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read + validate the record, or null if absent/corrupt. */
|
|
33
|
+
export function readManagerState(sockPath: string): ManagerState | null {
|
|
34
|
+
try {
|
|
35
|
+
return parseManagerState(fs.readFileSync(managerStatePathFor(sockPath), "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remove the record on graceful shutdown. Best-effort. */
|
|
42
|
+
export function removeManagerState(sockPath: string): void {
|
|
43
|
+
try {
|
|
44
|
+
fs.rmSync(managerStatePathFor(sockPath), { force: true });
|
|
45
|
+
} catch {
|
|
46
|
+
/* best effort */
|
|
47
|
+
}
|
|
48
|
+
}
|