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.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- 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
|
-
|
|
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
|
-
`$
|
|
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
|
-
|
|
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`
|
|
927
|
-
//
|
|
928
|
-
//
|
|
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`
|
|
31
|
-
// (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// - refresh/owned
|
|
229
|
-
//
|
|
230
|
-
// Wrapped so ANY throw in the pre-connect claim
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
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
|
-
//
|
|
284
|
-
//
|
|
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 {
|
|
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
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
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 {
|
|
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
|
-
//
|
|
408
|
-
|
|
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
|
|
467
|
-
//
|
|
468
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
524
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
}
|