mixdog 0.9.21 → 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/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +30 -17
- 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/bash-session.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
- 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/patch/orchestrator.mjs +23 -13
- 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 +44 -10
- 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 +653 -310
- 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
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from './paths.mjs';
|
|
26
26
|
import { ensureNativePatchBinaryAvailable } from './native-server.mjs';
|
|
27
27
|
import { assertPathReachable } from '../builtin/fs-reachability.mjs';
|
|
28
|
-
import { dispatchNativePatch } from './dispatch.mjs';
|
|
28
|
+
import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
|
|
29
29
|
import { normalizeOutputPath } from '../builtin.mjs';
|
|
30
30
|
import {
|
|
31
31
|
planV4ARenameSections,
|
|
@@ -242,17 +242,12 @@ async function apply_patch(args, cwd, options = {}) {
|
|
|
242
242
|
}
|
|
243
243
|
const insideEntries = entries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
|
|
244
244
|
const outsideEntries = entries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
|
|
245
|
-
if (outsideEntries.length > 0) {
|
|
246
|
-
const rels = outsideEntries.map((e) => normalizeOutputPath(e.fullPath || e.displayPath || '(unknown)')).join(', ');
|
|
247
|
-
return wrapPatchMutationOutput(
|
|
248
|
-
`Error: apply_patch target resolves outside base path (../ or absolute not allowed): ${rels}`,
|
|
249
|
-
mutationPlan,
|
|
250
|
-
{ backend: 'native-patch' },
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
245
|
const parsedInside = (parsed || []).filter(
|
|
254
246
|
(entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
|
|
255
247
|
);
|
|
248
|
+
const backend = outsideEntries.length > 0
|
|
249
|
+
? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
|
|
250
|
+
: 'native-patch';
|
|
256
251
|
const resultParts = [];
|
|
257
252
|
if (insideEntries.length > 0) {
|
|
258
253
|
const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), headerRewrites);
|
|
@@ -268,11 +263,26 @@ async function apply_patch(args, cwd, options = {}) {
|
|
|
268
263
|
parsed: parsedInside,
|
|
269
264
|
});
|
|
270
265
|
if (isPatchErrorText(nativeResult)) {
|
|
271
|
-
|
|
272
|
-
return wrapPatchMutationOutput(nativeResult, mutationPlan, { backend: 'native-patch' });
|
|
266
|
+
return wrapPatchMutationOutput(nativeResult, mutationPlan, { backend });
|
|
273
267
|
}
|
|
274
268
|
resultParts.push(nativeResult);
|
|
275
269
|
}
|
|
270
|
+
if (outsideEntries.length > 0) {
|
|
271
|
+
// Out-of-base targets are applied via the JS dispatcher (no base-path
|
|
272
|
+
// confinement); write permission is enforced at the hook layer.
|
|
273
|
+
const jsResult = await dispatchJsPatchEntries({
|
|
274
|
+
rows: outsideEntries,
|
|
275
|
+
parsed,
|
|
276
|
+
basePath,
|
|
277
|
+
dryRun,
|
|
278
|
+
fuzzy,
|
|
279
|
+
readStateScope,
|
|
280
|
+
});
|
|
281
|
+
if (isPatchErrorText(jsResult)) {
|
|
282
|
+
return wrapPatchMutationOutput(jsResult, mutationPlan, { backend });
|
|
283
|
+
}
|
|
284
|
+
resultParts.push(jsResult);
|
|
285
|
+
}
|
|
276
286
|
let combined = resultParts.join('\n');
|
|
277
287
|
const renameLines = formatV4ARenameSuccessLines(v4aRenameResults);
|
|
278
288
|
if (renameLines.length > 0 && !isPatchErrorText(combined)) {
|
|
@@ -287,9 +297,9 @@ async function apply_patch(args, cwd, options = {}) {
|
|
|
287
297
|
`hunk-level rejected (rejectPartial=false, V4A): ${rejectedV4AHunks.length}`,
|
|
288
298
|
...rejectedV4AHunks.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
|
|
289
299
|
];
|
|
290
|
-
return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend
|
|
300
|
+
return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend });
|
|
291
301
|
}
|
|
292
|
-
return wrapPatchMutationOutput(combined, mutationPlan, { backend
|
|
302
|
+
return wrapPatchMutationOutput(combined, mutationPlan, { backend });
|
|
293
303
|
}));
|
|
294
304
|
}
|
|
295
305
|
|
|
@@ -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
|
}
|