mixdog 0.9.17 → 0.9.19

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 (129) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/output-style-smoke.mjs +2 -2
  4. package/scripts/recall-bench-cases.json +11 -0
  5. package/scripts/recall-bench.mjs +91 -2
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/tool-efficiency-diag.mjs +5 -2
  9. package/scripts/tool-smoke.mjs +101 -27
  10. package/src/agents/debugger/AGENT.md +3 -3
  11. package/src/agents/heavy-worker/AGENT.md +7 -10
  12. package/src/agents/maintainer/AGENT.md +1 -2
  13. package/src/agents/reviewer/AGENT.md +1 -2
  14. package/src/agents/worker/AGENT.md +5 -8
  15. package/src/defaults/agents.json +3 -0
  16. package/src/mixdog-session-runtime.mjs +23 -6
  17. package/src/rules/agent/00-core.md +4 -3
  18. package/src/rules/agent/30-explorer.md +53 -22
  19. package/src/rules/lead/02-channels.md +3 -3
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +24 -29
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  25. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  28. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  35. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  36. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  39. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  40. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  41. package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
  42. package/src/runtime/channels/backends/discord.mjs +139 -7
  43. package/src/runtime/channels/index.mjs +290 -76
  44. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  45. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  46. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  47. package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
  48. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  49. package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
  50. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  51. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  52. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  53. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  54. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  55. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  56. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  57. package/src/runtime/shared/atomic-file.mjs +10 -4
  58. package/src/runtime/shared/background-tasks.mjs +4 -2
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  61. package/src/runtime/shared/tool-surface.mjs +30 -1
  62. package/src/session-runtime/config-lifecycle.mjs +1 -1
  63. package/src/session-runtime/cwd-plugins.mjs +46 -3
  64. package/src/session-runtime/mcp-glue.mjs +24 -3
  65. package/src/session-runtime/output-styles.mjs +44 -10
  66. package/src/session-runtime/workflow.mjs +16 -1
  67. package/src/standalone/channel-worker.mjs +88 -7
  68. package/src/standalone/explore-tool.mjs +1 -1
  69. package/src/tui/App.jsx +57 -77
  70. package/src/tui/app/channel-pickers.mjs +45 -0
  71. package/src/tui/app/slash-commands.mjs +0 -1
  72. package/src/tui/app/slash-dispatch.mjs +0 -16
  73. package/src/tui/app/transcript-window.mjs +66 -1
  74. package/src/tui/app/use-mouse-input.mjs +9 -2
  75. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  76. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  77. package/src/tui/app/use-transcript-window.mjs +74 -8
  78. package/src/tui/components/PromptInput.jsx +33 -64
  79. package/src/tui/components/ToolExecution.jsx +2 -2
  80. package/src/tui/dist/index.mjs +4744 -4806
  81. package/src/tui/engine.mjs +109 -20
  82. package/src/tui/lib/voice-setup.mjs +166 -0
  83. package/src/tui/paste-attachments.mjs +12 -5
  84. package/src/tui/prompt-history-store.mjs +125 -12
  85. package/scripts/bench/cache-probe-tasks.json +0 -8
  86. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  87. package/scripts/bench/lead-review-tasks.json +0 -20
  88. package/scripts/bench/r4-mixed-tasks.json +0 -20
  89. package/scripts/bench/r5-orchestrated-task.json +0 -7
  90. package/scripts/bench/review-tasks.json +0 -20
  91. package/scripts/bench/round-codex.json +0 -114
  92. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  93. package/scripts/bench/round-mixdog-lead.json +0 -269
  94. package/scripts/bench/round-mixdog.json +0 -126
  95. package/scripts/bench/round-r10-bigsample.json +0 -679
  96. package/scripts/bench/round-r11-codexalign.json +0 -257
  97. package/scripts/bench/round-r13-clientmeta.json +0 -464
  98. package/scripts/bench/round-r14-betafeatures.json +0 -466
  99. package/scripts/bench/round-r15-fulldefault.json +0 -462
  100. package/scripts/bench/round-r16-sessionid.json +0 -466
  101. package/scripts/bench/round-r17-wirebytes.json +0 -456
  102. package/scripts/bench/round-r18-prewarm.json +0 -468
  103. package/scripts/bench/round-r19-clean.json +0 -472
  104. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  105. package/scripts/bench/round-r21-delta-retry.json +0 -473
  106. package/scripts/bench/round-r22-full-probe.json +0 -693
  107. package/scripts/bench/round-r23-itemprobe.json +0 -701
  108. package/scripts/bench/round-r24-shapefix.json +0 -677
  109. package/scripts/bench/round-r25-serial.json +0 -464
  110. package/scripts/bench/round-r26-parallel3.json +0 -671
  111. package/scripts/bench/round-r27-parallel10.json +0 -894
  112. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  113. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  114. package/scripts/bench/round-r30-instid.json +0 -253
  115. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  116. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  117. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  118. package/scripts/bench/round-r34-orchestrated.json +0 -120
  119. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  120. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  121. package/scripts/bench/round-r4-codex.json +0 -114
  122. package/scripts/bench/round-r4-mixed.json +0 -225
  123. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  124. package/scripts/bench/round-r6-codex.json +0 -114
  125. package/scripts/bench/round-r6-solo.json +0 -257
  126. package/scripts/bench/round-r7-full.json +0 -254
  127. package/scripts/bench/round-r8-fulldefault.json +0 -255
  128. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  129. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -42,6 +42,7 @@ import {
42
42
  readActiveInstance,
43
43
  refreshActiveInstance,
44
44
  cleanupStaleRuntimeFiles,
45
+ probeActiveOwner,
45
46
  cleanupInstanceRuntimeFiles,
46
47
  releaseOwnedChannelLocks,
47
48
  clearActiveInstance,
@@ -305,6 +306,11 @@ const forwarder = new OutputForwarder({
305
306
  removeReaction: (ch, mid, emoji) => {
306
307
  if (!channelBridgeActive) return Promise.resolve();
307
308
  return backend.removeReaction(ch, mid, emoji);
309
+ },
310
+ // Watchdog backstop: force the backend to tear down + rebuild a wedged
311
+ // client so an over-budget send fails fast and the queue releases.
312
+ resetBackend: async () => {
313
+ if (typeof backend?._resetClient === "function") await backend._resetClient();
308
314
  }
309
315
  }, statusState);
310
316
  forwarder.setOnIdle(() => {
@@ -323,7 +329,11 @@ forwarder.setOnIdle(() => {
323
329
  forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
324
330
  function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
325
331
  if (!transcriptPath) return;
326
- forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
332
+ forwarder.setContext(channelId, transcriptPath, {
333
+ replayFromStart: options.replayFromStart,
334
+ catchUpFromPersisted: options.catchUpFromPersisted,
335
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
336
+ });
327
337
  const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
328
338
  forwarder.startWatch();
329
339
  void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
@@ -336,14 +346,31 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
336
346
  refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
337
347
  if (options.persistStatus !== false) {
338
348
  statusState.update((state) => {
349
+ const wasSameTranscript = typeof state.transcriptPath === "string"
350
+ && state.transcriptPath
351
+ && sameResolvedPath(state.transcriptPath, boundTranscriptPath);
352
+ const prevSentCount = Number(state.sentCount ?? 0);
353
+ const nextSentCount = Number(forwarder.sentCount ?? 0);
339
354
  state.channelId = channelId;
340
355
  state.transcriptPath = boundTranscriptPath;
341
356
  state.lastFileSize = forwarder.lastFileSize;
342
- state.sentCount = forwarder.sentCount;
343
- state.lastSentHash = forwarder.lastHash;
344
- state.lastSentTime = 0;
345
- state.sessionIdle = false;
346
- state.sessionCwd = options.cwd ?? null;
357
+ if (wasSameTranscript) {
358
+ state.sentCount = Math.max(
359
+ Number.isFinite(prevSentCount) ? prevSentCount : 0,
360
+ Number.isFinite(nextSentCount) ? nextSentCount : 0,
361
+ );
362
+ if (forwarder.lastHash) state.lastSentHash = forwarder.lastHash;
363
+ else if (typeof state.lastSentHash !== "string") state.lastSentHash = "";
364
+ if (!Number.isFinite(Number(state.lastSentTime))) state.lastSentTime = 0;
365
+ if (typeof state.sessionIdle !== "boolean") state.sessionIdle = false;
366
+ } else {
367
+ state.sentCount = forwarder.sentCount;
368
+ state.lastSentHash = forwarder.lastHash;
369
+ state.lastSentTime = 0;
370
+ state.sessionIdle = false;
371
+ }
372
+ if (options.cwd !== undefined) state.sessionCwd = options.cwd ?? null;
373
+ else if (!wasSameTranscript) state.sessionCwd = null;
347
374
  });
348
375
  }
349
376
  }
@@ -421,7 +448,8 @@ async function rebindTranscriptContext(channelId, options = {}) {
421
448
  applyTranscriptBinding(channelId, explicitTranscriptPath, {
422
449
  replayFromStart: Boolean(options.catchUp),
423
450
  catchUpFromPersisted: options.catchUpFromPersisted,
424
- persistStatus: options.persistStatus
451
+ persistStatus: options.persistStatus,
452
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
425
453
  });
426
454
  if (options.catchUp || options.catchUpFromPersisted) {
427
455
  await forwarder.forwardNewText();
@@ -452,6 +480,7 @@ async function rebindTranscriptContext(channelId, options = {}) {
452
480
  applyTranscriptBinding(channelId, bound.transcriptPath, {
453
481
  replayFromStart,
454
482
  catchUpFromPersisted: options.catchUpFromPersisted,
483
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
455
484
  persistStatus: options.persistStatus,
456
485
  cwd: bound.sessionCwd,
457
486
  });
@@ -497,6 +526,7 @@ async function rebindTranscriptContext(channelId, options = {}) {
497
526
  applyTranscriptBinding(channelId, pendingTranscriptPath, {
498
527
  replayFromStart: !samePersisted,
499
528
  catchUpFromPersisted: samePersisted,
529
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
500
530
  cwd: pendingTranscriptCwd,
501
531
  persistStatus: options.persistStatus,
502
532
  });
@@ -512,7 +542,11 @@ async function rebindTranscriptContext(channelId, options = {}) {
512
542
  }
513
543
  }
514
544
  if (previousPath && options.fallbackPrevious !== false) {
515
- applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
545
+ applyTranscriptBinding(channelId, previousPath, {
546
+ catchUpFromPersisted: true,
547
+ recoverUnsyncedTail: options.recoverUnsyncedTail,
548
+ cwd: statusState.read().sessionCwd
549
+ });
516
550
  if (fs.existsSync(previousPath)) {
517
551
  await forwarder.forwardNewText();
518
552
  } else {
@@ -631,7 +665,8 @@ async function bindPersistedTranscriptIfAny() {
631
665
  const bound = await rebindTranscriptContext(currentStatus.channelId, {
632
666
  previousPath: getPersistedTranscriptPath(),
633
667
  persistStatus: true,
634
- catchUpFromPersisted: true
668
+ catchUpFromPersisted: true,
669
+ recoverUnsyncedTail: true,
635
670
  });
636
671
  if (bound) {
637
672
  process.stderr.write(`mixdog: initial transcript bind: ${bound}
@@ -710,8 +745,40 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
710
745
  webhookServer = null;
711
746
  }
712
747
  }
748
+ let _ownedRuntimeSelfHealing = false;
749
+ // Explicit restore path for an already-connected owner. The 3s ownership tick
750
+ // must not run this implicitly: re-binding status on every tick can move the
751
+ // transcript cursor to EOF, and probing the gateway during Discord's own
752
+ // reconnect window can reset a healthy reconnect loop. Keep this for explicit
753
+ // activation/reload recovery only.
754
+ async function selfHealOwnedRuntime(options = {}) {
755
+ const shouldRestoreBinding = options.restoreBinding === true;
756
+ const shouldResetBackend = options.resetBackend === true;
757
+ if (!shouldRestoreBinding && !shouldResetBackend) return;
758
+ if (_ownedRuntimeSelfHealing) return;
759
+ _ownedRuntimeSelfHealing = true;
760
+ try {
761
+ if (shouldRestoreBinding) {
762
+ await bindPersistedTranscriptIfAny().catch((e) => {
763
+ process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
764
+ });
765
+ }
766
+ if (shouldResetBackend && typeof backend?._resetClient === "function") {
767
+ await backend._resetClient().catch((e) => {
768
+ process.stderr.write(`mixdog: self-heal backend reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
769
+ });
770
+ }
771
+ // Nudge the forwarder to drain anything the rebind/reconnect surfaced.
772
+ void forwarder.forwardNewText().catch(() => {});
773
+ } finally {
774
+ _ownedRuntimeSelfHealing = false;
775
+ }
776
+ }
713
777
  async function startOwnedRuntime(options = {}) {
714
- if (bridgeRuntimeConnected) return;
778
+ if (bridgeRuntimeConnected) {
779
+ if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
780
+ return;
781
+ }
715
782
  if (bridgeRuntimeStarting) return;
716
783
  if (!channelBridgeActive) return;
717
784
  bridgeRuntimeStarting = true;
@@ -723,23 +790,54 @@ async function startOwnedRuntime(options = {}) {
723
790
  // backend WE started (not the freshly-swapped one), closing the
724
791
  // both-backends-live window.
725
792
  const startingBackend = backend;
726
- // Advertise active-instance.json BEFORE backend connect so a newer remote
727
- // session's last-wins claim is visible immediately. backendReady=false
728
- // marks the partial state until backend.connect() succeeds.
729
- // onlyIfOwned: startOwnedRuntime is only entered on the owned path, but a
730
- // newer session can claim the seat between that check and this write; CAS
731
- // aborts instead of re-stealing. The write result is checked below so a
732
- // CAS abort stops the start path immediately (heartbeat/backend/scheduler
733
- // never start) instead of relying on the 3s ownership timer to notice.
734
- const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true });
735
- // A successful CAS write always sets instanceId to ours; any other result
736
- // (aborted write returning the stale/foreign/missing prior state) means
737
- // the seat was not ours to claim abort the start path here rather than
738
- // relying on the 3s ownership timer to notice later.
739
- if (casResult?.instanceId !== INSTANCE_ID) {
793
+ const claimAfterReady = options.claimAfterReady === true;
794
+ // Single-holder correctness: the seat is claimed BEFORE backend.connect(),
795
+ // never after. The old make-before-break (claim-after-ready) boot left two
796
+ // gateways connected and contending during the multi-second connect window;
797
+ // claiming first makes the previous owner observe owned=false on its next
798
+ // tick and drain+disconnect, so at most one gateway ever serves.
799
+ // - boot (claimAfterReady): last-wins acquire this new remote session
800
+ // takes the seat outright.
801
+ // - refresh/owned-path: CAS onlyIfOwned abort fast if the seat moved.
802
+ // backendReady=false marks the partial state until backend.connect() succeeds.
803
+ // Wrapped so ANY throw in the pre-connect claim (lock contention/error)
804
+ // resets bridgeRuntimeStartingotherwise a transient lock error would
805
+ // leave it stuck true and permanently block every future ownership attempt.
806
+ try {
807
+ if (claimAfterReady) {
808
+ refreshActiveInstance(INSTANCE_ID, { backendReady: false });
809
+ logOwnership("boot claim (pre-connect, last-wins)");
810
+ } else {
811
+ // Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
812
+ // the active-instance lock. Contention throws → caught below and treated
813
+ // as "seat busy, abort this attempt"; the next 3s tick retries.
814
+ const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
815
+ // A successful CAS write always sets instanceId to ours; any other result
816
+ // (aborted write returning the stale/foreign/missing prior state) means the
817
+ // seat was not ours to claim — abort here rather than relying on the timer.
818
+ if (casResult?.instanceId !== INSTANCE_ID) {
819
+ bridgeRuntimeStarting = false;
820
+ return;
821
+ }
822
+ }
823
+ // A newer session can claim between our write and this read. If we no longer
824
+ // own the seat, abort fast WITHOUT starting heartbeat/backend/scheduler/
825
+ // webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
826
+ if (!currentOwnerState().owned) {
827
+ bridgeRuntimeStarting = false;
828
+ return;
829
+ }
830
+ } catch (e) {
740
831
  bridgeRuntimeStarting = false;
832
+ process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
741
833
  return;
742
834
  }
835
+ // Arm ownership-loss detection BEFORE backend.connect() so a session that is
836
+ // superseded during the (multi-second) connect window is torn down: the 3s
837
+ // tick observes a newer owner and flips _ownedRuntimeStopRequested, and
838
+ // bailIfStopRequested below disconnects the backend WE connected.
839
+ armBridgeOwnershipTimer();
840
+ // Heartbeat is ownership-gated; safe to arm now that we hold the seat.
743
841
  startOwnerHeartbeat();
744
842
  // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
745
843
  // memory service publishes its port. Idempotent + reentrancy-guarded inside
@@ -783,12 +881,34 @@ async function startOwnedRuntime(options = {}) {
783
881
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
784
882
  return;
785
883
  }
884
+ // Post-connect ownership confirm (CAS). The seat was claimed BEFORE
885
+ // connect; a newer session may have superseded us during the connect
886
+ // window (the 3s tick would already be tearing us down). onlyIfOwned:
887
+ // never re-steal here. If the CAS aborts we LOST the seat — disconnect the
888
+ // backend WE connected and mark the bridge runtime not connected (do NOT
889
+ // clear active-instance; the newer owner holds it).
890
+ let ownConfirm;
891
+ try {
892
+ ownConfirm = refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
893
+ } catch { ownConfirm = null; }
894
+ if (ownConfirm?.instanceId !== INSTANCE_ID) {
895
+ try { await startingBackend.disconnect(); } catch {}
896
+ try { stopOwnerHeartbeat(); } catch {}
897
+ try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
898
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
899
+ bridgeRuntimeConnected = false;
900
+ cancelPendingTranscriptRearm();
901
+ try { forwarder.stopWatch(); } catch {}
902
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
903
+ notifyRemoteSuperseded();
904
+ return;
905
+ }
786
906
  bridgeRuntimeConnected = true;
787
- // onlyIfOwned: backend.connect() above can take seconds — a newer owner
788
- // claiming the seat during that await must not be overwritten here. On
789
- // CAS abort the next refreshBridgeOwnership tick observes owned=false
790
- // and stops this runtime; backendReady stays unset for the lost seat.
791
- refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
907
+ if (claimAfterReady) {
908
+ // First confirmed ownership at boot tell the parent it holds the seat
909
+ // exactly once (heartbeat/refresh re-pin ownership but never re-enter).
910
+ notifyRemoteAcquired();
911
+ }
792
912
  // initProviders must complete before scheduler.start() — otherwise the
793
913
  // scheduler's first fire can land before the registry is populated and
794
914
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -872,7 +992,14 @@ async function stopOwnedRuntime(reason) {
872
992
  try {
873
993
  // Only disconnect the backend when connect() actually completed; calling
874
994
  // disconnect() mid-connect races the connect promise.
875
- if (wasConnected) await backend.disconnect();
995
+ if (wasConnected) {
996
+ // Drain in-flight outbound sends before disconnecting so a handoff
997
+ // (owned=false observed → ownership lost) never cuts off a reply
998
+ // mid-delivery. Bounded inside drainPendingSends so a wedged send can
999
+ // not stall teardown — we still disconnect promptly.
1000
+ try { await backend.drainPendingSends?.(); } catch {}
1001
+ await backend.disconnect();
1002
+ }
876
1003
  } finally {
877
1004
  bridgeRuntimeConnected = false;
878
1005
  logOwnership(`standby: ${reason}`);
@@ -881,6 +1008,16 @@ async function stopOwnedRuntime(reason) {
881
1008
  function refreshBridgeOwnershipSafe(options = {}) {
882
1009
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
883
1010
  }
1011
+ // Ownership-loss / re-acquire detection timer. Armed BEFORE backend.connect()
1012
+ // (from startOwnedRuntime) so a session superseded during the connect window is
1013
+ // torn down promptly, and re-armed idempotently on every start path.
1014
+ function armBridgeOwnershipTimer() {
1015
+ if (bridgeOwnershipTimer) return;
1016
+ bridgeOwnershipTimer = setInterval(() => {
1017
+ refreshBridgeOwnershipSafe();
1018
+ }, 3e3);
1019
+ bridgeOwnershipTimer.unref?.();
1020
+ }
884
1021
  // Tell the parent session that this worker LOST the bridge seat to a newer
885
1022
  // remote session (last-wins). The parent flips its remote mode OFF entirely —
886
1023
  // exactly one session holds remote; losers fully release, no handover.
@@ -890,6 +1027,18 @@ function notifyRemoteSuperseded() {
890
1027
  process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
891
1028
  } catch {}
892
1029
  }
1030
+ // Symmetric to notifyRemoteSuperseded: tell the parent session this worker
1031
+ // ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
1032
+ // Guarded by process.send so a manually-forked worker with no IPC parent is a
1033
+ // no-op instead of crashing. Callers fire this only on a genuine claim
1034
+ // transition (boot make-before-break, activate when not already owned) — never
1035
+ // on a heartbeat refresh — so the parent's idempotent handler sees it once.
1036
+ function notifyRemoteAcquired() {
1037
+ if (!process.send) return;
1038
+ try {
1039
+ process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
1040
+ } catch {}
1041
+ }
893
1042
  async function refreshBridgeOwnership(options = {}) {
894
1043
  // Coalesce concurrent callers onto the in-flight refresh so backend tool
895
1044
  // calls landing during normal login wait for the same connect attempt
@@ -906,35 +1055,44 @@ async function refreshBridgeOwnership(options = {}) {
906
1055
  if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
907
1056
  return;
908
1057
  }
909
- const { active, owned } = currentOwnerState();
1058
+ // Non-blocking ownership probe (read-only, no lock): distinguishes a live
1059
+ // owner from a genuinely empty/stale seat from an INDETERMINATE read (a
1060
+ // concurrent atomic rename yields partial content). "Locked/unreadable =
1061
+ // busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
1062
+ // never claim a seat we merely failed to read (double-owner guard).
1063
+ const probe = probeActiveOwner();
1064
+ if (probe.status === 'unknown') return;
1065
+ const owned = probe.status === 'live' && probe.state?.instanceId === INSTANCE_ID;
910
1066
  if (owned) {
911
- // onlyIfOwned: ownership was checked outside the file lock; CAS mode
912
- // prevents this periodic tick from overwriting a newer owner that
913
- // claimed the seat in between (re-steal / ping-pong guard).
914
- refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
1067
+ // Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
1068
+ // and skip the metadata touch never block the tick. We still own, so
1069
+ // ensure/keep the owned runtime up.
1070
+ try { refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
915
1071
  await startOwnedRuntime(options);
916
1072
  return;
917
1073
  }
918
- // Not the owner. Two sub-cases:
919
- // (a) A live remote session holds the seat (active-instance names a
920
- // different, non-stale instance) last-wins: we lost, go quiet
921
- // (disconnect if we were connected) and tell the parent session to
922
- // drop remote mode entirely (single-holder, no handover).
923
- // (b) There is NO live owner (active is null/stale — e.g. our own entry
924
- // was cleared after a backend-connect failure or a bridge
925
- // deactivate/reactivate) this remote session claims the empty seat
926
- // and starts the owned runtime. Without this, a remote session could
927
- // never (re)acquire ownership once its active entry was cleared.
928
- if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
929
- if (bridgeRuntimeConnected) {
1074
+ if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== INSTANCE_ID) {
1075
+ // A different live remote session holds the seat (last-wins: we lost).
1076
+ // Go quiet (disconnect if connected) and tell the parent to drop remote
1077
+ // mode entirely (single-holder, no handover). Notify UNCONDITIONALLY
1078
+ // a loser whose backend never connected must still drop its Remote
1079
+ // indicator; the parent handler is idempotent.
1080
+ // Also cover the STARTING phase: a worker stuck in backend.connect() must
1081
+ // get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
1082
+ // bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
1083
+ // once connect() resolves otherwise the superseded connect lingers.
1084
+ if (bridgeRuntimeConnected || bridgeRuntimeStarting) {
930
1085
  await stopOwnedRuntime("ownership lost (newer remote session)");
931
- notifyRemoteSuperseded();
932
1086
  }
1087
+ notifyRemoteSuperseded();
933
1088
  return;
934
1089
  }
935
- // No live owner claim the empty seat and start.
936
- claimBridgeOwnership("no active owner");
937
- if (currentOwnerState().owned) {
1090
+ // Empty (absent) or stale (dead owner) seat claim it. Try-once
1091
+ // (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
1092
+ // so treat as busy and skip this tick rather than block.
1093
+ let claimed = false;
1094
+ try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
1095
+ if (claimed) {
938
1096
  await startOwnedRuntime(options);
939
1097
  }
940
1098
  })();
@@ -1444,10 +1602,12 @@ const {
1444
1602
  lifecycle: {
1445
1603
  getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
1446
1604
  getChannelBridgeActive: () => channelBridgeActive,
1605
+ getOwned: () => getBridgeOwnershipSnapshot().owned,
1447
1606
  setChannelBridgeActive: (v) => { channelBridgeActive = v; },
1448
1607
  writeBridgeState,
1449
1608
  stopServerTyping,
1450
1609
  claimBridgeOwnership,
1610
+ notifyRemoteAcquired,
1451
1611
  refreshBridgeOwnership,
1452
1612
  bindPersistedTranscriptIfAny,
1453
1613
  stopOwnedRuntime,
@@ -1658,6 +1818,26 @@ backend.onMessage = (msg) => {
1658
1818
  });
1659
1819
  })();
1660
1820
  };
1821
+ const NETWORK_ERR_RE = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|timeout|aborted|TimeoutError/i;
1822
+ function isNetworkError(err) {
1823
+ const s = `${err?.code ?? ""} ${err?.name ?? ""} ${err?.message ?? err ?? ""}`;
1824
+ return NETWORK_ERR_RE.test(s);
1825
+ }
1826
+ async function retryOnNetwork(fn, { attempts = 3, baseDelayMs = 300, label = "op" } = {}) {
1827
+ let lastErr;
1828
+ for (let i = 0; i < attempts; i++) {
1829
+ try {
1830
+ return await fn();
1831
+ } catch (err) {
1832
+ lastErr = err;
1833
+ if (i === attempts - 1 || !isNetworkError(err)) throw err;
1834
+ const delay = baseDelayMs * (i + 1);
1835
+ process.stderr.write(`mixdog: ${label} network failure (attempt ${i + 1}/${attempts}), retrying in ${delay}ms: ${err?.message || err}\n`);
1836
+ await new Promise((r) => setTimeout(r, delay));
1837
+ }
1838
+ }
1839
+ throw lastErr;
1840
+ }
1661
1841
  async function handleInbound(msg, route, options = {}) {
1662
1842
  const handleStartMs = Date.now();
1663
1843
  let text = msg.text;
@@ -1668,11 +1848,19 @@ async function handleInbound(msg, route, options = {}) {
1668
1848
  text = text || "[voice message]";
1669
1849
  } else {
1670
1850
  try {
1671
- const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
1851
+ const files = await retryOnNetwork(
1852
+ // Short per-attempt timeout (vs the 180s default) bounds worst-case
1853
+ // blockage of the serial inboundQueue on a bad voice message.
1854
+ () => backend.downloadAttachment(msg.chatId, msg.messageId, { timeoutMs: 20_000 }),
1855
+ { label: "voice.download" }
1856
+ );
1672
1857
  // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
1673
1858
  for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
1674
1859
  const _t0 = Date.now();
1675
- const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
1860
+ const transcript = await retryOnNetwork(
1861
+ () => transcribeVoice(f.path, { attachmentId: f.id }),
1862
+ { label: "voice.transcription" }
1863
+ );
1676
1864
  const _elapsed = Date.now() - _t0;
1677
1865
  if (transcript) {
1678
1866
  text = transcript;
@@ -1683,8 +1871,12 @@ async function handleInbound(msg, route, options = {}) {
1683
1871
  }
1684
1872
  }
1685
1873
  } catch (err) {
1686
- process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
1687
- text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
1874
+ const netFail = isNetworkError(err);
1875
+ process.stderr.write(`mixdog: voice.transcription error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
1876
+ const marker = netFail
1877
+ ? "[attachment: voice transcription failed (network)]"
1878
+ : `[voice message \u2014 transcription error: ${err?.message || err}]`;
1879
+ text = text ? `${text} ${marker}` : marker;
1688
1880
  }
1689
1881
  }
1690
1882
  }
@@ -1742,14 +1934,15 @@ async function init(_sharedMcp) {
1742
1934
  async function start() {
1743
1935
  channelBridgeActive = true;
1744
1936
  writeBridgeState(true);
1745
- // Opt-in remote, single-owner, last-wins. Claim the seat immediately so a
1746
- // later `mixdog --remote` session overwrites us and we drop on our next
1747
- // refresh tick. Then connect the owned runtime and arm the ownership timer
1748
- // that keeps checking whether a newer session has taken over.
1749
- claimBridgeOwnership("remote start");
1937
+ // Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
1938
+ // seat up front: connect our own gateway first and claim only after it reports
1939
+ // READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
1940
+ // owner keeps serving, so a boot causes no send/receive gap. This is also the
1941
+ // SINGLE boot claim — the old "remote start" pre-claim + "re-activate
1942
+ // takeover" second claim (double reconnect) is gone.
1750
1943
  const _bindingReadyStart = Date.now();
1751
1944
  try {
1752
- await refreshBridgeOwnership({ restoreBinding: true });
1945
+ await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
1753
1946
  bindingReadyStatus = "resolved";
1754
1947
  dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
1755
1948
  _bindingReadyResolve(true);
@@ -1759,13 +1952,10 @@ async function start() {
1759
1952
  _bindingReadyResolve(e);
1760
1953
  }
1761
1954
  // Ownership timer: keep checking whether a newer remote session has taken
1762
- // over (last-wins) so a superseded owner disconnects promptly.
1763
- if (!bridgeOwnershipTimer) {
1764
- bridgeOwnershipTimer = setInterval(() => {
1765
- refreshBridgeOwnershipSafe();
1766
- }, 3e3);
1767
- bridgeOwnershipTimer.unref?.();
1768
- }
1955
+ // over (last-wins) so a superseded owner disconnects promptly. Normally
1956
+ // already armed by startOwnedRuntime before connect(); this is the fallback
1957
+ // for a start path that aborted pre-connect (idempotent).
1958
+ armBridgeOwnershipTimer();
1769
1959
  // Hot-reload config on file change (schedules/webhooks/events).
1770
1960
  if (!_configWatcher) {
1771
1961
  try {
@@ -1953,15 +2143,39 @@ if (_isWorkerMode && process.send) {
1953
2143
  })
1954
2144
  void (async () => {
1955
2145
  const startedAt = performance.now()
1956
- try {
1957
- await start()
1958
- bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1) })
1959
- process.send({ type: 'ready' })
1960
- } catch (e) {
1961
- bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e?.message || String(e) })
1962
- process.stderr.write(`[channels-worker] start() failed: ${e && (e.message || e)}\n`)
1963
- process.send({ type: 'ready', degraded: true, error: e?.message || String(e) })
2146
+ const MAX_START_ATTEMPTS = 3
2147
+ const BASE_BACKOFF_MS = 250
2148
+ const isTransientStartErr = (err) =>
2149
+ err?.code === 'ELOCKTIMEOUT' || /atomic lock timeout/i.test(err?.message || '')
2150
+ let lastErr
2151
+ for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) {
2152
+ if (_channelsStopInFlight) return
2153
+ try {
2154
+ await start()
2155
+ bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
2156
+ process.send({ type: 'ready' })
2157
+ return
2158
+ } catch (e) {
2159
+ lastErr = e
2160
+ const transient = isTransientStartErr(e)
2161
+ bootProfile("worker:start-failed", { attempt, transient, error: e?.message || String(e) })
2162
+ process.stderr.write(`[channels-worker] start() failed (attempt ${attempt}/${MAX_START_ATTEMPTS}, transient=${transient}): ${e && (e.message || e)}\n`)
2163
+ if (!transient || attempt >= MAX_START_ATTEMPTS) break
2164
+ const backoff = BASE_BACKOFF_MS * attempt + Math.floor(Math.random() * BASE_BACKOFF_MS)
2165
+ await new Promise((r) => setTimeout(r, backoff))
2166
+ if (_channelsStopInFlight) return
2167
+ }
1964
2168
  }
2169
+ // A stop landed while we were failing — let clean shutdown proceed, never exit over it.
2170
+ if (_channelsStopInFlight) return
2171
+ // Terminal failure: do NOT mask as a (degraded) ready. Exit non-zero so the
2172
+ // parent's exit-before-ready path respawns or rejects startRemote instead of
2173
+ // silently losing remote output forwarding.
2174
+ bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: lastErr?.message || String(lastErr) })
2175
+ process.stderr.write(`[channels-worker] start() giving up after ${MAX_START_ATTEMPTS} attempts: ${lastErr && (lastErr.message || lastErr)}\n`)
2176
+ // Exit 2 = terminal (non-transient) start failure: parent must reject, not respawn.
2177
+ // Exit 1 = exhausted transient retries: parent may respawn.
2178
+ process.exit(isTransientStartErr(lastErr) ? 1 : 2)
1965
2179
  })()
1966
2180
  }
1967
2181
 
@@ -86,15 +86,33 @@ ${err instanceof Error ? err.stack : ""}
86
86
  // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
87
87
  // occurrence must NOT be fatal, only a run of 3+ in a row without an
88
88
  // intervening distinct/successful event.
89
- const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
89
+ const BENIGN_CRASH_CODES = new Set([
90
+ "EPERM", "EACCES", "EBUSY",
91
+ // Transient transport blips: a raw http/https ClientRequest (or an
92
+ // undici socket) can emit these when a keep-alive connection is torn
93
+ // down mid-flight (Discord gateway flapping, proxy resets). One blip
94
+ // must NOT kill the worker — it is benign; only a repeat storm within
95
+ // the streak window force-exits (see _fatalCrash).
96
+ "ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE", "EHOSTUNREACH",
97
+ "ENETUNREACH", "ENETDOWN", "EAI_AGAIN", "ERR_STREAM_PREMATURE_CLOSE",
98
+ "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
99
+ "UND_ERR_BODY_TIMEOUT", "UND_ERR_ABORTED",
100
+ ]);
101
+ // Some transient socket errors arrive with no `.code` (notably the classic
102
+ // "socket hang up" thrown by node:_http_client socketOnEnd — the exact crash
103
+ // this guard exists for). Match those by message so they classify benign too.
104
+ const BENIGN_CRASH_MESSAGE_RE = /socket hang up|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR_/i;
90
105
  const BENIGN_CRASH_FATAL_THRESHOLD = 3;
91
106
  // "In a row" needs a time dimension: benign errors minutes/hours apart are
92
107
  // independent contention events, not a corrupted-state run. Only count a
93
108
  // streak when hits land within this window of the previous one.
94
109
  const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
95
110
  function _isBenignCrash(err) {
96
- const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
97
- return BENIGN_CRASH_CODES.has(code);
111
+ const msg = String(err?.message || err);
112
+ const code = err?.code
113
+ || (/\b(EPERM|EACCES|EBUSY|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENETDOWN|EAI_AGAIN|UND_ERR_[A-Z_]+)\b/.exec(msg) || [])[0];
114
+ if (BENIGN_CRASH_CODES.has(code)) return true;
115
+ return BENIGN_CRASH_MESSAGE_RE.test(msg);
98
116
  }
99
117
 
100
118
  export {
@@ -28,6 +28,10 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
28
28
  const now = Date.now();
29
29
  if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
30
30
  inboundSeen.set(key, now);
31
+ // Cross-process marker write MUST stay synchronous: a fire-and-forget wx
32
+ // write opens a duplicate-delivery window where two processes both return
33
+ // false before either callback observes EEXIST. Single small write+stat is
34
+ // cheap. Only the periodic sweep is moved off the hot path (async).
31
35
  const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
32
36
  try {
33
37
  fs.writeFileSync(marker, String(now), { flag: "wx" });
@@ -39,24 +43,27 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
39
43
  } catch {}
40
44
  }
41
45
  }
42
- if (Math.random() < 0.1) {
43
- try {
44
- for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
45
- const fp = path.join(INBOUND_DEDUP_DIR, f);
46
- try {
47
- if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
48
- } catch {
49
- }
50
- }
51
- } catch {
52
- }
53
- }
46
+ scheduleSweep(now);
54
47
  for (const [k, t] of inboundSeen) {
55
48
  if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
56
49
  }
57
50
  return false;
58
51
  }
59
52
 
53
+ function scheduleSweep(now) {
54
+ if (Math.random() < 0.1) {
55
+ fs.readdir(INBOUND_DEDUP_DIR, (e, list) => {
56
+ if (e) return;
57
+ for (const f of list) {
58
+ const fp = path.join(INBOUND_DEDUP_DIR, f);
59
+ fs.stat(fp, (se, stat) => {
60
+ if (!se && now - stat.mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
61
+ });
62
+ }
63
+ });
64
+ }
65
+ }
66
+
60
67
  function resolveInboundRoute(chatId, parentChatId) {
61
68
  const config = getConfig();
62
69
  // Single main channel: there is no per-channel label/mode map anymore.