taskchef 8.2.2 → 8.3.1

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