zcode-acp-server 0.30.0 → 0.31.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/dist/code-fingerprint.json +1 -1
- package/dist/config/options.d.ts +4 -0
- package/dist/config/options.d.ts.map +1 -1
- package/dist/config/options.js +17 -7
- package/dist/config/options.js.map +1 -1
- package/dist/config/user-config.d.ts +6 -0
- package/dist/config/user-config.d.ts.map +1 -1
- package/dist/config/user-config.js +10 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/handlers/dispatch.js +11 -5
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/io.d.ts +9 -0
- package/dist/handlers/io.d.ts.map +1 -1
- package/dist/handlers/io.js +21 -0
- package/dist/handlers/io.js.map +1 -1
- package/dist/remote/config.d.ts +3 -0
- package/dist/remote/config.d.ts.map +1 -1
- package/dist/remote/config.js +21 -1
- package/dist/remote/config.js.map +1 -1
- package/dist/remote/endpoint.d.ts.map +1 -1
- package/dist/remote/endpoint.js +0 -7
- package/dist/remote/endpoint.js.map +1 -1
- package/dist/remote/hub-server.d.ts +30 -2
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +186 -70
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/session-close-endpoint.d.ts +7 -0
- package/dist/remote/session-close-endpoint.d.ts.map +1 -1
- package/dist/remote/session-close-endpoint.js +37 -7
- package/dist/remote/session-close-endpoint.js.map +1 -1
- package/package.json +1 -1
|
@@ -83,14 +83,31 @@ export interface HubOptions {
|
|
|
83
83
|
* dist/cli.js — an interactive TUI in a visible terminal for
|
|
84
84
|
* session-create and session-resume ("tui"; resume carries the requested
|
|
85
85
|
* session in ZCODE_ACP_RESUME_SESSION), a detached headless serve bridge
|
|
86
|
-
* for background queries ("serve").
|
|
86
|
+
* for background queries ("serve"). A "tui" attempt returns null when the
|
|
87
|
+
* launch fails; the caller then walks down the terminal preference list
|
|
88
|
+
* (terminalLaunches) and only spawns "serve" once every entry failed.
|
|
87
89
|
*/
|
|
88
90
|
spawnServe?: (opts: {
|
|
89
91
|
cwd: string;
|
|
90
92
|
env: NodeJS.ProcessEnv;
|
|
91
93
|
/** "tui" = visible terminal (session-create/-resume); "serve" = detached headless. */
|
|
92
94
|
kind: "tui" | "serve";
|
|
93
|
-
|
|
95
|
+
/** The resolved launch for a "tui" attempt (one list entry). */
|
|
96
|
+
launch?: TerminalLaunch;
|
|
97
|
+
}) => ChildProcess | null | Promise<ChildProcess | null>;
|
|
98
|
+
/**
|
|
99
|
+
* Override the ordered terminal preference list the create/resume
|
|
100
|
+
* incubation walks (tests inject fixed entries). Default: resolved LIVE
|
|
101
|
+
* per incubation from remoteTerminalPrefs (config file > env), so editing
|
|
102
|
+
* the file takes effect without a hub restart.
|
|
103
|
+
*/
|
|
104
|
+
terminalLaunches?: TerminalLaunch[];
|
|
105
|
+
/**
|
|
106
|
+
* How long after startup the hub ignores newer-bridge stale votes (tests
|
|
107
|
+
* shrink to 0). Default STALE_VOTE_COOLDOWN_MS — the loop breaker so a
|
|
108
|
+
* same-age respawn can never be voted into a restart churn.
|
|
109
|
+
*/
|
|
110
|
+
staleVoteCooldownMs?: number;
|
|
94
111
|
}
|
|
95
112
|
export interface HubHandle {
|
|
96
113
|
port: number;
|
|
@@ -149,6 +166,17 @@ export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv, prefs?: Te
|
|
|
149
166
|
launch: TerminalLaunch;
|
|
150
167
|
warning?: string;
|
|
151
168
|
};
|
|
169
|
+
/**
|
|
170
|
+
* The ORDERED terminal preference list (ADR-0016 amendment): the hub walks
|
|
171
|
+
* it when a window fails — a launch that errors moves down the list at
|
|
172
|
+
* once, a window that never registers moves down at its registration
|
|
173
|
+
* timeout — and only the exhaustion of every entry falls back to headless.
|
|
174
|
+
* Resolution per entry mirrors resolveTerminalLaunch: the explicit command
|
|
175
|
+
* template (the universal escape hatch) replaces the whole list; otherwise
|
|
176
|
+
* a built-in launcher by normalized app name, else `open -a` passthrough.
|
|
177
|
+
* Disabled prefs resolve to [] (no visible-terminal attempts at all).
|
|
178
|
+
*/
|
|
179
|
+
export declare function resolveTerminalLaunches(prefs: TerminalPrefs): TerminalLaunch[];
|
|
152
180
|
/**
|
|
153
181
|
* The .command script body. The incubation env MUST be embedded as exports:
|
|
154
182
|
* the script runs in a fresh shell spawned by the terminal app, which
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAkC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAS9D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;IAC/B
|
|
1
|
+
{"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -124,6 +124,13 @@ function validSessions(raw) {
|
|
|
124
124
|
/** How long POST /api/instances waits for the spawned bridge to register. */
|
|
125
125
|
const SERVE_REGISTER_TIMEOUT_MS = 10_000;
|
|
126
126
|
const SERVE_REGISTER_POLL_MS = 300;
|
|
127
|
+
/**
|
|
128
|
+
* A freshly started hub ignores newer-bridge stale votes for this long —
|
|
129
|
+
* the loop breaker for a restart that re-spawned a hub of the same age
|
|
130
|
+
* (the vote would otherwise fire again immediately; at most one restart
|
|
131
|
+
* per cooldown window).
|
|
132
|
+
*/
|
|
133
|
+
const STALE_VOTE_COOLDOWN_MS = 60_000;
|
|
127
134
|
/** Register-origin parser: only "serve" is special; anything else is "editor". */
|
|
128
135
|
function parseOrigin(raw) {
|
|
129
136
|
return raw === "serve" ? "serve" : "editor";
|
|
@@ -183,17 +190,32 @@ const TERMINAL_APP_LAUNCHERS = {
|
|
|
183
190
|
* name passes through to `open -a` unchanged.
|
|
184
191
|
*/
|
|
185
192
|
export function resolveTerminalLaunch(env, prefs = remoteTerminalPrefs(env)) {
|
|
193
|
+
const launches = resolveTerminalLaunches(prefs);
|
|
194
|
+
return { launch: launches[0] ?? { kind: "openApp", app: "Terminal" } };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The ORDERED terminal preference list (ADR-0016 amendment): the hub walks
|
|
198
|
+
* it when a window fails — a launch that errors moves down the list at
|
|
199
|
+
* once, a window that never registers moves down at its registration
|
|
200
|
+
* timeout — and only the exhaustion of every entry falls back to headless.
|
|
201
|
+
* Resolution per entry mirrors resolveTerminalLaunch: the explicit command
|
|
202
|
+
* template (the universal escape hatch) replaces the whole list; otherwise
|
|
203
|
+
* a built-in launcher by normalized app name, else `open -a` passthrough.
|
|
204
|
+
* Disabled prefs resolve to [] (no visible-terminal attempts at all).
|
|
205
|
+
*/
|
|
206
|
+
export function resolveTerminalLaunches(prefs) {
|
|
207
|
+
if (prefs.enabled === false)
|
|
208
|
+
return [];
|
|
186
209
|
if (prefs.command)
|
|
187
|
-
return {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return { launch: { kind: "openApp", app: raw || "Terminal" } };
|
|
210
|
+
return [{ kind: "shell", command: prefs.command }];
|
|
211
|
+
const names = prefs.terminals?.length ? prefs.terminals : prefs.app ? [prefs.app] : ["Terminal"];
|
|
212
|
+
return names.map((raw) => {
|
|
213
|
+
const name = raw
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.replace(/\.app$/, "")
|
|
216
|
+
.replace(/\s+/g, "-");
|
|
217
|
+
return TERMINAL_APP_LAUNCHERS[name] ?? { kind: "openApp", app: raw };
|
|
218
|
+
});
|
|
197
219
|
}
|
|
198
220
|
/**
|
|
199
221
|
* The .command script body. The incubation env MUST be embedded as exports:
|
|
@@ -316,30 +338,22 @@ export function writeTuiScript(workspace, cliJs, env) {
|
|
|
316
338
|
/**
|
|
317
339
|
* Spawn session-create as a VISIBLE interactive TUI (ADR-0016): write a
|
|
318
340
|
* throwaway .command script (`cd <project> && exec node cli.js`) and hand it
|
|
319
|
-
* to
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* serve spawn
|
|
341
|
+
* to ONE resolved terminal launch — the user gets a real terminal window
|
|
342
|
+
* running the local CLI instead of an invisible daemon. The TUI's bridge
|
|
343
|
+
* child inherits the remote ENV, so the incubation registers exactly like a
|
|
344
|
+
* serve bridge; closing the window ends the bridge (its lifetime follows
|
|
345
|
+
* the terminal, the ADR-0001 anchor). Returns null when the launch fails —
|
|
346
|
+
* platform without GUI support or the open erroring (pre-1.3 Ghostty,
|
|
347
|
+
* denied Automation permission) — and the CALLER walks down the terminal
|
|
348
|
+
* preference list, falling back to the detached serve spawn only after the
|
|
349
|
+
* list is exhausted.
|
|
327
350
|
*/
|
|
328
351
|
async function spawnTerminalTui(opts) {
|
|
329
352
|
if (process.platform !== "darwin")
|
|
330
353
|
return null;
|
|
331
|
-
// Terminal prefs are read LIVE here (config file first, env fallback): the
|
|
332
|
-
// hub outlives the shells that configured it, and its birth env rotates
|
|
333
|
-
// between GUI editors and interactive shells — only the file is stable.
|
|
334
|
-
// Set remote.terminal.enabled=false in ~/.config/zcode-acp/config.json (or
|
|
335
|
-
// ZCODE_ACP_HUB_TERMINAL=0) to keep remote session-create headless.
|
|
336
|
-
if (!remoteTerminalPrefs(process.env).enabled)
|
|
337
|
-
return null;
|
|
338
354
|
const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
339
355
|
const script = writeTuiScript(opts.cwd, cliJs, opts.env);
|
|
340
|
-
const { launch
|
|
341
|
-
if (warning)
|
|
342
|
-
warn(`hub: ${warning}`);
|
|
356
|
+
const { launch } = opts;
|
|
343
357
|
let argv;
|
|
344
358
|
if (launch.kind === "shell") {
|
|
345
359
|
const rendered = launch.command.includes("{script}")
|
|
@@ -413,11 +427,16 @@ async function spawnTerminalTui(opts) {
|
|
|
413
427
|
* detached headless serve bridge otherwise (the ADR-0014 original — also the
|
|
414
428
|
* fallback whenever the terminal window can't be opened).
|
|
415
429
|
*/
|
|
430
|
+
/**
|
|
431
|
+
* One spawn attempt. "tui" = one resolved terminal launch (returns null when
|
|
432
|
+
* the launch fails — the caller walks the preference list); "serve" = the
|
|
433
|
+
* detached headless bridge (the documented no-GUI fallback, ADR-0014).
|
|
434
|
+
*/
|
|
416
435
|
async function defaultSpawnServe(opts) {
|
|
417
436
|
if (opts.kind === "tui") {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
437
|
+
if (!opts.launch)
|
|
438
|
+
return null;
|
|
439
|
+
return spawnTerminalTui(opts);
|
|
421
440
|
}
|
|
422
441
|
// dist/remote/hub-server.js → dist/cli.js (one level up).
|
|
423
442
|
const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
@@ -675,7 +694,7 @@ async function diskCodeIsNewer(paths, startedAt, runningFingerprint) {
|
|
|
675
694
|
* EADDRINUSE when another hub already owns the port).
|
|
676
695
|
*/
|
|
677
696
|
export function startHub(options) {
|
|
678
|
-
const { port, host, token, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, tuiRegisterTimeoutMs = TUI_REGISTER_TIMEOUT_MS, onIdleExit, onRestart, codePaths = defaultCodePaths(), projectsDbPath, stayAliveCheck = () => remoteEnabledLive(), spawnServe = defaultSpawnServe, } = options;
|
|
697
|
+
const { port, host, token, heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, idleExitMs = IDLE_EXIT_MS, pingIntervalMs = PING_INTERVAL_MS, tuiRegisterTimeoutMs = TUI_REGISTER_TIMEOUT_MS, onIdleExit, onRestart, codePaths = defaultCodePaths(), projectsDbPath, stayAliveCheck = () => remoteEnabledLive(), spawnServe = defaultSpawnServe, terminalLaunches, staleVoteCooldownMs = STALE_VOTE_COOLDOWN_MS, } = options;
|
|
679
698
|
/** Frozen at hub start — the anchor the /api/upgrade signals compare to. */
|
|
680
699
|
const startedAt = Date.now();
|
|
681
700
|
/**
|
|
@@ -790,26 +809,84 @@ export function startHub(options) {
|
|
|
790
809
|
// (terminals otherwise name the tab after the process, "node").
|
|
791
810
|
env.ZCODE_ACP_TAB_TITLE = tabTitle ?? path.basename(workspacePath);
|
|
792
811
|
}
|
|
812
|
+
// Terminal preference list (ADR-0016 amendment): visible-terminal kinds
|
|
813
|
+
// walk it — a failed launch moves down at once, a window that never
|
|
814
|
+
// registers moves down at its timeout — and only the exhausted list
|
|
815
|
+
// falls back to the detached headless spawn. "serve" never pops a window.
|
|
816
|
+
const launches = kind === "serve"
|
|
817
|
+
? []
|
|
818
|
+
: (terminalLaunches ?? resolveTerminalLaunches(remoteTerminalPrefs(process.env)));
|
|
819
|
+
let li = 0;
|
|
820
|
+
/**
|
|
821
|
+
* Try launches[li..] until one OPENS; advance li past every failure and
|
|
822
|
+
* leave it parked on the first opened attempt's successor. null = the
|
|
823
|
+
* remaining list failed/exhausted (caller falls back to headless).
|
|
824
|
+
*/
|
|
825
|
+
const openNextTerminal = async () => {
|
|
826
|
+
while (li < launches.length) {
|
|
827
|
+
const attempt = launches[li];
|
|
828
|
+
li++;
|
|
829
|
+
try {
|
|
830
|
+
const opened = await spawnServe({
|
|
831
|
+
cwd: workspacePath,
|
|
832
|
+
kind: "tui",
|
|
833
|
+
env,
|
|
834
|
+
launch: attempt,
|
|
835
|
+
});
|
|
836
|
+
if (opened) {
|
|
837
|
+
opened.once("error", (e) => {
|
|
838
|
+
spawnError = e;
|
|
839
|
+
});
|
|
840
|
+
spawnError = null;
|
|
841
|
+
log(`hub: spawned terminal TUI via ${attempt.kind === "shell"
|
|
842
|
+
? "command template"
|
|
843
|
+
: attempt.kind === "ghosttyScript"
|
|
844
|
+
? `Ghostty (${attempt.app})`
|
|
845
|
+
: attempt.kind === "warpUri"
|
|
846
|
+
? `Warp (${attempt.app})`
|
|
847
|
+
: `open -a ${attempt.app}`} for ${workspacePath} (pid ${opened.pid})`);
|
|
848
|
+
return opened;
|
|
849
|
+
}
|
|
850
|
+
warn(`hub: terminal launch ${li}/${launches.length} failed to open — trying the next preference`);
|
|
851
|
+
}
|
|
852
|
+
catch (e) {
|
|
853
|
+
warn(`hub: terminal launch threw: ${e instanceof Error ? e.message : String(e)}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
};
|
|
793
858
|
try {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
859
|
+
const opened = await openNextTerminal();
|
|
860
|
+
if (opened) {
|
|
861
|
+
child = opened;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
// Resume shares session-create's visible-terminal surface (and its
|
|
865
|
+
// detached-serve fallback for headless machines); only a background
|
|
866
|
+
// listing spawns the headless form — it must never pop a window.
|
|
867
|
+
const spawned = await spawnServe({
|
|
868
|
+
cwd: workspacePath,
|
|
869
|
+
kind: "serve",
|
|
870
|
+
env,
|
|
871
|
+
});
|
|
872
|
+
if (!spawned)
|
|
873
|
+
throw new Error("headless spawn returned null");
|
|
874
|
+
child = spawned;
|
|
875
|
+
child.once("error", (e) => {
|
|
876
|
+
spawnError = e;
|
|
877
|
+
});
|
|
878
|
+
}
|
|
805
879
|
}
|
|
806
880
|
catch (e) {
|
|
807
881
|
warn(`hub: serve spawn failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
808
882
|
throw new Error("serve bridge spawn failed");
|
|
809
883
|
}
|
|
810
|
-
log(`hub:
|
|
884
|
+
log(`hub: incubating ${kind === "resume" ? "resume TUI" : kind === "tui" ? "terminal TUI" : "serve bridge"} for ${workspacePath} (pid ${child.pid})`);
|
|
811
885
|
const budget = kind === "serve" ? SERVE_REGISTER_TIMEOUT_MS : tuiRegisterTimeoutMs;
|
|
812
|
-
|
|
886
|
+
let deadline = Date.now() + budget;
|
|
887
|
+
// One headless rescue per incubation — a second timeout means the rescue
|
|
888
|
+
// bridge also failed to register, and retrying again just burns time.
|
|
889
|
+
let rescued = false;
|
|
813
890
|
// A visible-terminal incubation (create OR resume) runs NEXT TO the serve
|
|
814
891
|
// bridge the listing already incubated, and several incubations can race
|
|
815
892
|
// for one workspace: the poll matches only a registration that is neither
|
|
@@ -851,6 +928,46 @@ export function startHub(options) {
|
|
|
851
928
|
return { id: fallback.id, reused: true };
|
|
852
929
|
}
|
|
853
930
|
}
|
|
931
|
+
// Next terminal preference: the window opened but never registered —
|
|
932
|
+
// on a locked/display-asleep screen Ghostty's AppleScript tab opens
|
|
933
|
+
// fine but its surface init fails (error.OutOfMemory, ghostty
|
|
934
|
+
// #10712), leaving the tab dead on its error page (the fake child
|
|
935
|
+
// never exits, so the fast-fail above can't fire). Walk down the
|
|
936
|
+
// user's list with a fresh budget before giving up on windows.
|
|
937
|
+
if (kind !== "serve" && li < launches.length) {
|
|
938
|
+
warn(`hub: terminal TUI for ${workspacePath} never registered — trying the next terminal preference`);
|
|
939
|
+
const next = await openNextTerminal();
|
|
940
|
+
if (next) {
|
|
941
|
+
deadline = Date.now() + budget;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// Headless rescue (once, after the terminal list is exhausted):
|
|
946
|
+
// spawn the SAME incubation headlessly — the session-bind env rides
|
|
947
|
+
// along (create's pre-generated id, resume's target) and the nonce is
|
|
948
|
+
// unchanged, so this very loop matches the rescue bridge's
|
|
949
|
+
// registration and answers with a working session either way.
|
|
950
|
+
if (kind !== "serve" && !rescued) {
|
|
951
|
+
rescued = true;
|
|
952
|
+
warn(`hub: ${kind === "resume" ? "resume TUI" : "terminal TUI"} for ${workspacePath} ` +
|
|
953
|
+
`never registered — rescuing the request with a headless serve bridge`);
|
|
954
|
+
try {
|
|
955
|
+
const rescuedChild = await spawnServe({ cwd: workspacePath, kind: "serve", env });
|
|
956
|
+
if (!rescuedChild)
|
|
957
|
+
throw new Error("rescue spawn returned null");
|
|
958
|
+
child = rescuedChild;
|
|
959
|
+
child.once("error", (e) => {
|
|
960
|
+
spawnError = e;
|
|
961
|
+
});
|
|
962
|
+
spawnError = null;
|
|
963
|
+
}
|
|
964
|
+
catch (e) {
|
|
965
|
+
warn(`hub: headless rescue spawn failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
966
|
+
throw new Error("serve bridge did not register in time");
|
|
967
|
+
}
|
|
968
|
+
deadline = Date.now() + budget;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
854
971
|
warn(`hub: serve bridge for ${workspacePath} never registered`);
|
|
855
972
|
throw new Error("serve bridge did not register in time");
|
|
856
973
|
}
|
|
@@ -1490,34 +1607,33 @@ export function startHub(options) {
|
|
|
1490
1607
|
instances.delete(id);
|
|
1491
1608
|
idleSince = null; // re-arm the idle clock on membership change
|
|
1492
1609
|
}
|
|
1493
|
-
// Self-upgrade: a bridge
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
compareVersions(body.version, AGENT_INFO.version) > 0;
|
|
1514
|
-
const stale = url.pathname === "/api/register" && (fingerprintStale || versionStale);
|
|
1610
|
+
// Self-upgrade: a bridge with a strictly NEWER VERSION just registered,
|
|
1611
|
+
// so this process is stale. Reply first (the bridge re-spawns the hub
|
|
1612
|
+
// from its own dist when it sees `restarting`), then exit.
|
|
1613
|
+
//
|
|
1614
|
+
// Content fingerprints are deliberately NOT a vote signal: a hash has
|
|
1615
|
+
// no ordering (a lexically-higher fingerprint can be OLDER code), and
|
|
1616
|
+
// the hub's respawn source is not guaranteed to be the voting bridge's
|
|
1617
|
+
// dist — differing fingerprints could only ever produce a
|
|
1618
|
+
// non-converging restart loop (observed live: a coexisting dist voted
|
|
1619
|
+
// every respawned hub stale, ~5s churn, all instances wiped).
|
|
1620
|
+
// Fingerprint comparison belongs to /api/upgrade ONLY, where the
|
|
1621
|
+
// comparison target is the DISK a respawn would load — self-negating
|
|
1622
|
+
// by construction.
|
|
1623
|
+
//
|
|
1624
|
+
// Cooldown: a fresh hub ignores stale votes for the first minute. If
|
|
1625
|
+
// the respawn race ever produces another same-age hub, this breaks the
|
|
1626
|
+
// loop — at most one restart per STALE_VOTE_COOLDOWN_MS.
|
|
1627
|
+
const newerVersion = typeof body.version === "string" && compareVersions(body.version, AGENT_INFO.version) > 0;
|
|
1628
|
+
const cooledDown = Date.now() - startedAt > staleVoteCooldownMs;
|
|
1629
|
+
const stale = url.pathname === "/api/register" && newerVersion && cooledDown;
|
|
1515
1630
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1516
1631
|
res.end(JSON.stringify(stale ? { ok: true, restarting: true } : { ok: true }));
|
|
1517
1632
|
if (stale) {
|
|
1518
|
-
restartSoon(
|
|
1519
|
-
|
|
1520
|
-
|
|
1633
|
+
restartSoon(`hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`);
|
|
1634
|
+
}
|
|
1635
|
+
else if (url.pathname === "/api/register" && newerVersion && !cooledDown) {
|
|
1636
|
+
log("hub: newer-bridge vote ignored (restart cooldown after a recent restart)");
|
|
1521
1637
|
}
|
|
1522
1638
|
return;
|
|
1523
1639
|
}
|