@f5-sales-demo/xcsh 19.58.2 → 19.60.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 +7 -2
- package/src/commands/manager-core.ts +64 -1
- package/src/commands/manager.ts +80 -2
- package/src/commands/native-host.ts +110 -5
- package/src/commands/worker.ts +26 -14
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +7 -9
- package/src/services/manager-state.ts +48 -0
- package/src/services/xcsh-env.ts +25 -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.60.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.60.0",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.60.0",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.60.0",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.60.0",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.60.0",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.60.0",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.60.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
|
@@ -56,10 +56,15 @@ export function nativeHostLaunchCommand(
|
|
|
56
56
|
execPath: string = process.execPath,
|
|
57
57
|
resolveXcshBin: () => string | null = defaultResolveXcshBin,
|
|
58
58
|
): string[] {
|
|
59
|
+
// Prefer a VERSION-STABLE launcher on PATH (e.g. the Homebrew symlink
|
|
60
|
+
// /opt/homebrew/bin/xcsh) for BOTH compiled and bun installs. This keeps the
|
|
61
|
+
// native-messaging wrapper working across `brew upgrade`s instead of pinning
|
|
62
|
+
// the install-time versioned path (…/Cellar/xcsh/<v>/bin/xcsh), which froze
|
|
63
|
+
// the extension on an old version until install-host was re-run (#1874).
|
|
64
|
+
const xcsh = resolveXcshBin();
|
|
65
|
+
if (xcsh) return [xcsh];
|
|
59
66
|
const base = path.basename(execPath).toLowerCase();
|
|
60
67
|
if (base.startsWith("bun")) {
|
|
61
|
-
const xcsh = resolveXcshBin();
|
|
62
|
-
if (xcsh) return [xcsh];
|
|
63
68
|
const script = argv[1];
|
|
64
69
|
if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
|
|
65
70
|
return [execPath, path.resolve(script)];
|
|
@@ -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). */
|
|
@@ -130,6 +134,17 @@ export default class Manager extends Command {
|
|
|
130
134
|
const reg: Registry = new Map();
|
|
131
135
|
const range = portCandidates();
|
|
132
136
|
|
|
137
|
+
// The version this manager advertises (hello ack + manager.json). Overridable
|
|
138
|
+
// via env ONLY so the supersede integration tests can stand up an "old" manager
|
|
139
|
+
// (production always reports the real binary VERSION).
|
|
140
|
+
const selfVersion = process.env.XCSH_MANAGER_VERSION || VERSION;
|
|
141
|
+
|
|
142
|
+
// Lifecycle gate (#1874): once we begin graceful shutdown we stop accepting
|
|
143
|
+
// provisions (the host retries the successor manager) and never double-run
|
|
144
|
+
// the teardown.
|
|
145
|
+
let accepting = true;
|
|
146
|
+
let shuttingDown = false;
|
|
147
|
+
|
|
133
148
|
const poolTarget = Math.max(0, Math.trunc(Number(process.env.XCSH_WORKER_POOL_SIZE ?? "2")) || 0);
|
|
134
149
|
interface SpareRec {
|
|
135
150
|
proc: Bun.Subprocess;
|
|
@@ -215,6 +230,38 @@ export default class Manager extends Command {
|
|
|
215
230
|
reg.delete(sessionId);
|
|
216
231
|
};
|
|
217
232
|
|
|
233
|
+
const killPid = (pid: number): void => {
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid); // SIGTERM — spares exit at once; bound workers self-drain (worker.ts)
|
|
236
|
+
} catch {
|
|
237
|
+
/* already gone */
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** Graceful teardown (#1874): stop accepting, terminate the (session-less)
|
|
242
|
+
* spare pool at once, SIGTERM bound workers so they drain their in-flight turn,
|
|
243
|
+
* drop the socket + liveness record, and exit. Idempotent. Bounded by each
|
|
244
|
+
* worker's own drain ceiling — the manager frees the socket immediately so a
|
|
245
|
+
* successor can bind without waiting on drains. */
|
|
246
|
+
const gracefulShutdown = (reason: string): void => {
|
|
247
|
+
if (shuttingDown) return;
|
|
248
|
+
shuttingDown = true;
|
|
249
|
+
accepting = false;
|
|
250
|
+
console.error(
|
|
251
|
+
`[xcsh manager] graceful shutdown (${reason}); reaping ${pool.length} spare(s) + ${reg.size} worker(s)`,
|
|
252
|
+
);
|
|
253
|
+
for (const s of pool.splice(0)) killPid(s.pid);
|
|
254
|
+
for (const w of reg.values()) killPid(w.pid);
|
|
255
|
+
reg.clear();
|
|
256
|
+
removeManagerState(sockPath);
|
|
257
|
+
try {
|
|
258
|
+
fs.rmSync(sockPath, { force: true });
|
|
259
|
+
} catch {
|
|
260
|
+
/* best effort — the OS drops the bound socket on exit anyway */
|
|
261
|
+
}
|
|
262
|
+
process.exit(0);
|
|
263
|
+
};
|
|
264
|
+
|
|
218
265
|
const spawnWorker = (msg: { sessionId: string; tenant: string }): void => {
|
|
219
266
|
// Registry-dedupe (pickPort) over only the ports free at the OS level.
|
|
220
267
|
const port = pickPort(reg, range.filter(isPortFree));
|
|
@@ -257,7 +304,7 @@ export default class Manager extends Command {
|
|
|
257
304
|
console.error(`[xcsh manager] provisioned ${msg.sessionId} (${msg.tenant}) → pid ${proc.pid} on port ${port}`);
|
|
258
305
|
};
|
|
259
306
|
|
|
260
|
-
const handleFrame = (line: string): void => {
|
|
307
|
+
const handleFrame = (line: string, socket: { write(data: string): void }): void => {
|
|
261
308
|
const trimmed = line.trim();
|
|
262
309
|
if (!trimmed) return;
|
|
263
310
|
let raw: unknown;
|
|
@@ -269,6 +316,15 @@ export default class Manager extends Command {
|
|
|
269
316
|
const msg = parseControlMsg(raw);
|
|
270
317
|
if (!msg) return; // fail closed on unknown/invalid frames
|
|
271
318
|
if (msg.type === "provision") {
|
|
319
|
+
// Draining: refuse new work so the host retries the successor manager.
|
|
320
|
+
if (!accepting) {
|
|
321
|
+
try {
|
|
322
|
+
socket.write(`${JSON.stringify({ type: "draining" })}\n`);
|
|
323
|
+
} catch {
|
|
324
|
+
/* client hung up */
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
272
328
|
if (needsProvision(reg, msg.sessionId)) {
|
|
273
329
|
if (!adoptSpare(msg)) spawnWorker(msg); // adopt a warm spare, else cold-spawn (fallback)
|
|
274
330
|
}
|
|
@@ -276,6 +332,19 @@ export default class Manager extends Command {
|
|
|
276
332
|
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
277
333
|
} else if (msg.type === "release") {
|
|
278
334
|
reap(msg.sessionId);
|
|
335
|
+
} else if (msg.type === "shutdown") {
|
|
336
|
+
// A newer native-host (supersede) or the updater asks us to step down.
|
|
337
|
+
gracefulShutdown(msg.reason);
|
|
338
|
+
} else if (msg.type === "hello") {
|
|
339
|
+
// Identity handshake (#1874): a newer native-host reads our version to
|
|
340
|
+
// decide whether to supersede us. Answer over the same connection.
|
|
341
|
+
try {
|
|
342
|
+
socket.write(
|
|
343
|
+
`${JSON.stringify({ type: "manager_hello_ack", version: selfVersion, pid: process.pid })}\n`,
|
|
344
|
+
);
|
|
345
|
+
} catch {
|
|
346
|
+
/* client hung up mid-handshake — ignore */
|
|
347
|
+
}
|
|
279
348
|
}
|
|
280
349
|
// "status" is validated but currently a no-op sink.
|
|
281
350
|
};
|
|
@@ -292,7 +361,7 @@ export default class Manager extends Command {
|
|
|
292
361
|
const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
|
|
293
362
|
const parts = combined.split("\n");
|
|
294
363
|
buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
|
|
295
|
-
for (const line of parts) handleFrame(line);
|
|
364
|
+
for (const line of parts) handleFrame(line, socket as { write(data: string): void });
|
|
296
365
|
},
|
|
297
366
|
close(socket: object): void {
|
|
298
367
|
buffers.delete(socket);
|
|
@@ -331,6 +400,15 @@ export default class Manager extends Command {
|
|
|
331
400
|
}
|
|
332
401
|
console.error(`[xcsh manager] control socket listening at ${sockPath}`);
|
|
333
402
|
|
|
403
|
+
// Publish our liveness record (#1874) so a newer native-host can see which
|
|
404
|
+
// version owns the socket (and, if it must supersede us, find our pid).
|
|
405
|
+
writeManagerState(sockPath, { pid: process.pid, version: selfVersion, socket: sockPath, startedAt: Date.now() });
|
|
406
|
+
|
|
407
|
+
// Clean-signal handling: an operator SIGTERM (or upgrade/recycle) tears us
|
|
408
|
+
// down gracefully instead of orphaning workers + a stale socket/state file.
|
|
409
|
+
process.on("SIGTERM", () => gracefulShutdown("manual"));
|
|
410
|
+
process.on("SIGINT", () => gracefulShutdown("manual"));
|
|
411
|
+
|
|
334
412
|
// Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
|
|
335
413
|
if (poolTarget > 0) maintainPool();
|
|
336
414
|
|
|
@@ -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
|
@@ -23,7 +23,7 @@ import { initializeWithSettings } from "../discovery";
|
|
|
23
23
|
import { createAgentSession } from "../sdk";
|
|
24
24
|
import { activateTenantContext } from "../services/session-context-binding";
|
|
25
25
|
import { ContextService } from "../services/xcsh-context";
|
|
26
|
-
import {
|
|
26
|
+
import { deriveTenantEnv } from "../services/xcsh-env";
|
|
27
27
|
|
|
28
28
|
/** Mutable worker identity. Seeded from env at spawn (backward compat); replaced by a
|
|
29
29
|
* late IPC bind on a pre-warmed spare. `null` fields fall back to env, then the "spare"
|
|
@@ -64,19 +64,18 @@ export function sessionInfoForWorker(): {
|
|
|
64
64
|
/* ContextService not initialized — fall through to the tenant key; contextBound stays false. */
|
|
65
65
|
}
|
|
66
66
|
apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const [tenant, env] = raw.split("|");
|
|
75
|
-
return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound, sessionId };
|
|
76
|
-
}
|
|
77
|
-
return { tenant: null, env: null, apiUrl: null, contextBound, sessionId };
|
|
67
|
+
// Prefer the apiUrl-derived key (active context wins), but fall back to the
|
|
68
|
+
// tenant this worker was assigned (IPC bind or spawn env) so an apiUrl whose
|
|
69
|
+
// host we can't parse never blanks a KNOWN tenant — which would make the
|
|
70
|
+
// extension drop the bridge and show "No xcsh running for this tenant" (#1872).
|
|
71
|
+
const tenantKey = boundIdentity?.tenantKey ?? process.env.XCSH_SESSION_TENANT ?? null;
|
|
72
|
+
const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
|
|
73
|
+
return { tenant, env, apiUrl, contextBound, sessionId };
|
|
78
74
|
}
|
|
79
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
|
+
|
|
80
79
|
/** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
|
|
81
80
|
* With scoped tools the ONLY way to create a resource is the form-driven workflow
|
|
82
81
|
* runner, which is exactly what the human watching the browser wants. */
|
|
@@ -201,11 +200,24 @@ export default class Worker extends Command {
|
|
|
201
200
|
logger.endTiming();
|
|
202
201
|
|
|
203
202
|
let shuttingDown = false;
|
|
203
|
+
const teardown = () => {
|
|
204
|
+
chatHandler.dispose();
|
|
205
|
+
void bridge.close().finally(() => process.exit(0));
|
|
206
|
+
};
|
|
204
207
|
const shutdown = () => {
|
|
205
208
|
if (shuttingDown) return;
|
|
206
209
|
shuttingDown = true;
|
|
207
|
-
|
|
208
|
-
|
|
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();
|
|
209
221
|
};
|
|
210
222
|
process.on("SIGTERM", shutdown);
|
|
211
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.60.0",
|
|
21
|
+
"commit": "37266bce39f1c2714a947ebfcbd9dbae46b312a0",
|
|
22
|
+
"shortCommit": "37266bc",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.60.0",
|
|
25
|
+
"commitDate": "2026-07-05T19:32:50Z",
|
|
26
|
+
"buildDate": "2026-07-05T20:00:48.862Z",
|
|
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/37266bce39f1c2714a947ebfcbd9dbae46b312a0",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.60.0"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -815,7 +815,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
815
815
|
// tenant), so the panel can lock its session and route per tenant.
|
|
816
816
|
{
|
|
817
817
|
const { ContextService } = await import("./services/xcsh-context");
|
|
818
|
-
const {
|
|
818
|
+
const { deriveTenantEnv } = await import("./services/xcsh-env");
|
|
819
819
|
const readInfo = () => {
|
|
820
820
|
let apiUrl: string | null = null;
|
|
821
821
|
let contextBound = false;
|
|
@@ -828,15 +828,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
828
828
|
}
|
|
829
829
|
// Fall back to the env override when no context is active (env-only mode).
|
|
830
830
|
apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
|
|
831
|
-
|
|
831
|
+
// Prefer the apiUrl-derived key; fall back to the assigned tenant key so an
|
|
832
|
+
// unparseable apiUrl never blanks a known tenant (#1872). Same contract as
|
|
833
|
+
// the worker path (sessionInfoForWorker).
|
|
834
|
+
const tenantKey = process.env.XCSH_SESSION_TENANT ?? null;
|
|
835
|
+
const { tenant, env } = deriveTenantEnv(apiUrl, tenantKey);
|
|
832
836
|
// Interactive path has no per-tab XCSH_SESSION_ID; workers echo one, this doesn't.
|
|
833
|
-
return {
|
|
834
|
-
tenant: key?.tenant ?? null,
|
|
835
|
-
env: key?.env ?? null,
|
|
836
|
-
apiUrl,
|
|
837
|
-
contextBound,
|
|
838
|
-
sessionId: null,
|
|
839
|
-
};
|
|
837
|
+
return { tenant, env, apiUrl, contextBound, sessionId: null };
|
|
840
838
|
};
|
|
841
839
|
bridgeServer.setSessionInfo(readInfo);
|
|
842
840
|
ContextService.onContextChange(() => bridgeServer?.broadcastTenantChanged());
|
|
@@ -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
|
+
}
|
package/src/services/xcsh-env.ts
CHANGED
|
@@ -128,6 +128,31 @@ export function sessionKeyFromUrl(url: string | undefined): SessionKey | null {
|
|
|
128
128
|
return null;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the `{tenant, env}` a worker advertises to the extension's `hello`
|
|
133
|
+
* handshake. Prefers the session key parsed from `apiUrl` (the live/active
|
|
134
|
+
* context is authoritative) but falls back to the worker's assigned tenant key
|
|
135
|
+
* (`"tenant|env"`, from `boundIdentity.tenantKey` / `XCSH_SESSION_TENANT`) when
|
|
136
|
+
* `apiUrl` is absent OR its host doesn't parse to a key.
|
|
137
|
+
*
|
|
138
|
+
* The fallback is load-bearing (#1872): the extension's `liveTenants` filter
|
|
139
|
+
* keeps a bridge only when BOTH `tenant` and `env` are set, so returning
|
|
140
|
+
* `{tenant:null, env:null}` for a worker that KNOWS its tenant (e.g. an
|
|
141
|
+
* activated context whose stored `apiUrl` isn't `<tenant>.console.ves.volterra.io`)
|
|
142
|
+
* silently drops the bridge and the panel shows "No xcsh running for this tenant".
|
|
143
|
+
*/
|
|
144
|
+
export function deriveTenantEnv(
|
|
145
|
+
apiUrl: string | null,
|
|
146
|
+
tenantKey: string | null | undefined,
|
|
147
|
+
): { tenant: string | null; env: string | null } {
|
|
148
|
+
const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
|
|
149
|
+
const [assignedTenant, assignedEnv] = (tenantKey ?? "").split("|");
|
|
150
|
+
return {
|
|
151
|
+
tenant: key?.tenant ?? (assignedTenant || null),
|
|
152
|
+
env: key?.env ?? (assignedEnv || null),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
131
156
|
/**
|
|
132
157
|
* Reduce an API URL to its origin (`https://host[:port]`) — the canonical stored
|
|
133
158
|
* form for a context endpoint.
|