taskchef 7.22.3 → 7.22.5
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/.codex-plugin/plugin.json +1 -1
- package/README.md +40 -28
- package/docs/dashboard-lifecycle.md +168 -0
- package/docs/spec.md +117 -61
- package/docs/workflows.md +30 -18
- package/mcp/dashboard-session.js +51 -0
- package/package.json +2 -1
- package/src/dashboard-manager.js +633 -157
- package/src/dashboard-ownership.js +124 -13
- package/src/dashboard-session-process.js +179 -0
- package/src/dashboard-session.js +91 -0
- package/src/dashboard.js +288 -8
- package/src/mcp.js +2 -2
- package/src/usage.js +31 -3
- package/src/version.js +1 -1
package/src/dashboard-manager.js
CHANGED
|
@@ -1,30 +1,120 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { realpath } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
5
7
|
import {
|
|
6
8
|
DASHBOARD_HEALTH_MAX_BYTES,
|
|
7
9
|
DASHBOARD_HEALTH_PATH,
|
|
8
|
-
createDashboardServer,
|
|
9
10
|
dashboardAuthority,
|
|
10
11
|
} from "./dashboard.js";
|
|
11
12
|
import {
|
|
12
13
|
DASHBOARD_CONTROL_CHALLENGE_PATH,
|
|
14
|
+
DASHBOARD_CONTROL_HANDOFF_COMMIT_PATH,
|
|
15
|
+
DASHBOARD_CONTROL_HANDOFF_PATH,
|
|
16
|
+
DASHBOARD_CONTROL_SESSION_PATH,
|
|
13
17
|
DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
14
18
|
createDashboardControlNonce,
|
|
15
19
|
createDashboardControlSecret,
|
|
16
20
|
dashboardControlProof,
|
|
17
|
-
|
|
21
|
+
readDashboardHandoff,
|
|
18
22
|
readDashboardOwner,
|
|
23
|
+
validDashboardControlNonce,
|
|
19
24
|
verifyDashboardControlProof,
|
|
20
|
-
writeDashboardOwner,
|
|
21
25
|
} from "./dashboard-ownership.js";
|
|
22
26
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
27
|
+
import {
|
|
28
|
+
MAX_DASHBOARD_SESSION_PIDS,
|
|
29
|
+
MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
|
|
30
|
+
validSessionPid,
|
|
31
|
+
} from "./dashboard-session.js";
|
|
23
32
|
|
|
24
33
|
const DEFAULT_HOST = "127.0.0.1";
|
|
25
34
|
const DEFAULT_PORT = 3210;
|
|
26
35
|
const HEALTH_TIMEOUT_MS = 750;
|
|
27
|
-
const
|
|
36
|
+
const SESSION_START_ATTEMPTS = 80;
|
|
37
|
+
const SESSION_START_INTERVAL_MS = 25;
|
|
38
|
+
const HANDOFF_CONVERGENCE_ATTEMPTS = SESSION_START_ATTEMPTS * 3;
|
|
39
|
+
const SESSION_READY_TIMEOUT_MS = 15_000;
|
|
40
|
+
const OWNER_AUTH_DEFAULT_TIMEOUT_MS = 1_000;
|
|
41
|
+
const MAX_VERSION_REPLACEMENT_ATTEMPTS = 8;
|
|
42
|
+
const SESSION_PROCESS_PATH = fileURLToPath(new URL("../mcp/dashboard-session.js", import.meta.url));
|
|
43
|
+
|
|
44
|
+
export function launchDashboardSession({
|
|
45
|
+
workspace,
|
|
46
|
+
host,
|
|
47
|
+
port,
|
|
48
|
+
secret,
|
|
49
|
+
sessionPid,
|
|
50
|
+
sessionPids = [],
|
|
51
|
+
processPath = process.execPath,
|
|
52
|
+
spawnProcess = spawn,
|
|
53
|
+
readyTimeoutMs = SESSION_READY_TIMEOUT_MS,
|
|
54
|
+
} = {}) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const child = spawnProcess(processPath, [SESSION_PROCESS_PATH], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
59
|
+
env: {
|
|
60
|
+
...process.env,
|
|
61
|
+
TASKCHEF_DASHBOARD_WORKSPACE: workspace,
|
|
62
|
+
TASKCHEF_DASHBOARD_HOST: host,
|
|
63
|
+
TASKCHEF_DASHBOARD_PORT: String(port),
|
|
64
|
+
TASKCHEF_DASHBOARD_SECRET: secret,
|
|
65
|
+
TASKCHEF_DASHBOARD_SESSION_PID: String(sessionPid),
|
|
66
|
+
TASKCHEF_DASHBOARD_SESSION_PIDS: JSON.stringify(sessionPids),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = (callback, value, { disconnect = true } = {}) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
child.off("error", onError);
|
|
75
|
+
child.off("exit", onExit);
|
|
76
|
+
child.off("message", onMessage);
|
|
77
|
+
child.on("error", () => {});
|
|
78
|
+
if (disconnect && child.connected) child.disconnect();
|
|
79
|
+
child.unref();
|
|
80
|
+
callback(value);
|
|
81
|
+
};
|
|
82
|
+
const onError = (error) => finish(reject, error);
|
|
83
|
+
const onExit = (code) => {
|
|
84
|
+
const error = new Error("TaskChef dashboard session exited before becoming ready");
|
|
85
|
+
error.code = code === 0 ? "TASKCHEF_DASHBOARD_START_EXIT" : "TASKCHEF_DASHBOARD_START_FAILED";
|
|
86
|
+
finish(reject, error);
|
|
87
|
+
};
|
|
88
|
+
const onMessage = (message) => {
|
|
89
|
+
if (message?.type === "ready" && message.port === port) {
|
|
90
|
+
finish(resolve, { pid: child.pid, port: message.port });
|
|
91
|
+
} else if (message?.type === "error") {
|
|
92
|
+
const error = new Error("TaskChef dashboard session failed before becoming ready");
|
|
93
|
+
error.code = typeof message.code === "string"
|
|
94
|
+
? message.code
|
|
95
|
+
: "TASKCHEF_DASHBOARD_START_FAILED";
|
|
96
|
+
finish(reject, error);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
const error = new Error("TaskChef dashboard session readiness timed out");
|
|
101
|
+
error.code = "TASKCHEF_DASHBOARD_START_TIMEOUT";
|
|
102
|
+
finish(reject, error, { disconnect: false });
|
|
103
|
+
if (child.connected) {
|
|
104
|
+
try {
|
|
105
|
+
child.send({ type: "cancel" }, () => {
|
|
106
|
+
if (child.connected) child.disconnect();
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
if (child.connected) child.disconnect();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, readyTimeoutMs);
|
|
113
|
+
child.once("error", onError);
|
|
114
|
+
child.once("exit", onExit);
|
|
115
|
+
child.on("message", onMessage);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
28
118
|
|
|
29
119
|
function expectedIdentity(workspace, taskchefVersion, serverVersion, launcher) {
|
|
30
120
|
return {
|
|
@@ -106,7 +196,7 @@ function publicOwnerIdentity(owner) {
|
|
|
106
196
|
};
|
|
107
197
|
}
|
|
108
198
|
|
|
109
|
-
function requestDashboardJson({ host, port, path: requestPath, method = "GET", body, timeoutMs }) {
|
|
199
|
+
export function requestDashboardJson({ host, port, path: requestPath, method = "GET", body, timeoutMs }) {
|
|
110
200
|
return new Promise((resolve, reject) => {
|
|
111
201
|
const encoded = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
112
202
|
const request = http.request({
|
|
@@ -218,85 +308,214 @@ export function createDashboardManager({
|
|
|
218
308
|
port = DEFAULT_PORT,
|
|
219
309
|
taskchefVersion = TASKCHEF_VERSION,
|
|
220
310
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
221
|
-
launcher = "
|
|
222
|
-
|
|
311
|
+
launcher = "session",
|
|
312
|
+
sessionPid = process.ppid,
|
|
313
|
+
launchSession = launchDashboardSession,
|
|
223
314
|
readIdentity = readDashboardIdentity,
|
|
224
315
|
readOwner = readDashboardOwner,
|
|
225
|
-
|
|
226
|
-
|
|
316
|
+
readHandoff = readDashboardHandoff,
|
|
317
|
+
requestJson = requestDashboardJson,
|
|
318
|
+
ownerAuthTimeoutMs = SESSION_READY_TIMEOUT_MS,
|
|
319
|
+
handoffConvergenceAttempts = HANDOFF_CONVERGENCE_ATTEMPTS,
|
|
227
320
|
} = {}) {
|
|
321
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
322
|
+
throw new Error("dashboard manager port must be an integer from 1 to 65535");
|
|
323
|
+
}
|
|
324
|
+
if (!Number.isFinite(ownerAuthTimeoutMs) || ownerAuthTimeoutMs <= 0) {
|
|
325
|
+
throw new Error("dashboard owner authentication timeout must be positive");
|
|
326
|
+
}
|
|
228
327
|
let canonicalWorkspace;
|
|
229
|
-
let ownedServer = null;
|
|
230
|
-
let ownedSecret = null;
|
|
231
328
|
let ensurePromise = null;
|
|
232
329
|
let closePromise = null;
|
|
233
|
-
let
|
|
234
|
-
let reuseRecovery = null;
|
|
235
|
-
let retired = false;
|
|
330
|
+
let handoffSessionPids = [];
|
|
236
331
|
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
332
|
+
const settleBeforeDeadline = (promise, deadline) => {
|
|
333
|
+
const operation = Promise.resolve(promise);
|
|
334
|
+
const remaining = deadline - Date.now();
|
|
335
|
+
if (remaining <= 0) {
|
|
336
|
+
operation.catch(() => {});
|
|
337
|
+
return Promise.reject(Object.assign(new Error("dashboard ownership deadline expired"), {
|
|
338
|
+
code: "TASKCHEF_DASHBOARD_OWNER_TIMEOUT",
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const timer = setTimeout(() => reject(Object.assign(
|
|
343
|
+
new Error("dashboard ownership operation timed out"),
|
|
344
|
+
{ code: "TASKCHEF_DASHBOARD_OWNER_TIMEOUT" },
|
|
345
|
+
)), remaining);
|
|
346
|
+
operation.then(
|
|
347
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
348
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
349
|
+
);
|
|
350
|
+
});
|
|
247
351
|
};
|
|
248
352
|
|
|
249
353
|
const publicResult = (action) => ({
|
|
250
354
|
action,
|
|
251
355
|
launcher,
|
|
252
|
-
url: `http://${dashboardAuthority(host,
|
|
356
|
+
url: `http://${dashboardAuthority(host, port)}/`,
|
|
253
357
|
workspace: canonicalWorkspace,
|
|
254
358
|
taskchefVersion,
|
|
255
359
|
serverVersion,
|
|
256
360
|
});
|
|
257
361
|
|
|
258
|
-
const
|
|
259
|
-
let
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
362
|
+
const authenticateOwner = async (identity) => {
|
|
363
|
+
let observedOwner = null;
|
|
364
|
+
let matchingOwner = null;
|
|
365
|
+
let lastChallenge = null;
|
|
366
|
+
let ownershipDeadlineExpired = false;
|
|
367
|
+
let challengeUnavailable = false;
|
|
368
|
+
const ownerMatches = (candidate) => candidate?.host === host && candidate?.port === port
|
|
369
|
+
&& isExactIdentity(identity, publicOwnerIdentity(candidate));
|
|
370
|
+
const currentIdentity = expectedIdentity(
|
|
371
|
+
canonicalWorkspace, taskchefVersion, serverVersion, launcher,
|
|
372
|
+
);
|
|
373
|
+
const recognizedStartingSession = identity?.schemaVersion === 1
|
|
374
|
+
&& identity?.service === "taskchef-dashboard"
|
|
375
|
+
&& identity?.workspace === canonicalWorkspace
|
|
376
|
+
&& identity?.launcher === "session"
|
|
377
|
+
&& (isExactIdentity(identity, currentIdentity)
|
|
378
|
+
|| priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion));
|
|
379
|
+
const deadline = Date.now() + (recognizedStartingSession
|
|
380
|
+
? ownerAuthTimeoutMs
|
|
381
|
+
: Math.min(ownerAuthTimeoutMs, OWNER_AUTH_DEFAULT_TIMEOUT_MS));
|
|
382
|
+
do {
|
|
383
|
+
try {
|
|
384
|
+
const owner = await settleBeforeDeadline(readOwner(canonicalWorkspace), deadline);
|
|
385
|
+
observedOwner = owner;
|
|
386
|
+
if (ownerMatches(owner)) {
|
|
387
|
+
matchingOwner = owner;
|
|
388
|
+
const challengeNonce = createDashboardControlNonce();
|
|
389
|
+
lastChallenge = await requestJson({
|
|
390
|
+
host,
|
|
391
|
+
port,
|
|
392
|
+
path: `${DASHBOARD_CONTROL_CHALLENGE_PATH}?nonce=${challengeNonce}`,
|
|
393
|
+
timeoutMs: Math.max(1, Math.min(HEALTH_TIMEOUT_MS, deadline - Date.now())),
|
|
394
|
+
}).catch((error) => {
|
|
395
|
+
if (Date.now() >= deadline || /timed out/i.test(error?.message ?? "")) {
|
|
396
|
+
ownershipDeadlineExpired = true;
|
|
397
|
+
} else {
|
|
398
|
+
challengeUnavailable = true;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
});
|
|
402
|
+
if (lastChallenge?.statusCode === 200
|
|
403
|
+
&& lastChallenge.value?.schemaVersion === 1
|
|
404
|
+
&& lastChallenge.value?.nonce === challengeNonce
|
|
405
|
+
&& verifyDashboardControlProof(
|
|
406
|
+
owner.secret,
|
|
407
|
+
"challenge",
|
|
408
|
+
challengeNonce,
|
|
409
|
+
lastChallenge.value?.proof,
|
|
410
|
+
)) {
|
|
411
|
+
return owner;
|
|
412
|
+
}
|
|
413
|
+
if (challengeUnavailable) break;
|
|
414
|
+
} else if (owner.host === host && owner.port === port
|
|
415
|
+
&& priorCompatibleVersion(taskchefVersion, owner.taskchefVersion)) {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error?.code === "TASKCHEF_DASHBOARD_OWNER_TIMEOUT") {
|
|
420
|
+
ownershipDeadlineExpired = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const remaining = deadline - Date.now();
|
|
424
|
+
if (remaining <= 0) break;
|
|
425
|
+
await new Promise((resolve) => setTimeout(
|
|
426
|
+
resolve,
|
|
427
|
+
Math.min(SESSION_START_INTERVAL_MS, remaining),
|
|
428
|
+
));
|
|
429
|
+
} while (Date.now() < deadline);
|
|
430
|
+
if (!matchingOwner) {
|
|
431
|
+
const priorVersion = identity.taskchefVersion !== taskchefVersion;
|
|
432
|
+
if (observedOwner) {
|
|
433
|
+
throw listenerConflict(
|
|
434
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
435
|
+
"has ownership metadata that does not exactly match the listener identity.",
|
|
436
|
+
{ staleTaskchefVersion: priorVersion ? identity.taskchefVersion : undefined },
|
|
437
|
+
);
|
|
438
|
+
}
|
|
263
439
|
throw listenerConflict(
|
|
264
440
|
`http://${dashboardAuthority(host, port)}/`,
|
|
265
|
-
|
|
441
|
+
priorVersion
|
|
442
|
+
? `is a verified older TaskChef ${identity.taskchefVersion} listener without usable authenticated handoff metadata.`
|
|
443
|
+
: `is a verified TaskChef ${identity.taskchefVersion} session listener without usable authenticated ownership metadata.`,
|
|
266
444
|
{
|
|
267
|
-
staleTaskchefVersion: identity.taskchefVersion,
|
|
268
|
-
handoffRaceEligible: error?.code === "ENOENT",
|
|
269
|
-
retryAuthentication: error?.code === "ENOENT",
|
|
445
|
+
staleTaskchefVersion: priorVersion ? identity.taskchefVersion : undefined,
|
|
270
446
|
},
|
|
271
447
|
);
|
|
272
448
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
449
|
+
throw listenerConflict(
|
|
450
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
451
|
+
"did not prove control of its private TaskChef ownership credential.",
|
|
452
|
+
{
|
|
453
|
+
staleTaskchefVersion: identity.taskchefVersion !== taskchefVersion
|
|
454
|
+
? identity.taskchefVersion
|
|
455
|
+
: undefined,
|
|
456
|
+
handoffRaceEligible: !ownershipDeadlineExpired && lastChallenge === null,
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const recoverFinalHandoff = async (
|
|
462
|
+
owner,
|
|
463
|
+
expectedId,
|
|
464
|
+
deadline = Date.now() + ownerAuthTimeoutMs,
|
|
465
|
+
) => {
|
|
466
|
+
let handoff;
|
|
467
|
+
try {
|
|
468
|
+
handoff = await settleBeforeDeadline(readHandoff(canonicalWorkspace), deadline);
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
280
471
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
472
|
+
const pids = handoff?.pids;
|
|
473
|
+
if (handoff?.workspace !== canonicalWorkspace || handoff?.host !== host
|
|
474
|
+
|| handoff?.port !== port || handoff?.launcher !== "session"
|
|
475
|
+
|| handoff?.taskchefVersion !== owner.taskchefVersion
|
|
476
|
+
|| handoff?.serverVersion !== owner.serverVersion
|
|
477
|
+
|| (expectedId !== undefined && handoff?.id !== expectedId)
|
|
478
|
+
|| !Array.isArray(pids) || pids.length > MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS
|
|
479
|
+
|| pids.some((pid) => !validSessionPid(pid))
|
|
480
|
+
|| new Set(pids).size !== pids.length
|
|
481
|
+
|| !verifyDashboardControlProof(
|
|
482
|
+
owner.secret,
|
|
483
|
+
`handoff-final:${JSON.stringify(pids)}`,
|
|
484
|
+
handoff?.id,
|
|
485
|
+
handoff?.proof,
|
|
486
|
+
)) return null;
|
|
487
|
+
return pids;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const registerSession = async (identity) => {
|
|
491
|
+
const owner = await authenticateOwner(identity);
|
|
492
|
+
const nonce = createDashboardControlNonce();
|
|
493
|
+
const registration = await requestJson({
|
|
283
494
|
host,
|
|
284
495
|
port,
|
|
285
|
-
path:
|
|
496
|
+
path: DASHBOARD_CONTROL_SESSION_PATH,
|
|
497
|
+
method: "POST",
|
|
498
|
+
body: {
|
|
499
|
+
pid: sessionPid,
|
|
500
|
+
nonce,
|
|
501
|
+
proof: dashboardControlProof(owner.secret, `session:${sessionPid}`, nonce),
|
|
502
|
+
},
|
|
286
503
|
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
287
504
|
}).catch(() => null);
|
|
288
|
-
if (
|
|
289
|
-
||
|
|
290
|
-
|| challenge.value?.nonce !== challengeNonce
|
|
505
|
+
if (registration?.statusCode !== 200 || registration.value?.accepted !== true
|
|
506
|
+
|| registration.value?.nonce !== nonce
|
|
291
507
|
|| !verifyDashboardControlProof(
|
|
292
|
-
owner.secret,
|
|
508
|
+
owner.secret,
|
|
509
|
+
`session-accepted:${sessionPid}`,
|
|
510
|
+
nonce,
|
|
511
|
+
registration.value?.proof,
|
|
293
512
|
)) {
|
|
294
513
|
throw listenerConflict(
|
|
295
514
|
`http://${dashboardAuthority(host, port)}/`,
|
|
296
|
-
"
|
|
515
|
+
"refused authenticated Codex-session registration.",
|
|
297
516
|
{
|
|
298
|
-
|
|
299
|
-
|
|
517
|
+
handoffRaceEligible: registration === null
|
|
518
|
+
|| registration?.value?.reason === "retiring",
|
|
300
519
|
},
|
|
301
520
|
);
|
|
302
521
|
}
|
|
@@ -304,34 +523,161 @@ export function createDashboardManager({
|
|
|
304
523
|
};
|
|
305
524
|
|
|
306
525
|
const retirePriorOwner = async (identity) => {
|
|
307
|
-
const owner = await
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
526
|
+
const owner = await authenticateOwner(identity);
|
|
527
|
+
let sessionPids = [];
|
|
528
|
+
if (identity.launcher === "session") {
|
|
529
|
+
let handoff = null;
|
|
530
|
+
let handoffNonce;
|
|
531
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
532
|
+
handoffNonce = createDashboardControlNonce();
|
|
533
|
+
handoff = await requestJson({
|
|
534
|
+
host,
|
|
535
|
+
port,
|
|
536
|
+
path: DASHBOARD_CONTROL_HANDOFF_PATH,
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: {
|
|
539
|
+
pid: sessionPid,
|
|
540
|
+
nonce: handoffNonce,
|
|
541
|
+
proof: dashboardControlProof(owner.secret, `handoff:${sessionPid}`, handoffNonce),
|
|
542
|
+
},
|
|
543
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
544
|
+
}).catch(() => null);
|
|
545
|
+
if (handoff !== null) break;
|
|
546
|
+
}
|
|
547
|
+
const pids = handoff?.value?.pids;
|
|
548
|
+
const handoffId = handoff?.value?.id;
|
|
549
|
+
if (handoff?.statusCode !== 200 || handoff.value?.accepted !== true
|
|
550
|
+
|| !validDashboardControlNonce(handoffId)
|
|
551
|
+
|| !Array.isArray(pids)
|
|
552
|
+
|| pids.length > MAX_DASHBOARD_SESSION_PIDS
|
|
553
|
+
|| pids.some((pid) => !validSessionPid(pid))
|
|
554
|
+
|| new Set(pids).size !== pids.length
|
|
555
|
+
|| !pids.includes(sessionPid)
|
|
556
|
+
|| handoff.value?.nonce !== handoffNonce
|
|
557
|
+
|| !verifyDashboardControlProof(
|
|
558
|
+
owner.secret,
|
|
559
|
+
`handoff-prepared:${sessionPid}:${handoffId}:${JSON.stringify(pids)}`,
|
|
560
|
+
handoffNonce,
|
|
561
|
+
handoff.value?.proof,
|
|
562
|
+
)) {
|
|
563
|
+
throw listenerConflict(
|
|
564
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
565
|
+
"refused authenticated TaskChef session handoff.",
|
|
566
|
+
{
|
|
567
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
568
|
+
handoffRaceEligible: handoff === null || handoff?.value?.reason === "retiring",
|
|
569
|
+
},
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
sessionPids = pids;
|
|
573
|
+
let committed = false;
|
|
574
|
+
let commitResponseLost = false;
|
|
575
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
576
|
+
const commitNonce = createDashboardControlNonce();
|
|
577
|
+
const commit = await requestJson({
|
|
578
|
+
host,
|
|
579
|
+
port,
|
|
580
|
+
path: DASHBOARD_CONTROL_HANDOFF_COMMIT_PATH,
|
|
581
|
+
method: "POST",
|
|
582
|
+
body: {
|
|
583
|
+
id: handoffId,
|
|
584
|
+
nonce: commitNonce,
|
|
585
|
+
proof: dashboardControlProof(owner.secret, `handoff-commit:${handoffId}`, commitNonce),
|
|
586
|
+
},
|
|
587
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
588
|
+
}).catch(() => null);
|
|
589
|
+
const committedPids = commit?.value?.pids;
|
|
590
|
+
if (commit?.statusCode === 202 && commit.value?.accepted === true
|
|
591
|
+
&& commit.value?.id === handoffId && commit.value?.nonce === commitNonce
|
|
592
|
+
&& Array.isArray(committedPids)
|
|
593
|
+
&& committedPids.length <= MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS
|
|
594
|
+
&& committedPids.every((pid) => validSessionPid(pid))
|
|
595
|
+
&& new Set(committedPids).size === committedPids.length
|
|
596
|
+
&& committedPids.includes(sessionPid)
|
|
597
|
+
&& verifyDashboardControlProof(
|
|
598
|
+
owner.secret,
|
|
599
|
+
`handoff-committed:${handoffId}:${JSON.stringify(committedPids)}`,
|
|
600
|
+
commitNonce,
|
|
601
|
+
commit.value?.proof,
|
|
602
|
+
)) {
|
|
603
|
+
sessionPids = committedPids;
|
|
604
|
+
committed = true;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (commit === null) {
|
|
608
|
+
commitResponseLost = true;
|
|
609
|
+
try {
|
|
610
|
+
await readIdentity({ host, port, timeoutMs: 100 });
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
613
|
+
const recovered = await recoverFinalHandoff(owner, handoffId);
|
|
614
|
+
if (recovered) return recovered;
|
|
615
|
+
throw listenerConflict(
|
|
616
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
617
|
+
"closed before its authenticated final lease snapshot could be recovered.",
|
|
618
|
+
{ staleTaskchefVersion: identity.taskchefVersion },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
if (!committed && commitResponseLost) {
|
|
628
|
+
const recoveryDeadline = Date.now() + ownerAuthTimeoutMs;
|
|
629
|
+
let recovered = null;
|
|
630
|
+
do {
|
|
631
|
+
recovered ??= await recoverFinalHandoff(owner, handoffId, recoveryDeadline);
|
|
632
|
+
try {
|
|
633
|
+
await readIdentity({ host, port, timeoutMs: 100 });
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
636
|
+
recovered ??= await recoverFinalHandoff(owner, handoffId, recoveryDeadline);
|
|
637
|
+
if (recovered) return recovered;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
643
|
+
} while (Date.now() < recoveryDeadline);
|
|
644
|
+
}
|
|
645
|
+
if (!committed) {
|
|
646
|
+
throw listenerConflict(
|
|
647
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
648
|
+
"refused authenticated TaskChef session handoff commit.",
|
|
649
|
+
{ staleTaskchefVersion: identity.taskchefVersion },
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
const shutdownNonce = createDashboardControlNonce();
|
|
654
|
+
const shutdown = await requestJson({
|
|
655
|
+
host,
|
|
656
|
+
port,
|
|
657
|
+
path: DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
658
|
+
method: "POST",
|
|
659
|
+
body: {
|
|
660
|
+
nonce: shutdownNonce,
|
|
661
|
+
proof: dashboardControlProof(owner.secret, "shutdown", shutdownNonce),
|
|
327
662
|
},
|
|
328
|
-
|
|
663
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
664
|
+
}).catch(() => null);
|
|
665
|
+
if (shutdown?.statusCode !== 202 || shutdown.value?.accepted !== true) {
|
|
666
|
+
throw listenerConflict(
|
|
667
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
668
|
+
"refused authenticated TaskChef version handoff.",
|
|
669
|
+
{
|
|
670
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
671
|
+
handoffRaceEligible: shutdown === null || shutdown?.statusCode === 409,
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
}
|
|
329
675
|
}
|
|
330
|
-
for (let attempt = 0; attempt <
|
|
676
|
+
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
331
677
|
try {
|
|
332
678
|
await readIdentity({ host, port, timeoutMs: 100 });
|
|
333
679
|
} catch (error) {
|
|
334
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET") return;
|
|
680
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return sessionPids;
|
|
335
681
|
}
|
|
336
682
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
337
683
|
}
|
|
@@ -346,50 +692,193 @@ export function createDashboardManager({
|
|
|
346
692
|
);
|
|
347
693
|
};
|
|
348
694
|
|
|
695
|
+
const waitForPriorSessionReplacement = async () => {
|
|
696
|
+
const deadline = Date.now() + ownerAuthTimeoutMs;
|
|
697
|
+
let owner;
|
|
698
|
+
try {
|
|
699
|
+
owner = await settleBeforeDeadline(readOwner(canonicalWorkspace), deadline);
|
|
700
|
+
} catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
const priorIdentity = publicOwnerIdentity(owner);
|
|
704
|
+
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
|
|
705
|
+
const ownerIsCurrent = isExactIdentity(priorIdentity, expected);
|
|
706
|
+
const ownerIsPrior = priorCompatibleVersion(owner.taskchefVersion, taskchefVersion);
|
|
707
|
+
const ownerIsNewer = priorCompatibleVersion(taskchefVersion, owner.taskchefVersion);
|
|
708
|
+
if (owner.host === host && owner.port === port && owner.launcher === "session"
|
|
709
|
+
&& ownerIsNewer) {
|
|
710
|
+
throw listenerConflict(
|
|
711
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
712
|
+
`has retained ownership from newer TaskChef ${owner.taskchefVersion}; refusing to downgrade it.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (owner.host !== host || owner.port !== port || owner.launcher !== "session"
|
|
716
|
+
|| (!ownerIsCurrent && !ownerIsPrior)) return false;
|
|
717
|
+
let finalizedCurrentOwner = false;
|
|
718
|
+
let recoveredPriorOwner = false;
|
|
719
|
+
const recoveredAtStart = await recoverFinalHandoff(owner, undefined, deadline);
|
|
720
|
+
if (recoveredAtStart) {
|
|
721
|
+
if (ownerIsPrior) {
|
|
722
|
+
handoffSessionPids = recoveredAtStart;
|
|
723
|
+
recoveredPriorOwner = true;
|
|
724
|
+
}
|
|
725
|
+
if (ownerIsCurrent) finalizedCurrentOwner = true;
|
|
726
|
+
}
|
|
727
|
+
if (ownerIsCurrent && !finalizedCurrentOwner) return false;
|
|
728
|
+
do {
|
|
729
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
730
|
+
let replacement;
|
|
731
|
+
try {
|
|
732
|
+
replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
735
|
+
if (ownerIsPrior) {
|
|
736
|
+
const recovered = await recoverFinalHandoff(owner, undefined, deadline);
|
|
737
|
+
if (recovered) {
|
|
738
|
+
handoffSessionPids = recovered;
|
|
739
|
+
recoveredPriorOwner = true;
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
} else if (ownerIsCurrent) {
|
|
743
|
+
finalizedCurrentOwner ||= Boolean(
|
|
744
|
+
await recoverFinalHandoff(owner, undefined, deadline),
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
if (isExactIdentity(replacement, expected)) {
|
|
752
|
+
await registerSession(replacement);
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
if (isExactIdentity(replacement, priorIdentity)) {
|
|
756
|
+
if (ownerIsCurrent) {
|
|
757
|
+
await registerSession(replacement);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
if (recoveredPriorOwner) continue;
|
|
761
|
+
handoffSessionPids = await retirePriorOwner(replacement);
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
const replacementIsNewer = replacement?.schemaVersion === 1
|
|
765
|
+
&& replacement?.service === "taskchef-dashboard"
|
|
766
|
+
&& replacement?.workspace === canonicalWorkspace
|
|
767
|
+
&& replacement?.launcher === "session"
|
|
768
|
+
&& priorCompatibleVersion(taskchefVersion, replacement?.taskchefVersion);
|
|
769
|
+
if (replacementIsNewer) {
|
|
770
|
+
throw listenerConflict(
|
|
771
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
772
|
+
`belongs to newer TaskChef ${replacement.taskchefVersion}; refusing to downgrade it.`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
throw listenerConflict(
|
|
776
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
777
|
+
"changed to an unexpected listener during authenticated version handoff.",
|
|
778
|
+
);
|
|
779
|
+
} while (Date.now() < deadline);
|
|
780
|
+
if (finalizedCurrentOwner) {
|
|
781
|
+
throw listenerConflict(
|
|
782
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
783
|
+
`TaskChef ${taskchefVersion} has already finalized retirement; refusing to relaunch it.`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (recoveredPriorOwner) {
|
|
787
|
+
throw listenerConflict(
|
|
788
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
789
|
+
"has a verified final handoff snapshot but did not release the listener.",
|
|
790
|
+
{ staleTaskchefVersion: owner.taskchefVersion },
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
};
|
|
795
|
+
|
|
349
796
|
const probe = async () => {
|
|
350
797
|
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
351
798
|
let identity;
|
|
352
799
|
try {
|
|
353
800
|
identity = await readIdentity({ host, port });
|
|
354
801
|
} catch (error) {
|
|
355
|
-
if (listenerAbsent(error))
|
|
802
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
803
|
+
return waitForPriorSessionReplacement();
|
|
804
|
+
}
|
|
356
805
|
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
357
806
|
}
|
|
358
807
|
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
|
|
359
|
-
if (isExactIdentity(identity, expected))
|
|
808
|
+
if (isExactIdentity(identity, expected)) {
|
|
809
|
+
try {
|
|
810
|
+
await registerSession(identity);
|
|
811
|
+
return true;
|
|
812
|
+
} catch (registrationError) {
|
|
813
|
+
if (!registrationError?.handoffRaceEligible) throw registrationError;
|
|
814
|
+
let lastError = registrationError;
|
|
815
|
+
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
816
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
817
|
+
let replacement;
|
|
818
|
+
try {
|
|
819
|
+
replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
820
|
+
} catch (error) {
|
|
821
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return false;
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
if (!isExactIdentity(replacement, expected)) throw registrationError;
|
|
825
|
+
try {
|
|
826
|
+
await registerSession(replacement);
|
|
827
|
+
return true;
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (!error?.handoffRaceEligible) throw error;
|
|
830
|
+
lastError = error;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
throw lastError;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
360
836
|
const compatiblePrior = identity?.schemaVersion === 1
|
|
361
837
|
&& identity?.service === "taskchef-dashboard"
|
|
362
838
|
&& identity?.workspace === canonicalWorkspace
|
|
363
|
-
&&
|
|
364
|
-
&& identity?.serverVersion === serverVersion
|
|
839
|
+
&& new Set(["mcp", "session"]).has(identity?.launcher)
|
|
365
840
|
&& priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion);
|
|
366
841
|
if (compatiblePrior) {
|
|
367
842
|
try {
|
|
368
|
-
await retirePriorOwner(identity);
|
|
843
|
+
handoffSessionPids = await retirePriorOwner(identity);
|
|
369
844
|
return false;
|
|
370
845
|
} catch (handoffError) {
|
|
371
846
|
if (!handoffError?.handoffRaceEligible) throw handoffError;
|
|
372
847
|
let raceError = handoffError;
|
|
373
|
-
|
|
848
|
+
try {
|
|
849
|
+
const reused = await waitForPriorSessionReplacement();
|
|
850
|
+
if (reused || handoffSessionPids.length > 0) return reused;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (!error?.handoffRaceEligible) throw error;
|
|
853
|
+
raceError = error;
|
|
854
|
+
}
|
|
855
|
+
let sawAbsent = false;
|
|
856
|
+
for (let attempt = 0; attempt < handoffConvergenceAttempts; attempt += 1) {
|
|
374
857
|
try {
|
|
375
858
|
const replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
376
|
-
if (isExactIdentity(replacement, expected))
|
|
859
|
+
if (isExactIdentity(replacement, expected)) {
|
|
860
|
+
await registerSession(replacement);
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
377
863
|
if (!isExactIdentity(replacement, identity)) throw raceError;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
raceError = error;
|
|
385
|
-
}
|
|
864
|
+
try {
|
|
865
|
+
handoffSessionPids = await retirePriorOwner(identity);
|
|
866
|
+
return false;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
if (!error?.handoffRaceEligible) throw error;
|
|
869
|
+
raceError = error;
|
|
386
870
|
}
|
|
387
871
|
} catch (error) {
|
|
388
|
-
if (listenerAbsent(error) || error?.code === "ECONNRESET")
|
|
872
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") {
|
|
873
|
+
sawAbsent = true;
|
|
874
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
389
877
|
throw error;
|
|
390
878
|
}
|
|
391
879
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
392
880
|
}
|
|
881
|
+
if (sawAbsent) return false;
|
|
393
882
|
throw raceError;
|
|
394
883
|
}
|
|
395
884
|
}
|
|
@@ -404,103 +893,90 @@ export function createDashboardManager({
|
|
|
404
893
|
|
|
405
894
|
const ensureOnce = async () => {
|
|
406
895
|
canonicalWorkspace ??= await realpath(path.resolve(workspace));
|
|
407
|
-
if (ownedServer) return publicResult("reused");
|
|
408
896
|
if (await probe()) return publicResult("reused");
|
|
409
|
-
|
|
897
|
+
for (let replacementAttempt = 0;
|
|
898
|
+
replacementAttempt < MAX_VERSION_REPLACEMENT_ATTEMPTS;
|
|
899
|
+
replacementAttempt += 1) {
|
|
410
900
|
const secret = createDashboardControlSecret();
|
|
411
|
-
const control = {
|
|
412
|
-
secret,
|
|
413
|
-
onShutdown: () => {
|
|
414
|
-
retired = true;
|
|
415
|
-
return closeOwned();
|
|
416
|
-
},
|
|
417
|
-
};
|
|
418
|
-
ownedServer = await createServer({
|
|
419
|
-
workspace: canonicalWorkspace,
|
|
420
|
-
host,
|
|
421
|
-
port,
|
|
422
|
-
taskchefVersion,
|
|
423
|
-
serverVersion,
|
|
424
|
-
launcher,
|
|
425
|
-
control,
|
|
426
|
-
});
|
|
427
|
-
ownedSecret = secret;
|
|
428
901
|
try {
|
|
429
|
-
await
|
|
902
|
+
await launchSession({
|
|
430
903
|
workspace: canonicalWorkspace,
|
|
431
904
|
host,
|
|
432
|
-
port
|
|
905
|
+
port,
|
|
906
|
+
secret,
|
|
907
|
+
sessionPid,
|
|
908
|
+
sessionPids: handoffSessionPids,
|
|
433
909
|
taskchefVersion,
|
|
434
910
|
serverVersion,
|
|
435
|
-
|
|
436
|
-
secret,
|
|
437
|
-
}));
|
|
911
|
+
});
|
|
438
912
|
} catch (error) {
|
|
439
|
-
|
|
440
|
-
|
|
913
|
+
// Concurrent activations can both observe a free port before one binds it.
|
|
914
|
+
// The loser must authenticate and reuse or upgrade the winner.
|
|
915
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
441
916
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (await probe()) return publicResult("reused");
|
|
446
|
-
throw error;
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const monitorExactReuse = () => {
|
|
451
|
-
if (reuseMonitor || ownedServer || closePromise || retired) return;
|
|
452
|
-
reuseMonitor = setInterval(() => {
|
|
453
|
-
if (ownedServer || closePromise || retired || reuseRecovery) return;
|
|
454
|
-
reuseRecovery = (async () => {
|
|
917
|
+
let lastError = null;
|
|
918
|
+
let retryAfterLowerVersion = false;
|
|
919
|
+
for (let attempt = 0; attempt < SESSION_START_ATTEMPTS; attempt += 1) {
|
|
455
920
|
try {
|
|
456
|
-
const identity = await readIdentity({ host, port, timeoutMs:
|
|
921
|
+
const identity = await readIdentity({ host, port, timeoutMs: 100 });
|
|
457
922
|
const expected = expectedIdentity(
|
|
458
923
|
canonicalWorkspace, taskchefVersion, serverVersion, launcher,
|
|
459
924
|
);
|
|
460
|
-
if (isExactIdentity(identity, expected))
|
|
461
|
-
|
|
462
|
-
|
|
925
|
+
if (!isExactIdentity(identity, expected)) {
|
|
926
|
+
const compatibleLower = identity?.schemaVersion === 1
|
|
927
|
+
&& identity?.service === "taskchef-dashboard"
|
|
928
|
+
&& identity?.workspace === canonicalWorkspace
|
|
929
|
+
&& new Set(["mcp", "session"]).has(identity?.launcher)
|
|
930
|
+
&& priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion);
|
|
931
|
+
if (compatibleLower) {
|
|
932
|
+
if (await probe()) return publicResult("reused");
|
|
933
|
+
retryAfterLowerVersion = true;
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
throw listenerConflict(
|
|
937
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
938
|
+
"changed to an unexpected listener while the dashboard session was starting.",
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
const owner = await registerSession(identity);
|
|
942
|
+
return publicResult(owner.secret === secret ? "started" : "reused");
|
|
463
943
|
} catch (error) {
|
|
464
|
-
if (!listenerAbsent(error) && error?.code !== "ECONNRESET"
|
|
465
|
-
|
|
466
|
-
|
|
944
|
+
if (!listenerAbsent(error) && error?.code !== "ECONNRESET"
|
|
945
|
+
&& error?.code !== "ENOENT" && !error?.handoffRaceEligible) throw error;
|
|
946
|
+
lastError = error;
|
|
467
947
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
948
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_START_INTERVAL_MS));
|
|
949
|
+
}
|
|
950
|
+
if (retryAfterLowerVersion) continue;
|
|
951
|
+
const error = new Error(
|
|
952
|
+
`TaskChef dashboard session did not become available at http://${dashboardAuthority(host, port)}/`,
|
|
953
|
+
{ cause: lastError },
|
|
954
|
+
);
|
|
955
|
+
error.code = "TASKCHEF_DASHBOARD_START_TIMEOUT";
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
958
|
+
throw listenerConflict(
|
|
959
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
960
|
+
"changed versions too many times during concurrent authenticated startup.",
|
|
961
|
+
);
|
|
475
962
|
};
|
|
476
963
|
|
|
477
964
|
return {
|
|
478
965
|
async ensure() {
|
|
479
|
-
if (closePromise
|
|
966
|
+
if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
|
|
480
967
|
if (ensurePromise) {
|
|
481
968
|
await ensurePromise;
|
|
482
969
|
return publicResult("reused");
|
|
483
970
|
}
|
|
484
971
|
ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
|
|
485
|
-
|
|
486
|
-
if (result.action === "reused" && !ownedServer) monitorExactReuse();
|
|
487
|
-
return result;
|
|
972
|
+
return ensurePromise;
|
|
488
973
|
},
|
|
489
974
|
async close() {
|
|
490
975
|
closePromise ??= (async () => {
|
|
491
|
-
stopReuseMonitor();
|
|
492
976
|
await ensurePromise?.catch(() => {});
|
|
493
|
-
await reuseRecovery?.catch(() => {});
|
|
494
|
-
const secret = ownedSecret;
|
|
495
|
-
if (secret) await closeOwned();
|
|
496
|
-
else {
|
|
497
|
-
const server = ownedServer;
|
|
498
|
-
ownedServer = null;
|
|
499
|
-
await server?.close();
|
|
500
|
-
}
|
|
501
977
|
})();
|
|
502
978
|
return closePromise;
|
|
503
979
|
},
|
|
504
|
-
get owned() { return
|
|
980
|
+
get owned() { return false; },
|
|
505
981
|
};
|
|
506
982
|
}
|