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.
@@ -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
- dashboardOwnerMetadata,
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 REUSE_MONITOR_INTERVAL_MS = 250;
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 = "mcp",
222
- createServer = createDashboardServer,
311
+ launcher = "session",
312
+ sessionPid = process.ppid,
313
+ launchSession = launchDashboardSession,
223
314
  readIdentity = readDashboardIdentity,
224
315
  readOwner = readDashboardOwner,
225
- writeOwner = writeDashboardOwner,
226
- reuseMonitorIntervalMs = REUSE_MONITOR_INTERVAL_MS,
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 reuseMonitor = null;
234
- let reuseRecovery = null;
235
- let retired = false;
330
+ let handoffSessionPids = [];
236
331
 
237
- const stopReuseMonitor = () => {
238
- if (reuseMonitor) clearInterval(reuseMonitor);
239
- reuseMonitor = null;
240
- };
241
-
242
- const closeOwned = async () => {
243
- const server = ownedServer;
244
- ownedServer = null;
245
- ownedSecret = null;
246
- await server?.close();
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, ownedServer?.port ?? port)}/`,
356
+ url: `http://${dashboardAuthority(host, port)}/`,
253
357
  workspace: canonicalWorkspace,
254
358
  taskchefVersion,
255
359
  serverVersion,
256
360
  });
257
361
 
258
- const authenticatePriorOwner = async (identity) => {
259
- let owner;
260
- try {
261
- owner = await readOwner(canonicalWorkspace);
262
- } catch (error) {
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
- `is a verified older TaskChef ${identity.taskchefVersion} listener without usable authenticated handoff metadata.`,
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
- if (owner.host !== host || owner.port !== port
274
- || !isExactIdentity(identity, publicOwnerIdentity(owner))) {
275
- throw listenerConflict(
276
- `http://${dashboardAuthority(host, port)}/`,
277
- "has ownership metadata that does not exactly match the listener identity.",
278
- { staleTaskchefVersion: identity.taskchefVersion },
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 challengeNonce = createDashboardControlNonce();
282
- const challenge = await requestDashboardJson({
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: `${DASHBOARD_CONTROL_CHALLENGE_PATH}?nonce=${challengeNonce}`,
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 (challenge?.statusCode !== 200
289
- || challenge.value?.schemaVersion !== 1
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, "challenge", challengeNonce, challenge.value?.proof,
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
- "did not prove control of its private TaskChef ownership credential.",
515
+ "refused authenticated Codex-session registration.",
297
516
  {
298
- staleTaskchefVersion: identity.taskchefVersion,
299
- handoffRaceEligible: challenge === null,
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 authenticatePriorOwner(identity);
308
- const shutdownNonce = createDashboardControlNonce();
309
- const shutdown = await requestDashboardJson({
310
- host,
311
- port,
312
- path: DASHBOARD_CONTROL_SHUTDOWN_PATH,
313
- method: "POST",
314
- body: {
315
- nonce: shutdownNonce,
316
- proof: dashboardControlProof(owner.secret, "shutdown", shutdownNonce),
317
- },
318
- timeoutMs: HEALTH_TIMEOUT_MS,
319
- }).catch(() => null);
320
- if (shutdown?.statusCode !== 202 || shutdown.value?.accepted !== true) {
321
- throw listenerConflict(
322
- `http://${dashboardAuthority(host, port)}/`,
323
- "refused authenticated TaskChef version handoff.",
324
- {
325
- staleTaskchefVersion: identity.taskchefVersion,
326
- handoffRaceEligible: shutdown === null || shutdown?.statusCode === 409,
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 < 40; attempt += 1) {
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)) return false;
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)) return true;
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
- && identity?.launcher === "mcp"
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
- for (let attempt = 0; attempt < 40; attempt += 1) {
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)) return true;
859
+ if (isExactIdentity(replacement, expected)) {
860
+ await registerSession(replacement);
861
+ return true;
862
+ }
377
863
  if (!isExactIdentity(replacement, identity)) throw raceError;
378
- if (raceError.retryAuthentication) {
379
- try {
380
- await retirePriorOwner(identity);
381
- return false;
382
- } catch (error) {
383
- if (!error?.handoffRaceEligible) throw error;
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") return false;
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
- try {
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 writeOwner(canonicalWorkspace, dashboardOwnerMetadata({
902
+ await launchSession({
430
903
  workspace: canonicalWorkspace,
431
904
  host,
432
- port: ownedServer.port,
905
+ port,
906
+ secret,
907
+ sessionPid,
908
+ sessionPids: handoffSessionPids,
433
909
  taskchefVersion,
434
910
  serverVersion,
435
- launcher,
436
- secret,
437
- }));
911
+ });
438
912
  } catch (error) {
439
- await closeOwned().catch(() => {});
440
- throw error;
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
- return publicResult("started");
443
- } catch (error) {
444
- if (error?.code !== "EADDRINUSE") throw error;
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: HEALTH_TIMEOUT_MS });
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)) return;
461
- // A replacement listener with any other identity is never touched. Keep
462
- // watching so this process can recover normally if that occupant exits.
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") return;
465
- const result = await ensureOnce();
466
- if (result.action === "started" || ownedServer) stopReuseMonitor();
944
+ if (!listenerAbsent(error) && error?.code !== "ECONNRESET"
945
+ && error?.code !== "ENOENT" && !error?.handoffRaceEligible) throw error;
946
+ lastError = error;
467
947
  }
468
- })().catch(() => {
469
- // Startup diagnostics remain available through an explicit ensure call.
470
- }).finally(() => {
471
- reuseRecovery = null;
472
- });
473
- }, reuseMonitorIntervalMs);
474
- reuseMonitor.unref?.();
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 || retired) throw new Error("TaskChef dashboard manager is shutting down");
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
- const result = await ensurePromise;
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 ownedServer !== null; },
980
+ get owned() { return false; },
505
981
  };
506
982
  }