mixdog 0.9.22 → 0.9.23

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 (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -86,10 +86,6 @@ function psSingleQuote(s) {
86
86
  return `'${String(s).replace(/'/g, "''")}'`;
87
87
  }
88
88
 
89
- function powerShellEncodedCommand(command) {
90
- return Buffer.from(String(command || ''), 'utf16le').toString('base64');
91
- }
92
-
93
89
  function isPowerShellShell(shell, shellType) {
94
90
  if (shellType === 'powershell') return true;
95
91
  const stem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
@@ -857,14 +853,24 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
857
853
  const exitPath = shellJobExitPath(jobId);
858
854
  const donePath = shellJobDonePath(jobId);
859
855
  const wrappedTempPath = `${exitPath}.cmd.ps1`;
860
- const encodedCommand = powerShellEncodedCommand(command);
856
+ // Stage the USER command as its own .ps1 and run it via `-File` instead of
857
+ // `-EncodedCommand <base64>`: the base64 token landed on the grandchild's
858
+ // visible command line, which is a prime Defender obfuscation signature
859
+ // (same family as the PowhidSubExec false positive). A file path on the
860
+ // command line carries no such signature, and the payload stays opaque to
861
+ // outer-shell quoting exactly as base64 did. UTF-8 BOM so Windows
862
+ // PowerShell 5.1 doesn't misread non-ASCII as ANSI.
863
+ // Trailer mirrors -Command/-EncodedCommand exit semantics (-File alone
864
+ // does NOT propagate the last native command's exit code).
865
+ const innerTempPath = `${exitPath}.user.ps1`;
866
+ const innerScript = `\ufeff${command}\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n`;
861
867
  const mergeLiteral = mergeStderr ? '$true' : '$false';
862
868
  const wrapper = [
863
869
  "$ErrorActionPreference = 'Continue'",
864
870
  '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
865
871
  '$OutputEncoding=[System.Text.Encoding]::UTF8',
866
872
  '$exe = (Get-Process -Id $PID).Path',
867
- `$encoded = ${psSingleQuote(encodedCommand)}`,
873
+ `$innerPath = ${psSingleQuote(innerTempPath)}`,
868
874
  `$stdoutPath = ${psSingleQuote(stdoutPath)}`,
869
875
  `$stderrPath = ${psSingleQuote(rawStderrPath)}`,
870
876
  `$exitPath = ${psSingleQuote(exitPath)}`,
@@ -873,7 +879,10 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
873
879
  `$timeoutMs = ${Math.max(1, Math.floor(timeoutMs || 0))}`,
874
880
  '$code = 1',
875
881
  'try {',
876
- " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)",
882
+ // -ExecutionPolicy Bypass: unlike -EncodedCommand, -File is subject to
883
+ // the execution policy on Windows PowerShell; pwsh on macOS/Linux
884
+ // accepts the parameter as a no-op, so it is safe unconditionally.
885
+ " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $innerPath)",
877
886
  // -WindowStyle is a Windows-only Start-Process parameter; pwsh on
878
887
  // macOS/Linux throws "not supported on this platform". Add it only on win32.
879
888
  ' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
@@ -912,23 +921,27 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
912
921
  '}',
913
922
  'try { Set-Content -LiteralPath $exitPath -Value ([string]$code) -NoNewline -Encoding ascii } catch {}',
914
923
  'try { Set-Content -LiteralPath $donePath -Value "" -NoNewline -Encoding ascii } catch {}',
924
+ 'try { Remove-Item -LiteralPath $innerPath -Force -ErrorAction SilentlyContinue } catch {}',
915
925
  'try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}',
916
926
  'exit $code',
917
927
  '',
918
928
  ].join('\n');
919
929
  try {
920
930
  writeFileSync(wrappedTempPath, wrapper, 'utf-8');
931
+ writeFileSync(innerTempPath, innerScript, 'utf-8');
921
932
  } catch (e) {
922
933
  return { jobId, kind: 'bash', status: 'failed', error: `failed to stage PowerShell background task: ${e?.message || e}` };
923
934
  }
924
935
 
925
936
  const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
926
- // `-WindowStyle Hidden` is a Windows-only CLI switch; pwsh on macOS/Linux
927
- // rejects it. `-ExecutionPolicy` likewise only applies to Windows
928
- // PowerShell. Build args per-platform so cross-OS pwsh background tasks run.
937
+ // No `-WindowStyle Hidden` CLI switch: windowsHide:true on the spawn below
938
+ // already gives CREATE_NO_WINDOW, and the visible command-line token trips
939
+ // Defender's hidden-PowerShell dropper signature (PowhidSubExec). The
940
+ // in-wrapper Start-Process WindowStyle=Hidden (above) stays — it lives in
941
+ // the staged .ps1, not on the command line, and is still needed there.
942
+ // `-ExecutionPolicy` only applies to Windows PowerShell; build per-platform.
929
943
  const isWin = process.platform === 'win32';
930
944
  const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
931
- if (isWin) wrapperArgs.push('-WindowStyle', 'Hidden');
932
945
  if (isWin && shellStem === 'powershell') wrapperArgs.push('-ExecutionPolicy', 'Bypass');
933
946
  wrapperArgs.push('-File', wrappedTempPath);
934
947
  // Spawn the staged wrapper directly. detached MUST be false on Windows:
@@ -27,11 +27,12 @@ function shellTypeFor(shell) {
27
27
 
28
28
  function shellSpec(shell, shellType = shellTypeFor(shell)) {
29
29
  if (shellType === 'powershell') {
30
- // `-WindowStyle Hidden` is a Windows-only switch. PowerShell Core
31
- // (pwsh) on macOS/Linux rejects it, so only append it on win32.
32
- const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
33
- if (process.platform === 'win32') psArgs.push('-WindowStyle', 'Hidden');
34
- psArgs.push('-Command');
30
+ // Deliberately NO `-WindowStyle Hidden` here: every spawn site passes
31
+ // windowsHide:true (CREATE_NO_WINDOW), which suppresses the console at
32
+ // the OS level without leaving a token on the command line. The CLI
33
+ // switch is redundant AND matches Defender's node→hidden-PowerShell
34
+ // dropper signature (Trojan:Win32/PowhidSubExec false positive).
35
+ const psArgs = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'];
35
36
  return {
36
37
  shell,
37
38
  shellArg: '-Command',
@@ -433,6 +433,55 @@ async function _execPolicyBlockMessage(command) {
433
433
  return checkExecPolicyMessage(command);
434
434
  }
435
435
 
436
+ // Count of shell spawns currently in-flight (including those parked in an
437
+ // EPERM backoff). Logged with each failed spawn so a Defender-induced storm
438
+ // is reconstructable: activeSpawnCount > 1 means concurrent spawns were
439
+ // racing the AV scan when the failure hit.
440
+ let _activeShellSpawns = 0;
441
+
442
+ function _isPowerShellSpawn(shell, shellArg) {
443
+ return /pwsh|powershell/i.test(String(shell || '')) || shellArg === '-Command';
444
+ }
445
+
446
+ // Windows Defender intermittently fails node→PowerShell spawns with EPERM
447
+ // while it scans the child image (see shell-runtime.mjs Trojan false-positive
448
+ // note). The failure is at spawn() time — before any stdio/side effect — so a
449
+ // short bounded retry is safe and never re-runs a command that already ran.
450
+ // Retry ONLY on EPERM/win32/powershell; everything else throws on first
451
+ // failure. Backoff 100/300/700ms caps added latency at ~1.1s. Every failed
452
+ // attempt logs one diagnostic line for later reconstruction.
453
+ async function _spawnShellWithRetry({ shell, argv, spawnOptions, shellArg, cwd }) {
454
+ const _delays = [100, 300, 700];
455
+ const _isPowerShell = _isPowerShellSpawn(shell, shellArg);
456
+ _activeShellSpawns++;
457
+ try {
458
+ let attempt = 0;
459
+ for (;;) {
460
+ try {
461
+ return spawn(shell, argv, spawnOptions);
462
+ } catch (err) {
463
+ try {
464
+ console.error('[shell-spawn-retry] ' + JSON.stringify({
465
+ code: (err && err.code) || null,
466
+ syscall: (err && err.syscall) || null,
467
+ shell,
468
+ cwd,
469
+ activeSpawnCount: _activeShellSpawns,
470
+ }));
471
+ } catch { /* logging must never mask the spawn error */ }
472
+ const _canRetry = err && err.code === 'EPERM'
473
+ && process.platform === 'win32'
474
+ && _isPowerShell
475
+ && attempt < _delays.length;
476
+ if (!_canRetry) throw err;
477
+ await new Promise((r) => setTimeout(r, _delays[attempt++]));
478
+ }
479
+ }
480
+ } finally {
481
+ _activeShellSpawns--;
482
+ }
483
+ }
484
+
436
485
  export function execShellCommand({
437
486
  shell,
438
487
  shellArg,
@@ -516,7 +565,12 @@ export function execShellCommand({
516
565
  const argv = Array.isArray(shellArgs) && shellArgs.length > 0
517
566
  ? [...shellArgs, _spawnCommand]
518
567
  : [shellArg, _spawnCommand];
519
- child = spawn(shell, argv, {
568
+ child = await _spawnShellWithRetry({
569
+ shell,
570
+ argv,
571
+ shellArg,
572
+ cwd,
573
+ spawnOptions: {
520
574
  env,
521
575
  cwd,
522
576
  windowsHide: true,
@@ -533,6 +587,7 @@ export function execShellCommand({
533
587
  // not unref it after adoption. Windows detached has different console
534
588
  // semantics, so it stays off there.
535
589
  detached: process.platform !== 'win32',
590
+ },
536
591
  });
537
592
  startChildGuardian({
538
593
  childPid: child.pid,
@@ -9,8 +9,6 @@ import {
9
9
  refreshActiveInstance,
10
10
  releaseOwnedChannelLocks,
11
11
  clearActiveInstance,
12
- probeActiveOwner,
13
- RUNTIME_ROOT,
14
12
  } from "./runtime-paths.mjs";
15
13
  // Owned-runtime lifecycle extracted from channels/index.mjs (behavior-
16
14
  // preserving): bridge-ownership claim/refresh/loss, backend connect/disconnect,
@@ -37,9 +35,10 @@ export function createOwnedRuntime({
37
35
  statusState,
38
36
  logOwnership,
39
37
  currentOwnerState,
40
- claimBridgeOwnership,
41
- startOwnerHeartbeat,
42
- stopOwnerHeartbeat,
38
+ acquireSeat,
39
+ closeSeatServer,
40
+ isSeatHeld,
41
+ onTakeover,
43
42
  bindPersistedTranscriptIfAny,
44
43
  cancelPendingTranscriptRearm,
45
44
  schedulePendingTranscriptRearm,
@@ -51,51 +50,52 @@ export function createOwnedRuntime({
51
50
  let bridgeRuntimeStarting = false;
52
51
  let _ownedRuntimeStopRequested = false;
53
52
  let bridgeOwnershipRefreshInFlight = null;
54
- let bridgeOwnershipTimer = null;
55
53
  let _memoryDrainTimer = null;
56
- // Event-driven ownership signal: an fs.watch on the runtime dir fires the
57
- // ownership refresh the instant active-instance.json changes (a newer owner
58
- // claims, or the owner releases/clears it), instead of waiting up to 3s for
59
- // the poll tick. This shrinks the double-owner window on takeover (the old
60
- // owner observes owned=false and tears down in ms) and signals ownership
61
- // loss to contenders immediately on release. The 3s timer stays as fallback.
62
- let activeInstanceWatcher = null;
63
- let _activeInstanceWatchDebounce = null;
64
- function armActiveInstanceWatcher() {
65
- if (activeInstanceWatcher) return;
66
- try {
67
- activeInstanceWatcher = fs.watch(RUNTIME_ROOT, { persistent: false }, (_event, filename) => {
68
- if (filename && filename !== 'active-instance.json') return;
69
- // Coalesce the burst of events an atomic rename/truncate emits.
70
- if (_activeInstanceWatchDebounce) return;
71
- _activeInstanceWatchDebounce = setTimeout(() => {
72
- _activeInstanceWatchDebounce = null;
73
- refreshBridgeOwnershipSafe();
74
- }, 50);
75
- _activeInstanceWatchDebounce.unref?.();
76
- });
77
- // fs.watch emits 'error' (and an unhandled one CRASHES the worker) when
78
- // the watched dir is removed or the handle is invalidated (common on
79
- // Windows). Close the dead handle and fall back to the 3s poll, which is
80
- // still armed — the event signal is a latency optimization, never the
81
- // sole ownership mechanism.
82
- activeInstanceWatcher.on('error', (err) => {
83
- process.stderr.write(`[ownership] active-instance watch error, falling back to poll: ${err?.message || err}\n`);
84
- clearActiveInstanceWatcher();
85
- });
86
- activeInstanceWatcher.unref?.();
87
- } catch { activeInstanceWatcher = null; }
88
- }
89
- function clearActiveInstanceWatcher() {
90
- if (_activeInstanceWatchDebounce) { clearTimeout(_activeInstanceWatchDebounce); _activeInstanceWatchDebounce = null; }
91
- if (activeInstanceWatcher) { try { activeInstanceWatcher.close(); } catch {} activeInstanceWatcher = null; }
54
+ let _seatTakeoverRegistered = false;
55
+ let _vacantReacquireTimer = null;
56
+ // Ownership loss is push-based now: the seat lock invokes our onTakeover
57
+ // handler when a contender sends an explicit takeover message, and the OS
58
+ // auto-releases the pipe/socket on crash. No fs.watch on active-instance.json
59
+ // and no 3s ownership poll both retired with the file-heuristic model.
60
+ function registerSeatTakeoverOnce() {
61
+ if (_seatTakeoverRegistered) return;
62
+ _seatTakeoverRegistered = true;
63
+ onTakeover(async () => {
64
+ // A newer session explicitly took the seat. Tear the owned runtime down
65
+ // (the seat lock closes the listener afterwards) and drop remote mode.
66
+ if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
67
+ await stopOwnedRuntime("ownership lost (seat taken over)");
68
+ }
69
+ notifyRemoteSuperseded();
70
+ });
92
71
  }
93
72
  function clearBridgeOwnershipTimer() {
94
- if (bridgeOwnershipTimer) {
95
- clearInterval(bridgeOwnershipTimer);
96
- bridgeOwnershipTimer = null;
97
- }
98
- clearActiveInstanceWatcher();
73
+ // Only the standby reacquire poll survives under the seat-lock model (the 3s
74
+ // ownership poll + active-instance fs.watch are gone). Stop it on shutdown.
75
+ stopVacantReacquirePoll();
76
+ }
77
+ function stopVacantReacquirePoll() {
78
+ if (_vacantReacquireTimer) { clearInterval(_vacantReacquireTimer); _vacantReacquireTimer = null; }
79
+ }
80
+ // Standby reacquire: after an auto-start (claim-if-vacant) backed off from a
81
+ // LIVE holder, poll modestly so a LATER holder crash is reclaimed without
82
+ // explicit user action. Never steals a live holder (force:false, try-once),
83
+ // and stops the instant it wins. Unref'd so it never keeps the worker alive.
84
+ function armVacantReacquirePoll() {
85
+ if (_vacantReacquireTimer) return;
86
+ _vacantReacquireTimer = setInterval(() => {
87
+ void (async () => {
88
+ if (!getChannelBridgeActive() || isSeatHeld() || getBridgeRuntimeConnected() || bridgeRuntimeStarting) return;
89
+ let got = false;
90
+ try { got = await acquireSeat({ force: false, timeoutMs: 0 }); } catch { got = false; }
91
+ if (!got) return;
92
+ stopVacantReacquirePoll();
93
+ registerSeatTakeoverOnce();
94
+ logOwnership("standby reclaim (vacant seat acquired)");
95
+ await startOwnedRuntime({ restoreBinding: true });
96
+ })();
97
+ }, 15e3);
98
+ _vacantReacquireTimer.unref?.();
99
99
  }
100
100
  function shouldStartEventPipelineRuntime() {
101
101
  return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
@@ -218,60 +218,47 @@ async function startOwnedRuntime(options = {}) {
218
218
  // Auto-start intent: claim the seat ONLY if it is vacant/stale (never steal a
219
219
  // live owner). Threaded from worker start() (MIXDOG_REMOTE_INTENT=auto).
220
220
  const claimIfVacant = options.claimIfVacant === true;
221
- // Single-holder correctness: the seat is claimed BEFORE getBackend().connect(),
222
- // never after. The old make-before-break (claim-after-ready) boot left two
223
- // gateways connected and contending during the multi-second connect window;
224
- // claiming first makes the previous owner observe owned=false on its next
225
- // tick and drain+disconnect, so at most one gateway ever serves.
226
- // - boot (claimAfterReady): last-wins acquire this new remote session
227
- // takes the seat outright.
228
- // - refresh/owned-path: CAS onlyIfOwned abort fast if the seat moved.
229
- // backendReady=false marks the partial state until getBackend().connect() succeeds.
230
- // Wrapped so ANY throw in the pre-connect claim (lock contention/error)
231
- // resets bridgeRuntimeStarting otherwise a transient lock error would
232
- // leave it stuck true and permanently block every future ownership attempt.
221
+ // Seat acquisition IS the ownership claim (OS-enforced singleton). Acquiring
222
+ // == successfully listen()ing on the pipe/socket, so at most one process ever
223
+ // holds it. This replaces the old pre-connect active-instance CAS dance:
224
+ // - boot (claimAfterReady) / reload (reclaim): acquire the seat here.
225
+ // claimIfVacant (auto-start) backs off silently when a LIVE holder is
226
+ // present (force:false); explicit /remote + last-wins force-take it via
227
+ // the seat lock's takeover message (force:true).
228
+ // - refresh/owned path: only proceed if we ALREADY hold the seat (never
229
+ // re-steal the seat lock, not a file re-read, is the authority).
230
+ // Wrapped so ANY throw in the pre-connect claim resets bridgeRuntimeStarting.
231
+ const reclaim = options.reclaim === true;
233
232
  try {
234
- if (claimAfterReady) {
235
- if (claimIfVacant) {
236
- // Auto-start claim-if-vacant. Probe first: a live owner other than us
237
- // holds the seat -> back off SILENTLY (no claim, no acquire notify) so
238
- // this session stays non-remote. Then claim atomically via the
239
- // onlyIfVacant CAS (guards the probe->write TOCTOU: a live owner landing
240
- // in that gap aborts the write, leaving the seat untouched).
241
- const probe = probeActiveOwner();
242
- if (probe.status === 'live' && probe.state?.instanceId && probe.state.instanceId !== instanceId) {
243
- bridgeRuntimeStarting = false;
233
+ if (claimAfterReady || reclaim) {
234
+ const acquired = await acquireSeat({ force: !claimIfVacant });
235
+ if (!acquired) {
236
+ bridgeRuntimeStarting = false;
237
+ if (claimIfVacant) {
244
238
  logOwnership("autostart backoff (live owner holds seat)");
245
- return;
239
+ // Keep a bounded standby poll running so a later holder crash frees
240
+ // the seat and this session reclaims it without explicit action.
241
+ armVacantReacquirePoll();
242
+ } else {
243
+ logOwnership("seat acquire failed");
246
244
  }
247
- const casResult = refreshActiveInstance(instanceId, { backendReady: false }, { onlyIfVacant: true, timeoutMs: 0 });
248
- if (casResult?.instanceId !== instanceId) {
249
- bridgeRuntimeStarting = false;
250
- logOwnership("autostart backoff (seat claimed by newer owner)");
251
- return;
252
- }
253
- logOwnership("boot claim (pre-connect, claim-if-vacant)");
254
- } else {
255
- refreshActiveInstance(instanceId, { backendReady: false });
256
- logOwnership("boot claim (pre-connect, last-wins)");
257
- }
258
- } else {
259
- // Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
260
- // the active-instance lock. Contention throws → caught below and treated
261
- // as "seat busy, abort this attempt"; the next 3s tick retries.
262
- const casResult = refreshActiveInstance(instanceId, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
263
- // A successful CAS write always sets instanceId to ours; any other result
264
- // (aborted write returning the stale/foreign/missing prior state) means the
265
- // seat was not ours to claim — abort here rather than relying on the timer.
266
- if (casResult?.instanceId !== instanceId) {
267
- bridgeRuntimeStarting = false;
268
245
  return;
269
246
  }
247
+ registerSeatTakeoverOnce();
248
+ stopVacantReacquirePoll();
249
+ logOwnership(claimIfVacant
250
+ ? "boot claim (seat acquired, claim-if-vacant)"
251
+ : (reclaim ? "reclaim (seat acquired)" : "boot claim (seat acquired, last-wins)"));
252
+ } else if (!isSeatHeld()) {
253
+ // Refresh/owned path with no seat held: not ours to start.
254
+ bridgeRuntimeStarting = false;
255
+ return;
270
256
  }
271
- // A newer session can claim between our write and this read. If we no longer
272
- // own the seat, abort fast WITHOUT starting heartbeat/getBackend()/scheduler/
273
- // webhook/ngrok ancillary runtime starts only past a confirmed-owned claim.
274
- if (!currentOwnerState().owned) {
257
+ // Advertise metadata (memory_port/pg_*/channelId/... preserved inside
258
+ // refreshActiveInstance). active-instance.json is a pure advert now; the
259
+ // seat lock is the ownership authority, so no CAS guard is needed.
260
+ refreshActiveInstance(instanceId, { backendReady: false });
261
+ if (!isSeatHeld()) {
275
262
  bridgeRuntimeStarting = false;
276
263
  return;
277
264
  }
@@ -280,13 +267,9 @@ async function startOwnedRuntime(options = {}) {
280
267
  process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
281
268
  return;
282
269
  }
283
- // Arm ownership-loss detection BEFORE getBackend().connect() so a session that is
284
- // superseded during the (multi-second) connect window is torn down: the 3s
285
- // tick observes a newer owner and flips _ownedRuntimeStopRequested, and
286
- // bailIfStopRequested below disconnects the getBackend() WE connected.
270
+ // Ensure the seat-lock takeover handler is registered so a superseding
271
+ // session tears us down promptly (replaces the old 3s ownership poll).
287
272
  armBridgeOwnershipTimer();
288
- // Heartbeat is ownership-gated; safe to arm now that we hold the seat.
289
- startOwnerHeartbeat();
290
273
  // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
291
274
  // memory service publishes its port. Idempotent + reentrancy-guarded inside
292
275
  // drainBuffer(); unref'd so it never holds the worker open.
@@ -304,7 +287,7 @@ async function startOwnedRuntime(options = {}) {
304
287
  const bailIfStopRequested = async () => {
305
288
  if (!_ownedRuntimeStopRequested) return false;
306
289
  try { await startingBackend.disconnect(); } catch {}
307
- try { stopOwnerHeartbeat(); } catch {}
290
+ try { await closeSeatServer(); } catch {}
308
291
  try { releaseOwnedChannelLocks(instanceId); } catch {}
309
292
  try { clearActiveInstance(instanceId); } catch {}
310
293
  setBridgeRuntimeConnected(false);
@@ -329,19 +312,14 @@ async function startOwnedRuntime(options = {}) {
329
312
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
330
313
  return;
331
314
  }
332
- // Post-connect ownership confirm (CAS). The seat was claimed BEFORE
333
- // connect; a newer session may have superseded us during the connect
334
- // window (the 3s tick would already be tearing us down). onlyIfOwned:
335
- // never re-steal here. If the CAS aborts we LOST the seat — disconnect the
336
- // getBackend() WE connected and mark the bridge runtime not connected (do NOT
337
- // clear active-instance; the newer owner holds it).
338
- let ownConfirm;
339
- try {
340
- ownConfirm = refreshActiveInstance(instanceId, { backendReady: true }, { onlyIfOwned: true });
341
- } catch { ownConfirm = null; }
342
- if (ownConfirm?.instanceId !== instanceId) {
315
+ // Post-connect seat check. The seat was acquired BEFORE connect; a takeover
316
+ // during the (multi-second) connect window would have closed our listener
317
+ // via onTakeover. If we no longer hold the seat we LOST it — disconnect the
318
+ // backend WE connected and drop remote (no active-instance clear; the newer
319
+ // owner holds the seat).
320
+ if (!isSeatHeld()) {
343
321
  try { await startingBackend.disconnect(); } catch {}
344
- try { stopOwnerHeartbeat(); } catch {}
322
+ try { await closeSeatServer(); } catch {}
345
323
  try { releaseOwnedChannelLocks(instanceId); } catch {}
346
324
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
347
325
  setBridgeRuntimeConnected(false);
@@ -351,6 +329,8 @@ async function startOwnedRuntime(options = {}) {
351
329
  notifyRemoteSuperseded();
352
330
  return;
353
331
  }
332
+ // Advertise backend readiness (metadata advert; seat lock is authority).
333
+ try { refreshActiveInstance(instanceId, { backendReady: true }); } catch {}
354
334
  setBridgeRuntimeConnected(true);
355
335
  // Fresh confirmed ownership — tell the parent it holds the seat so it flips
356
336
  // remote ON. Reached ONLY on a not-connected -> connected transition (the
@@ -404,11 +384,14 @@ async function startOwnedRuntime(options = {}) {
404
384
  try { forwarder.stopWatch(); } catch {}
405
385
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
406
386
  // Roll back partial owner-side state advertised before connect() ran:
407
- // heartbeat and active-instance entry.
408
- try { stopOwnerHeartbeat(); } catch {}
387
+ // disconnect the backend WE started FIRST (a post-connect startup step may
388
+ // have thrown while the gateway is live), then release the seat LAST so no
389
+ // contender can acquire + connect a second gateway while ours is still up.
390
+ try { await startingBackend.disconnect(); } catch {}
409
391
  try { releaseOwnedChannelLocks(instanceId); } catch {}
410
392
  try { clearActiveInstance(instanceId); } catch {}
411
393
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
394
+ try { await closeSeatServer(); } catch {}
412
395
  } finally {
413
396
  bridgeRuntimeStarting = false;
414
397
  }
@@ -437,7 +420,6 @@ async function stopOwnedRuntime(reason) {
437
420
  // and the drain/retry timers stay live after ownership is dropped, leaking a
438
421
  // file handle + timers for the rest of the process lifetime.
439
422
  try { forwarder.stopWatch(); } catch {}
440
- stopOwnerHeartbeat();
441
423
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
442
424
  scheduler.stop();
443
425
  stopSnapshotWriter();
@@ -459,21 +441,23 @@ async function stopOwnedRuntime(reason) {
459
441
  setBridgeRuntimeConnected(false);
460
442
  logOwnership(`standby: ${reason}`);
461
443
  }
444
+ // Release the OS seat lock LAST — only after the backend has drained and
445
+ // disconnected — so a contender can never acquire + connect a second gateway
446
+ // while ours is still live (double-owner). Idempotent: a takeover-message
447
+ // teardown runs this via stopOwnedRuntime, then the seat lock's own close is
448
+ // a no-op.
449
+ try { await closeSeatServer(); } catch {}
462
450
  }
463
451
  function refreshBridgeOwnershipSafe(options = {}) {
464
452
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
465
453
  }
466
- // Ownership-loss / re-acquire detection timer. Armed BEFORE getBackend().connect()
467
- // (from startOwnedRuntime) so a session superseded during the connect window is
468
- // torn down promptly, and re-armed idempotently on every start path.
454
+ // Ownership-loss detection is push-based under the seat-lock model: the OS
455
+ // releases the pipe/socket on holder crash, and an explicit takeover message
456
+ // invokes onTakeover. This entry point (called from worker-main start() and
457
+ // startOwnedRuntime) now just guarantees that handler is registered — no poll
458
+ // timer, no active-instance fs.watch.
469
459
  function armBridgeOwnershipTimer() {
470
- if (bridgeOwnershipTimer) return;
471
- bridgeOwnershipTimer = setInterval(() => {
472
- refreshBridgeOwnershipSafe();
473
- }, 3e3);
474
- bridgeOwnershipTimer.unref?.();
475
- // Arm the event-driven signal alongside the poll fallback.
476
- armActiveInstanceWatcher();
460
+ registerSeatTakeoverOnce();
477
461
  }
478
462
  // Guarded IPC send to the parent: no-ops when there is no channel or it is
479
463
  // already disconnected, and swallows both the synchronous throw and the async
@@ -510,55 +494,28 @@ async function refreshBridgeOwnership(options = {}) {
510
494
  // instead of returning early and observing spurious auto-connect failure.
511
495
  if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
512
496
  bridgeOwnershipRefreshInFlight = (async () => {
513
- // Opt-in remote, single-owner, last-wins. Only a remote session with an
514
- // active bridge participates. If this instance is the active owner (its
515
- // instanceId is the one advertised in active-instance.json) it ensures
516
- // the owned runtime is up. If a newer remote session has since claimed
517
- // ownership (last-wins overwrite), this instance is no longer owner and
518
- // quietly tears its getBackend() down on the next tick. No proxy, no steal.
497
+ // Ownership authority is the OS seat lock, not active-instance.json.
519
498
  if (!getChannelBridgeActive()) {
520
499
  if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
521
500
  return;
522
501
  }
523
- // Non-blocking ownership probe (read-only, no lock): distinguishes a live
524
- // owner from a genuinely empty/stale seat from an INDETERMINATE read (a
525
- // concurrent atomic rename yields partial content). "Locked/unreadable =
526
- // busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
527
- // never claim a seat we merely failed to read (double-owner guard).
528
- const probe = probeActiveOwner();
529
- if (probe.status === 'unknown') return;
530
- const owned = probe.status === 'live' && probe.state?.instanceId === instanceId;
531
- if (owned) {
532
- // Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
533
- // and skip the metadata touch — never block the tick. We still own, so
534
- // ensure/keep the owned runtime up.
535
- try { refreshActiveInstance(instanceId, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
502
+ // We hold the seat -> ensure the owned runtime is up (idempotent).
503
+ if (isSeatHeld()) {
536
504
  await startOwnedRuntime(options);
537
505
  return;
538
506
  }
539
- if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== instanceId) {
540
- // A different live remote session holds the seat (last-wins: we lost).
541
- // Go quiet (disconnect if connected) and tell the parent to drop remote
542
- // mode entirely (single-holder, no handover). Notify UNCONDITIONALLY —
543
- // a loser whose getBackend() never connected must still drop its Remote
544
- // indicator; the parent handler is idempotent.
545
- // Also cover the STARTING phase: a worker stuck in getBackend().connect() must
546
- // get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
547
- // bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
548
- // once connect() resolves — otherwise the superseded connect lingers.
549
- if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
550
- await stopOwnedRuntime("ownership lost (newer remote session)");
551
- }
507
+ // We do NOT hold the seat but the runtime still thinks it is live — a
508
+ // takeover slipped past the push handler; tear down and drop remote.
509
+ if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
510
+ await stopOwnedRuntime("ownership lost (seat released)");
552
511
  notifyRemoteSuperseded();
553
512
  return;
554
513
  }
555
- // Empty (absent) or stale (dead owner) seat claim it. Try-once
556
- // (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
557
- // so treat as busy and skip this tick rather than block.
558
- let claimed = false;
559
- try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
560
- if (claimed) {
561
- await startOwnedRuntime(options);
514
+ // Explicit (re)claim only: activation (/remote), reload restart, or the
515
+ // auto-connect retry. Default force-takeover; claimIfVacant backs off from a
516
+ // live holder. Periodic/defensive nudges pass no claim flag and never steal.
517
+ if (options.claim) {
518
+ await startOwnedRuntime({ ...options, reclaim: true });
562
519
  }
563
520
  })();
564
521
  try {
@@ -603,7 +560,9 @@ async function reloadRuntimeConfig() {
603
560
  state.transcriptPath = "";
604
561
  });
605
562
  } catch {}
606
- if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
563
+ // stopOwnedRuntime above released the seat; a same-session reload must
564
+ // re-acquire it (claim: force-takeover) to resume owning the bridge.
565
+ if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false, claim: true });
607
566
  } else if (nextBackend !== previousBackend) {
608
567
  try { await nextBackend.disconnect?.(); } catch {}
609
568
  }