mixdog 0.9.51 → 0.9.52

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.
Files changed (110) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -12,8 +12,9 @@
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import http from 'node:http';
15
+ import { EventEmitter } from 'node:events';
15
16
  import { spawn } from 'node:child_process';
16
- import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
19
20
  import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
@@ -28,6 +29,11 @@ const waitFor = async (fn, ms = 1500) => {
28
29
  while (Date.now() < end) { if (fn()) return true; await delay(20); }
29
30
  return fn();
30
31
  };
32
+ const waitForAsync = async (fn, ms = 1500) => {
33
+ const end = Date.now() + ms;
34
+ while (Date.now() < end) { if (await fn()) return true; await delay(20); }
35
+ return await fn();
36
+ };
31
37
  let failures = 0;
32
38
  function check(label, cond) {
33
39
  const ok = !!cond;
@@ -160,6 +166,15 @@ async function main() {
160
166
  await reattachBufferTest();
161
167
  await pointerFailoverTest();
162
168
  await pointerReconnectFollowsTest();
169
+ await tokenReplacementRetirementTest();
170
+ await tokenReplacementReplayTest();
171
+ await tokenReplacementResponseLossCloseTest();
172
+ await registrationReplayTtlTest();
173
+ await registrationReplayCleanupTest();
174
+ await staleTokenCallTest();
175
+ await clientReconnectSafetyTest();
176
+ await clientTruncatedReplacementCloseTest();
177
+ await workerAttachSafetyTest();
163
178
 
164
179
  console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
165
180
  process.exit(failures === 0 ? 0 : 1);
@@ -176,7 +191,7 @@ async function flipTest() {
176
191
  process.env.MIXDOG_DATA_DIR = tmp;
177
192
  process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
178
193
  const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
179
- const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
194
+ const { readDaemonDiscovery, probeDaemonHealth } = await import('../src/standalone/channel-daemon-client.mjs');
180
195
  const discFile = path.join(tmp, 'channel-daemon.json');
181
196
 
182
197
  const notA = [];
@@ -199,17 +214,22 @@ async function flipTest() {
199
214
  notB.some((m) => m?.params?.content === 'ping-from-stub') &&
200
215
  !notA.some((m) => m?.params?.content === 'ping-from-stub'));
201
216
 
202
- // (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
203
- // next call must transparently respawn + re-attach (bounded retry inside
204
- // execute) no TUI process restart.
217
+ // (fix 1) daemon death mid-session: stale SSE clients must immediately
218
+ // invalidate and re-read discovery. Both workers reach the replacement
219
+ // without a tool call or the old five one-second stale-port retries.
205
220
  const beforeKill = readDaemonDiscovery(discFile);
206
221
  if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
207
- await delay(400);
208
- let recovered = false;
209
- try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
222
+ const reattached = await waitForAsync(async () => {
223
+ const discovery = readDaemonDiscovery(discFile);
224
+ if (!discovery?.pid || discovery.pid === beforeKill?.pid) return false;
225
+ const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: 300 });
226
+ return health?.clients >= 2;
227
+ }, 2600);
210
228
  const afterKill = readDaemonDiscovery(discFile);
211
- check('flip: call transparently respawns+reattaches after daemon death',
212
- recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
229
+ let recovered = false;
230
+ try { recovered = (await wA.execute('reply', { after: 'stale-sse-replacement' }))?.ok === true; } catch {}
231
+ check('flip: stale SSE immediately re-attaches to replacement (no 1..5 stale-port storm)',
232
+ reattached === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid && recovered === true);
213
233
 
214
234
  await wA.stop();
215
235
  await wB.stop();
@@ -302,6 +322,29 @@ function rawPost(port, serverToken, p, body) {
302
322
  req.on('error', reject); req.write(payload); req.end();
303
323
  });
304
324
  }
325
+ function rawPostLoseResponse(port, serverToken, p, body) {
326
+ return new Promise((resolve, reject) => {
327
+ const payload = JSON.stringify(body);
328
+ let settled = false;
329
+ const done = (err) => {
330
+ if (settled) return;
331
+ settled = true;
332
+ if (err) reject(err); else resolve();
333
+ };
334
+ const req = http.request({
335
+ hostname: '127.0.0.1', port, path: p, method: 'POST',
336
+ headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
337
+ }, (res) => {
338
+ // The daemon has already committed before emitting response headers; drop
339
+ // the body exactly where a transport response-loss retry would begin.
340
+ try { res.destroy(); } catch {}
341
+ done();
342
+ });
343
+ req.on('error', (err) => { if (!settled) done(err); });
344
+ req.write(payload);
345
+ req.end();
346
+ });
347
+ }
305
348
  function rawSse(port, serverToken, clientToken, onNotify) {
306
349
  const req = http.request({
307
350
  hostname: '127.0.0.1', port,
@@ -325,6 +368,583 @@ function rawSse(port, serverToken, clientToken, onNotify) {
325
368
  req.end(); return req;
326
369
  }
327
370
 
371
+ function readJsonBody(req) {
372
+ return new Promise((resolve) => {
373
+ let body = '';
374
+ req.setEncoding('utf8');
375
+ req.on('data', (chunk) => { body += chunk; });
376
+ req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
377
+ });
378
+ }
379
+ function sendTestJson(res, statusCode, body) {
380
+ const json = JSON.stringify(body);
381
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json) });
382
+ res.end(json);
383
+ }
384
+ async function startTestServer(handle) {
385
+ const server = http.createServer(handle);
386
+ await new Promise((resolve, reject) => {
387
+ server.once('error', reject);
388
+ server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
389
+ });
390
+ return server;
391
+ }
392
+ function testPort(server) { return server.address().port; }
393
+ async function stopTestServer(server) {
394
+ await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
395
+ }
396
+
397
+ // Removed client tokens must never dispatch a tool with a null ownership
398
+ // context, including a callId that could otherwise hit the dedup cache.
399
+ async function staleTokenCallTest() {
400
+ let dispatched = 0;
401
+ const transport = createChannelDaemonTransport({
402
+ handleCall: async () => { dispatched++; return { ok: true }; },
403
+ clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
404
+ });
405
+ const { port, token } = await transport.start();
406
+ const stale = await rawPost(port, token, '/call', { token: 'removed-token', name: 'reply', callId: 'stale-call' });
407
+ check('transport: stale /call token is rejected before dispatch',
408
+ stale?.error === 'unknown client token' && dispatched === 0);
409
+ await transport.stop();
410
+ }
411
+
412
+ // A reconnecting non-owner must replace its exact old token, not merely leave
413
+ // it behind because another client currently owns the pointer.
414
+ async function tokenReplacementRetirementTest() {
415
+ let dispatches = 0;
416
+ const transport = createChannelDaemonTransport({
417
+ handleCall: async (name, args, ctx) => {
418
+ dispatches++;
419
+ return { ok: true, name, args, leadPid: ctx.leadPid };
420
+ },
421
+ clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
422
+ });
423
+ const { port, token } = await transport.start();
424
+ const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
425
+ const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
426
+ const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
427
+ await delay(80);
428
+ const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
429
+ await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
430
+ const ownerC = await rawPost(port, token, '/client/register', { leadPid: c.pid, cwd: 'C' });
431
+ const oldBufferedBeforeReplace = transport._clientsForTest.get(oldA.token)?.pending?.length === 1;
432
+ const freshA = await rawPost(port, token, '/client/register', {
433
+ leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
434
+ });
435
+ const freshNotifies = [];
436
+ const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
437
+ await waitFor(() => freshNotifies.some((message) =>
438
+ message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
439
+ const nonOwnerStateMigrated =
440
+ freshNotifies.some((message) => message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded') &&
441
+ transport._clientsForTest.get(freshA.token)?.pending?.length === 0 &&
442
+ transport._resolveTargetForTest()?.token === ownerC.token;
443
+ const stale = await rawPost(port, token, '/call', {
444
+ token: oldA.token, name: 'rebind_current_transcript',
445
+ args: { transcriptPath: '/tmp/stale-a.jsonl' }, callId: 'late-old-dedup',
446
+ });
447
+ const [live1, live2] = await Promise.all([
448
+ rawPost(port, token, '/call', {
449
+ token: freshA.token, name: 'rebind_current_transcript',
450
+ args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
451
+ }),
452
+ rawPost(port, token, '/call', {
453
+ token: freshA.token, name: 'rebind_current_transcript',
454
+ args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
455
+ }),
456
+ ]);
457
+ const beforeFreshClose = transport._clientsForTest.size;
458
+ const pointerBeforeFreshClose = transport._resolveTargetForTest()?.token;
459
+ await rawPost(port, token, '/client/deregister', { token: freshA.token });
460
+ check('reconnect: non-owner old token is retired; stale dedup call cannot move pointer',
461
+ stale?.error === 'unknown client token' &&
462
+ oldBufferedBeforeReplace &&
463
+ nonOwnerStateMigrated &&
464
+ !transport._clientsForTest.has(oldA.token) &&
465
+ beforeFreshClose === 3 &&
466
+ pointerBeforeFreshClose === freshA.token &&
467
+ live1?.result?.ok === true && live2?.result?.ok === true &&
468
+ dispatches === 1 &&
469
+ !transport._clientsForTest.has(freshA.token) &&
470
+ transport._clientsForTest.size === 2 &&
471
+ ownerC.token !== freshA.token);
472
+ try { a.kill(); } catch {}
473
+ try { b.kill(); } catch {}
474
+ try { c.kill(); } catch {}
475
+ try { freshSse.destroy(); } catch {}
476
+ await transport.stop();
477
+ }
478
+
479
+ // If replacement commits but its response is lost, the same logical
480
+ // registration id must replay the first fresh token rather than create another.
481
+ async function tokenReplacementReplayTest() {
482
+ const transport = createChannelDaemonTransport({
483
+ handleCall: async () => ({ ok: true }),
484
+ clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
485
+ });
486
+ const { port, token } = await transport.start();
487
+ const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
488
+ const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
489
+ await delay(80);
490
+ const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
491
+ const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
492
+ const replacement = {
493
+ leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
494
+ registrationId: 'response-loss-replacement-1',
495
+ };
496
+ await rawPostLoseResponse(port, token, '/client/register', replacement);
497
+ const freshA = await rawPost(port, token, '/client/register', replacement);
498
+ const countAfterReplay = transport._clientsForTest.size;
499
+ const freshNotifies = [];
500
+ const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
501
+ await waitFor(() => freshNotifies.some((message) =>
502
+ message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
503
+ const pendingMigrated = transport._clientsForTest.get(freshA.token)?.pending?.length === 0;
504
+ await rawPost(port, token, '/client/deregister', { token: freshA.token });
505
+ check('reconnect: response-loss replacement replays one fresh token and close leaves no orphan',
506
+ countAfterReplay === 2 &&
507
+ !transport._clientsForTest.has(oldA.token) &&
508
+ transport._resolveTargetForTest()?.token === ownerB.token &&
509
+ pendingMigrated &&
510
+ !transport._clientsForTest.has(freshA.token) &&
511
+ transport._clientsForTest.size === 1 &&
512
+ transport._registrationReplaysForTest.size === 0);
513
+ try { freshSse.destroy(); } catch {}
514
+ try { a.kill(); } catch {}
515
+ try { b.kill(); } catch {}
516
+ await transport.stop();
517
+ }
518
+
519
+ // If the replacement commit survives but its response is lost, an immediate
520
+ // close still knows the retired token + registration id and must cancel exactly
521
+ // that unknown fresh logical client, not an unrelated live owner.
522
+ async function tokenReplacementResponseLossCloseTest() {
523
+ const transport = createChannelDaemonTransport({
524
+ handleCall: async () => ({ ok: true }),
525
+ clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
526
+ });
527
+ const { port, token } = await transport.start();
528
+ const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
529
+ const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
530
+ await delay(80);
531
+ const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
532
+ const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
533
+ const cancellation = {
534
+ token: oldA.token, replaceToken: oldA.token, leadPid: a.pid, cwd: 'A',
535
+ registrationId: 'response-loss-immediate-close-1',
536
+ };
537
+ await rawPostLoseResponse(port, token, '/client/register', {
538
+ ...cancellation, reattach: true,
539
+ });
540
+ const fresh = [...transport._clientsForTest.values()].find((client) => client.leadPid === a.pid);
541
+ const pendingMigrated = fresh?.pending?.length === 1;
542
+ const wrongClose = await rawPost(port, token, '/client/deregister', {
543
+ ...cancellation, cwd: 'not-A',
544
+ });
545
+ const close1 = await rawPost(port, token, '/client/deregister', cancellation);
546
+ const close2 = await rawPost(port, token, '/client/deregister', cancellation);
547
+ check('reconnect: response-loss immediate close cancels unknown fresh client without stranding state',
548
+ wrongClose?.error === 'forbidden replacement deregister' &&
549
+ pendingMigrated &&
550
+ close1?.ok === true && close1?.cancelled === true &&
551
+ close2?.ok === true && close2?.cancelled === false &&
552
+ !transport._clientsForTest.has(oldA.token) &&
553
+ !transport._clientsForTest.has(fresh?.token) &&
554
+ transport._clientsForTest.size === 1 &&
555
+ transport._resolveTargetForTest()?.token === ownerB.token &&
556
+ transport._registrationReplaysForTest.size === 0);
557
+ try { a.kill(); } catch {}
558
+ try { b.kill(); } catch {}
559
+ await transport.stop();
560
+ }
561
+
562
+ // Replay TTL bounds only cancellation metadata after a response has flushed:
563
+ // a valid client may delay SSE/call well beyond that TTL. A replay near expiry
564
+ // refreshes the metadata lifetime so its returned token remains cancellable.
565
+ async function registrationReplayTtlTest() {
566
+ const transport = createChannelDaemonTransport({
567
+ handleCall: async () => ({ ok: true }),
568
+ clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 100,
569
+ onClientsEmpty: () => {},
570
+ });
571
+ const { port, token } = await transport.start();
572
+ const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
573
+ const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
574
+ await delay(80);
575
+ const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
576
+ await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
577
+ const valid = await rawPost(port, token, '/client/register', {
578
+ leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token, registrationId: 'ttl-valid',
579
+ });
580
+ await delay(150);
581
+ const validSurvived = transport._clientsForTest.has(valid.token) &&
582
+ transport._clientsForTest.get(valid.token)?.pending?.length === 1 &&
583
+ transport._registrationReplaysForTest.size === 0;
584
+ await rawPost(port, token, '/client/deregister', { token: valid.token });
585
+
586
+ const oldA2 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A2' });
587
+ const replacement = {
588
+ leadPid: a.pid, cwd: 'A2', reattach: true, replaceToken: oldA2.token, registrationId: 'ttl-refresh',
589
+ };
590
+ const fresh = await rawPost(port, token, '/client/register', replacement);
591
+ await delay(70);
592
+ const replayed = await rawPost(port, token, '/client/register', replacement);
593
+ await delay(60); // Past the original deadline, before the refreshed deadline.
594
+ const replayRefreshed = replayed.token === fresh.token &&
595
+ transport._clientsForTest.has(fresh.token) &&
596
+ transport._registrationReplaysForTest.size === 1;
597
+ await delay(70); // Full refreshed interval elapsed.
598
+ const freshSurvivedRefreshExpiry = transport._clientsForTest.has(fresh.token) &&
599
+ transport._registrationReplaysForTest.size === 0;
600
+ await rawPost(port, token, '/client/deregister', { token: fresh.token });
601
+ check('reconnect: replay TTL preserves flushed clients and refreshes near-expiry replay metadata',
602
+ validSurvived && replayRefreshed && freshSurvivedRefreshExpiry);
603
+ try { a.kill(); } catch {}
604
+ try { b.kill(); } catch {}
605
+ await transport.stop();
606
+ }
607
+
608
+ // Every explicit retirement, replacement, deregister, and stop must clear the
609
+ // associated replay entry/timer; no metadata may target an absent token.
610
+ async function registrationReplayCleanupTest() {
611
+ const transport = createChannelDaemonTransport({
612
+ handleCall: async () => ({ ok: true }),
613
+ clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 1_000,
614
+ onClientsEmpty: () => {},
615
+ });
616
+ const { port, token } = await transport.start();
617
+ const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
618
+ await delay(80);
619
+ const old = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
620
+ const fresh1 = await rawPost(port, token, '/client/register', {
621
+ leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: old.token, registrationId: 'cleanup-1',
622
+ });
623
+ const fresh2 = await rawPost(port, token, '/client/register', {
624
+ leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: fresh1.token, registrationId: 'cleanup-2',
625
+ });
626
+ const replacementClean = !transport._clientsForTest.has(fresh1.token) &&
627
+ transport._registrationReplaysForTest.size === 1;
628
+ await rawPost(port, token, '/client/deregister', { token: fresh2.token });
629
+ const deregisterClean = transport._registrationReplaysForTest.size === 0;
630
+ const old3 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A3' });
631
+ await rawPost(port, token, '/client/register', {
632
+ leadPid: a.pid, cwd: 'A3', reattach: true, replaceToken: old3.token, registrationId: 'cleanup-stop',
633
+ });
634
+ await transport.stop();
635
+ check('reconnect: replacement, deregister, and stop clear all replay metadata',
636
+ replacementClean && deregisterClean &&
637
+ transport._clientsForTest.size === 0 && transport._registrationReplaysForTest.size === 0);
638
+ try { a.kill(); } catch {}
639
+ }
640
+
641
+ // Client-only SSE races: immediate 200/end cycles consume the bounded budget;
642
+ // a late old response cannot start another token rotation; close waits for a
643
+ // delayed reconnect registration and removes its fresh token.
644
+ async function clientReconnectSafetyTest() {
645
+ let registerCount = 0;
646
+ let fatalReason = null;
647
+ const flapping = await startTestServer(async (req, res) => {
648
+ const url = new URL(req.url, 'http://127.0.0.1');
649
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
650
+ if (url.pathname === '/client/register') {
651
+ await readJsonBody(req);
652
+ registerCount++;
653
+ return sendTestJson(res, 200, { token: `flap-${registerCount}`, pid: process.pid });
654
+ }
655
+ if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
656
+ if (url.pathname === '/events') {
657
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
658
+ res.write(': attached\n\n');
659
+ setTimeout(() => res.end(), 10);
660
+ return;
661
+ }
662
+ sendTestJson(res, 404, { error: 'not found' });
663
+ });
664
+ const flapClient = await attachToDaemon({
665
+ discovery: { port: testPort(flapping), token: 'flap', pid: process.pid },
666
+ onFatal: (reason) => { fatalReason = reason; },
667
+ });
668
+ await waitFor(() => fatalReason !== null, 8_000);
669
+ check('client: repeated 200/end SSE reconnects are bounded',
670
+ fatalReason !== null && registerCount === 6);
671
+ await flapClient.close();
672
+ await stopTestServer(flapping);
673
+
674
+ let lateRegisters = 0;
675
+ const lateServer = await startTestServer(async (req, res) => {
676
+ const url = new URL(req.url, 'http://127.0.0.1');
677
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
678
+ if (url.pathname === '/client/register') {
679
+ await readJsonBody(req);
680
+ lateRegisters++;
681
+ return sendTestJson(res, 200, { token: `late-${lateRegisters}`, pid: process.pid });
682
+ }
683
+ if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
684
+ if (url.pathname === '/events') return sendTestJson(res, 500, { error: 'test SSE should be intercepted' });
685
+ sendTestJson(res, 404, { error: 'not found' });
686
+ });
687
+ const realHttpRequest = http.request;
688
+ const fakeStreams = [];
689
+ http.request = function interceptedSseRequest(options, callback) {
690
+ if (!String(options?.path || '').startsWith('/events')) {
691
+ return realHttpRequest.apply(this, arguments);
692
+ }
693
+ const fakeReq = new EventEmitter();
694
+ fakeReq.destroy = () => {};
695
+ fakeReq.end = () => {
696
+ const fakeRes = new EventEmitter();
697
+ fakeRes.statusCode = 200;
698
+ fakeRes.setEncoding = () => {};
699
+ fakeRes.resume = () => {};
700
+ fakeStreams.push({ req: fakeReq, res: fakeRes });
701
+ queueMicrotask(() => callback(fakeRes));
702
+ };
703
+ return fakeReq;
704
+ };
705
+ let lateClient = null;
706
+ try {
707
+ lateClient = await attachToDaemon({ discovery: { port: testPort(lateServer), token: 'late', pid: process.pid } });
708
+ await waitFor(() => fakeStreams.length === 1, 500);
709
+ fakeStreams[0].res.emit('end');
710
+ await waitFor(() => lateRegisters === 2 && fakeStreams.length === 2, 2_500);
711
+ fakeStreams[0].res.emit('error', new Error('late old SSE error'));
712
+ await delay(100);
713
+ check('client: late old-SSE events cannot rotate a newer token', lateRegisters === 2);
714
+ } finally {
715
+ http.request = realHttpRequest;
716
+ await lateClient?.close();
717
+ }
718
+ await stopTestServer(lateServer);
719
+
720
+ let closeRegisters = 0;
721
+ let delayedRegisterResponse = null;
722
+ const liveTokens = new Set();
723
+ const closeServer = await startTestServer(async (req, res) => {
724
+ const url = new URL(req.url, 'http://127.0.0.1');
725
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
726
+ if (url.pathname === '/client/register') {
727
+ await readJsonBody(req);
728
+ closeRegisters++;
729
+ const fresh = `close-${closeRegisters}`;
730
+ liveTokens.add(fresh);
731
+ if (closeRegisters === 2) { delayedRegisterResponse = { res, fresh }; return; }
732
+ return sendTestJson(res, 200, { token: fresh, pid: process.pid });
733
+ }
734
+ if (url.pathname === '/client/deregister') {
735
+ const body = await readJsonBody(req);
736
+ liveTokens.delete(body.token);
737
+ return sendTestJson(res, 200, { ok: true });
738
+ }
739
+ if (url.pathname === '/events') {
740
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
741
+ res.write(': attached\n\n');
742
+ if (closeRegisters === 1) setTimeout(() => res.end(), 10);
743
+ return;
744
+ }
745
+ sendTestJson(res, 404, { error: 'not found' });
746
+ });
747
+ const closeClient = await attachToDaemon({ discovery: { port: testPort(closeServer), token: 'close', pid: process.pid } });
748
+ await waitFor(() => delayedRegisterResponse !== null, 2_500);
749
+ const closing = closeClient.close('close during register');
750
+ await delay(20);
751
+ sendTestJson(delayedRegisterResponse.res, 200, { token: delayedRegisterResponse.fresh, pid: process.pid });
752
+ await closing;
753
+ check('client: close during re-register removes the fresh token',
754
+ !liveTokens.has(delayedRegisterResponse.fresh) && liveTokens.size === 0);
755
+ await stopTestServer(closeServer);
756
+ }
757
+
758
+ // End-to-end client path: the daemon commits replacement, but a proxy loses the
759
+ // register response after headers/partial JSON. request() must settle, and close
760
+ // must use the retained logical-registration identity to cancel the unknown
761
+ // fresh transport client before any later reconnect can revive it.
762
+ async function clientTruncatedReplacementCloseTest() {
763
+ let cancellationObserved = false;
764
+ const transport = createChannelDaemonTransport({
765
+ handleCall: async () => ({ ok: true }),
766
+ clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
767
+ log: (line) => { if (String(line).includes('replacement deregister')) cancellationObserved = true; },
768
+ });
769
+ const { port, token } = await transport.start();
770
+ const client = await attachToDaemon({ discovery: { port, token, pid: process.pid }, cwd: 'truncated-client' });
771
+ const initialToken = client.clientToken;
772
+ const realHttpRequest = http.request;
773
+ let replacementRequests = 0;
774
+ let truncated = false;
775
+ http.request = function truncateCommittedRegister(options, callback) {
776
+ if (!String(options?.path || '').startsWith('/client/register')) {
777
+ return realHttpRequest.apply(this, arguments);
778
+ }
779
+ replacementRequests++;
780
+ const fakeReq = new EventEmitter();
781
+ let payload = null;
782
+ let inner = null;
783
+ fakeReq.write = (chunk) => { payload = Buffer.from(chunk); };
784
+ fakeReq.destroy = (error) => { try { inner?.destroy?.(error); } catch {} };
785
+ fakeReq.end = () => {
786
+ inner = realHttpRequest(options, (realRes) => {
787
+ realRes.resume();
788
+ realRes.once('end', () => {
789
+ const fakeRes = new EventEmitter();
790
+ fakeRes.statusCode = 200;
791
+ fakeRes.setEncoding = () => {};
792
+ fakeRes.resume = () => {};
793
+ fakeRes.destroy = () => {};
794
+ callback(fakeRes);
795
+ fakeRes.emit('data', '{"token":"committed-but-truncated');
796
+ truncated = true;
797
+ queueMicrotask(() => {
798
+ fakeRes.emit('aborted');
799
+ fakeRes.emit('close');
800
+ });
801
+ });
802
+ });
803
+ inner.on('error', (error) => fakeReq.emit('error', error));
804
+ if (payload) inner.write(payload);
805
+ inner.end();
806
+ };
807
+ return fakeReq;
808
+ };
809
+ try {
810
+ await waitFor(() => !!transport._clientsForTest.get(initialToken)?.sse, 1_000);
811
+ transport._clientsForTest.get(initialToken)?.sse?.end();
812
+ await waitFor(() => truncated && replacementRequests === 1 && transport._clientsForTest.size === 1, 2_500);
813
+ const closeStarted = Date.now();
814
+ await client.close('truncated replacement response');
815
+ const closeElapsed = Date.now() - closeStarted;
816
+ await delay(1_200);
817
+ check('client: truncated committed replacement settles and close cancels unknown fresh client',
818
+ closeElapsed < 1_000 &&
819
+ cancellationObserved &&
820
+ replacementRequests === 1 &&
821
+ transport._clientsForTest.size === 0 &&
822
+ transport._registrationReplaysForTest.size === 0 &&
823
+ transport._resolveTargetForTest() === null);
824
+ } finally {
825
+ http.request = realHttpRequest;
826
+ await client.close('truncated replacement cleanup');
827
+ await transport.stop();
828
+ }
829
+ }
830
+
831
+ // Worker-only attach races: auth rejection re-reads discovery instead of
832
+ // rejecting start(), a wrong but alive discovery PID is refused, and stop()
833
+ // waits for an in-flight register so it cannot publish a client afterward.
834
+ async function workerAttachSafetyTest() {
835
+ const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
836
+
837
+ const wrongPidServer = await startTestServer((req, res) => {
838
+ if (new URL(req.url, 'http://127.0.0.1').pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
839
+ sendTestJson(res, 404, { error: 'not found' });
840
+ });
841
+ let wrongPidRejected = false;
842
+ try {
843
+ await attachToDaemon({ discovery: { port: testPort(wrongPidServer), token: 'wrong-pid', pid: process.pid + 100_000 } });
844
+ } catch (err) { wrongPidRejected = err?.daemonDiscoveryStale === true; }
845
+ check('client: alive but wrong discovery PID is rejected', wrongPidRejected);
846
+ await stopTestServer(wrongPidServer);
847
+
848
+ const authTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-attach-'));
849
+ const authDiscovery = path.join(authTmp, 'channel-daemon.json');
850
+ const goodToken = 'good-discovery-token';
851
+ let rejectedRegisters = 0;
852
+ const authServer = await startTestServer(async (req, res) => {
853
+ const url = new URL(req.url, 'http://127.0.0.1');
854
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
855
+ if (url.pathname === '/client/register') {
856
+ await readJsonBody(req);
857
+ if (req.headers['x-mixdog-daemon-token'] !== goodToken) {
858
+ rejectedRegisters++;
859
+ return sendTestJson(res, 403, { error: 'forbidden' });
860
+ }
861
+ return sendTestJson(res, 200, { token: 'auth-client', pid: process.pid });
862
+ }
863
+ if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
864
+ if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
865
+ sendTestJson(res, 404, { error: 'not found' });
866
+ });
867
+ const authPort = testPort(authServer);
868
+ writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: 'stale-discovery-token' }));
869
+ process.env.MIXDOG_RUNTIME_ROOT = authTmp;
870
+ process.env.MIXDOG_DATA_DIR = authTmp;
871
+ process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
872
+ const authWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: authTmp, dataDir: authTmp, cwd: authTmp });
873
+ const publishGoodDiscovery = setTimeout(() => {
874
+ writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: goodToken }));
875
+ }, 250);
876
+ await authWorker.start();
877
+ clearTimeout(publishGoodDiscovery);
878
+ check('worker: initial register 403 re-reads discovery and attaches', rejectedRegisters > 0);
879
+ await authWorker.stop();
880
+ await stopTestServer(authServer);
881
+ try { rmSync(authTmp, { recursive: true, force: true }); } catch {}
882
+
883
+ const permanentTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-permanent-'));
884
+ const permanentDiscovery = path.join(permanentTmp, 'channel-daemon.json');
885
+ let permanentRejects = 0;
886
+ const permanentServer = await startTestServer(async (req, res) => {
887
+ const url = new URL(req.url, 'http://127.0.0.1');
888
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
889
+ if (url.pathname === '/client/register') {
890
+ await readJsonBody(req);
891
+ permanentRejects++;
892
+ return sendTestJson(res, 403, { error: 'forbidden' });
893
+ }
894
+ sendTestJson(res, 404, { error: 'not found' });
895
+ });
896
+ writeFileSync(permanentDiscovery, JSON.stringify({
897
+ pid: process.pid, port: testPort(permanentServer), token: 'permanently-stale-token',
898
+ }));
899
+ process.env.MIXDOG_RUNTIME_ROOT = permanentTmp;
900
+ process.env.MIXDOG_DATA_DIR = permanentTmp;
901
+ const permanentWorker = createStandaloneChannelWorker({
902
+ entry: STUB_ENTRY, rootDir: permanentTmp, dataDir: permanentTmp, cwd: permanentTmp,
903
+ });
904
+ const permanentStartedAt = Date.now();
905
+ let permanentRejected = false;
906
+ try { await permanentWorker.start(); } catch (err) {
907
+ permanentRejected = /register rejected discovery auth 5 times/.test(err?.message || '');
908
+ }
909
+ const permanentElapsed = Date.now() - permanentStartedAt;
910
+ check('worker: permanent register 403 uses bounded backoff and terminates',
911
+ permanentRejected && permanentRejects === 5 && permanentElapsed >= 1000 && permanentElapsed < 6000);
912
+ await permanentWorker.stop();
913
+ await stopTestServer(permanentServer);
914
+ try { rmSync(permanentTmp, { recursive: true, force: true }); } catch {}
915
+
916
+ const stopTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-stop-attach-'));
917
+ const stopDiscovery = path.join(stopTmp, 'channel-daemon.json');
918
+ let pendingRegister = null;
919
+ const deregistered = new Set();
920
+ const stopServer = await startTestServer(async (req, res) => {
921
+ const url = new URL(req.url, 'http://127.0.0.1');
922
+ if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
923
+ if (url.pathname === '/client/register') { await readJsonBody(req); pendingRegister = res; return; }
924
+ if (url.pathname === '/client/deregister') {
925
+ const body = await readJsonBody(req);
926
+ deregistered.add(body.token);
927
+ return sendTestJson(res, 200, { ok: true });
928
+ }
929
+ if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
930
+ sendTestJson(res, 404, { error: 'not found' });
931
+ });
932
+ const stopPort = testPort(stopServer);
933
+ writeFileSync(stopDiscovery, JSON.stringify({ pid: process.pid, port: stopPort, token: 'stop-token' }));
934
+ process.env.MIXDOG_RUNTIME_ROOT = stopTmp;
935
+ process.env.MIXDOG_DATA_DIR = stopTmp;
936
+ const stopWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: stopTmp, dataDir: stopTmp, cwd: stopTmp });
937
+ const startResult = stopWorker.start().then(() => 'started').catch(() => 'cancelled');
938
+ await waitFor(() => pendingRegister !== null, 1_500);
939
+ const stopping = stopWorker.stop('stop during attach');
940
+ sendTestJson(pendingRegister, 200, { token: 'stop-fresh', pid: process.pid });
941
+ const [stopped, startState] = await Promise.all([stopping, startResult]);
942
+ check('worker: stop during attach cannot publish a client',
943
+ stopped === false && startState === 'cancelled' && deregistered.has('stop-fresh'));
944
+ await stopTestServer(stopServer);
945
+ try { rmSync(stopTmp, { recursive: true, force: true }); } catch {}
946
+ }
947
+
328
948
  // Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
329
949
  // (2) a targeted 'superseded' emitted while the displaced client has no live
330
950
  // stream is buffered and flushed when its stream (re)attaches.