mixdog 0.9.22 → 0.9.24
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/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- 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/agent-loop.mjs +24 -7
- 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/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- 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/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- 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/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +150 -6
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- 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/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- 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
|
@@ -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,
|
|
@@ -33,13 +31,11 @@ export function createOwnedRuntime({
|
|
|
33
31
|
instanceId,
|
|
34
32
|
TERMINAL_LEAD_PID,
|
|
35
33
|
forwarder,
|
|
34
|
+
sendNotifyToParent,
|
|
36
35
|
scheduler,
|
|
37
36
|
statusState,
|
|
38
37
|
logOwnership,
|
|
39
38
|
currentOwnerState,
|
|
40
|
-
claimBridgeOwnership,
|
|
41
|
-
startOwnerHeartbeat,
|
|
42
|
-
stopOwnerHeartbeat,
|
|
43
39
|
bindPersistedTranscriptIfAny,
|
|
44
40
|
cancelPendingTranscriptRearm,
|
|
45
41
|
schedulePendingTranscriptRearm,
|
|
@@ -51,51 +47,19 @@ export function createOwnedRuntime({
|
|
|
51
47
|
let bridgeRuntimeStarting = false;
|
|
52
48
|
let _ownedRuntimeStopRequested = false;
|
|
53
49
|
let bridgeOwnershipRefreshInFlight = null;
|
|
54
|
-
let bridgeOwnershipTimer = null;
|
|
55
50
|
let _memoryDrainTimer = null;
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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; }
|
|
92
|
-
}
|
|
51
|
+
// Promise that resolves when the current startOwnedRuntime() run fully
|
|
52
|
+
// settles (bridgeRuntimeStarting -> false). reloadRuntimeConfig awaits this
|
|
53
|
+
// before issuing a restart so a backend swap that lands mid-start is not
|
|
54
|
+
// dropped by startOwnedRuntime's in-flight guard (lost-restart race).
|
|
55
|
+
let _inFlightStart = null;
|
|
56
|
+
// Daemon model: the machine-global channels daemon (singleton-owner lock in
|
|
57
|
+
// src/standalone) guarantees exactly one runtime per machine, so this process
|
|
58
|
+
// is the unconditional bridge owner. The OS seat lock is retired — no takeover
|
|
59
|
+
// handler, no vacant-reacquire poll, no cross-process ownership-loss detection.
|
|
93
60
|
function clearBridgeOwnershipTimer() {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
bridgeOwnershipTimer = null;
|
|
97
|
-
}
|
|
98
|
-
clearActiveInstanceWatcher();
|
|
61
|
+
// No ownership timer under the daemon model; kept as a no-op so the worker
|
|
62
|
+
// teardown call site stays unchanged.
|
|
99
63
|
}
|
|
100
64
|
function shouldStartEventPipelineRuntime() {
|
|
101
65
|
return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
|
|
@@ -207,6 +171,15 @@ async function startOwnedRuntime(options = {}) {
|
|
|
207
171
|
if (!getChannelBridgeActive()) return;
|
|
208
172
|
bridgeRuntimeStarting = true;
|
|
209
173
|
_ownedRuntimeStopRequested = false;
|
|
174
|
+
let _settleStart;
|
|
175
|
+
_inFlightStart = new Promise((r) => { _settleStart = r; });
|
|
176
|
+
const settleInFlightStart = () => {
|
|
177
|
+
bridgeRuntimeStarting = false;
|
|
178
|
+
_inFlightStart = null;
|
|
179
|
+
const done = _settleStart;
|
|
180
|
+
_settleStart = null;
|
|
181
|
+
done?.();
|
|
182
|
+
};
|
|
210
183
|
// Capture the getBackend() instance that THIS start operation will connect. A
|
|
211
184
|
// reloadRuntimeConfig() hot-swap can replace the global `getBackend()` while this
|
|
212
185
|
// start is still awaiting connect(); using the captured instance for both
|
|
@@ -214,79 +187,19 @@ async function startOwnedRuntime(options = {}) {
|
|
|
214
187
|
// getBackend() WE started (not the freshly-swapped one), closing the
|
|
215
188
|
// both-backends-live window.
|
|
216
189
|
const startingBackend = getBackend();
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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.
|
|
190
|
+
// Daemon model: this runtime is the singleton bridge owner (enforced by the
|
|
191
|
+
// standalone daemon's singleton-owner lock), so there is no seat to claim and
|
|
192
|
+
// no cross-process contender to back off from. Just advertise metadata
|
|
193
|
+
// (memory_port/pg_*/channelId/... preserved inside refreshActiveInstance);
|
|
194
|
+
// active-instance.json is a pure advert. Wrapped so any throw resets
|
|
195
|
+
// bridgeRuntimeStarting.
|
|
233
196
|
try {
|
|
234
|
-
|
|
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;
|
|
244
|
-
logOwnership("autostart backoff (live owner holds seat)");
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
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
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
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) {
|
|
275
|
-
bridgeRuntimeStarting = false;
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
197
|
+
refreshActiveInstance(instanceId, { backendReady: false });
|
|
278
198
|
} catch (e) {
|
|
279
|
-
|
|
280
|
-
process.stderr.write(`mixdog: pre-connect
|
|
199
|
+
settleInFlightStart();
|
|
200
|
+
process.stderr.write(`mixdog: pre-connect metadata advert aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
281
201
|
return;
|
|
282
202
|
}
|
|
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.
|
|
287
|
-
armBridgeOwnershipTimer();
|
|
288
|
-
// Heartbeat is ownership-gated; safe to arm now that we hold the seat.
|
|
289
|
-
startOwnerHeartbeat();
|
|
290
203
|
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
291
204
|
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
292
205
|
// drainBuffer(); unref'd so it never holds the worker open.
|
|
@@ -304,7 +217,6 @@ async function startOwnedRuntime(options = {}) {
|
|
|
304
217
|
const bailIfStopRequested = async () => {
|
|
305
218
|
if (!_ownedRuntimeStopRequested) return false;
|
|
306
219
|
try { await startingBackend.disconnect(); } catch {}
|
|
307
|
-
try { stopOwnerHeartbeat(); } catch {}
|
|
308
220
|
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
309
221
|
try { clearActiveInstance(instanceId); } catch {}
|
|
310
222
|
setBridgeRuntimeConnected(false);
|
|
@@ -329,36 +241,13 @@ async function startOwnedRuntime(options = {}) {
|
|
|
329
241
|
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
330
242
|
return;
|
|
331
243
|
}
|
|
332
|
-
//
|
|
333
|
-
|
|
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) {
|
|
343
|
-
try { await startingBackend.disconnect(); } catch {}
|
|
344
|
-
try { stopOwnerHeartbeat(); } catch {}
|
|
345
|
-
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
346
|
-
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
347
|
-
setBridgeRuntimeConnected(false);
|
|
348
|
-
cancelPendingTranscriptRearm();
|
|
349
|
-
try { forwarder.stopWatch(); } catch {}
|
|
350
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
351
|
-
notifyRemoteSuperseded();
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
244
|
+
// Advertise backend readiness (metadata advert).
|
|
245
|
+
try { refreshActiveInstance(instanceId, { backendReady: true }); } catch {}
|
|
354
246
|
setBridgeRuntimeConnected(true);
|
|
355
|
-
// Fresh confirmed
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
// - explicit/auto boot claim (claimAfterReady),
|
|
360
|
-
// - the deferred claim when a bridge timer's refreshBridgeOwnership()
|
|
361
|
-
// claims a seat vacated by a departing owner (finding 1).
|
|
247
|
+
// Fresh confirmed connection — tell the parent to flip remote ON. Reached
|
|
248
|
+
// ONLY on a not-connected -> connected transition (the top-of-fn
|
|
249
|
+
// early-return skips already-connected re-ticks), so this fires exactly once
|
|
250
|
+
// per connect and covers EVERY start path (boot + reload restart + activate).
|
|
362
251
|
// The parent's acquired handler is idempotent (no-op when already remote),
|
|
363
252
|
// so notifying post-connect — never pre-connect — means a connect FAILURE
|
|
364
253
|
// below leaves the parent non-remote instead of stuck remote-with-no-bridge
|
|
@@ -404,13 +293,15 @@ async function startOwnedRuntime(options = {}) {
|
|
|
404
293
|
try { forwarder.stopWatch(); } catch {}
|
|
405
294
|
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
406
295
|
// Roll back partial owner-side state advertised before connect() ran:
|
|
407
|
-
//
|
|
408
|
-
|
|
296
|
+
// disconnect the backend WE started (a post-connect startup step may have
|
|
297
|
+
// thrown while the gateway is live), then release the channel locks + clear
|
|
298
|
+
// the active-instance advert.
|
|
299
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
409
300
|
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
410
301
|
try { clearActiveInstance(instanceId); } catch {}
|
|
411
302
|
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
412
303
|
} finally {
|
|
413
|
-
|
|
304
|
+
settleInFlightStart();
|
|
414
305
|
}
|
|
415
306
|
}
|
|
416
307
|
async function stopOwnedRuntime(reason) {
|
|
@@ -437,7 +328,6 @@ async function stopOwnedRuntime(reason) {
|
|
|
437
328
|
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
438
329
|
// file handle + timers for the rest of the process lifetime.
|
|
439
330
|
try { forwarder.stopWatch(); } catch {}
|
|
440
|
-
stopOwnerHeartbeat();
|
|
441
331
|
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
442
332
|
scheduler.stop();
|
|
443
333
|
stopSnapshotWriter();
|
|
@@ -463,18 +353,9 @@ async function stopOwnedRuntime(reason) {
|
|
|
463
353
|
function refreshBridgeOwnershipSafe(options = {}) {
|
|
464
354
|
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
465
355
|
}
|
|
466
|
-
//
|
|
467
|
-
// (
|
|
468
|
-
|
|
469
|
-
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();
|
|
477
|
-
}
|
|
356
|
+
// Daemon model: no ownership timer or takeover handler. Kept as a no-op so the
|
|
357
|
+
// worker start() call site stays unchanged.
|
|
358
|
+
function armBridgeOwnershipTimer() {}
|
|
478
359
|
// Guarded IPC send to the parent: no-ops when there is no channel or it is
|
|
479
360
|
// already disconnected, and swallows both the synchronous throw and the async
|
|
480
361
|
// error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
|
|
@@ -489,20 +370,16 @@ function sendToParent(message) {
|
|
|
489
370
|
process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
|
|
490
371
|
}
|
|
491
372
|
}
|
|
492
|
-
// Tell the parent session
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
// Symmetric to notifyRemoteSuperseded: tell the parent session this worker
|
|
499
|
-
// ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
|
|
500
|
-
// Guarded by process.send so a manually-forked worker with no IPC parent is a
|
|
501
|
-
// no-op instead of crashing. Callers fire this only on a genuine claim
|
|
502
|
-
// transition (boot make-before-break, activate when not already owned) — never
|
|
503
|
-
// on a heartbeat refresh — so the parent's idempotent handler sees it once.
|
|
373
|
+
// Tell the parent session this worker ACQUIRED the bridge so it flips remote
|
|
374
|
+
// mode ON (badge/transcript writer). Callers fire this only on a genuine
|
|
375
|
+
// not-connected -> connected transition — never on a refresh — so the parent's
|
|
376
|
+
// idempotent handler sees it once. Cross-process supersede is a transport-level
|
|
377
|
+
// concern (daemon), not emitted here.
|
|
504
378
|
function notifyRemoteAcquired() {
|
|
505
|
-
|
|
379
|
+
// Sink-aware path so the daemon replays this to every TUI (the transport
|
|
380
|
+
// sticky-caches 'acquired'). Raw sendToParent would reach only the node-IPC
|
|
381
|
+
// spawner and be ignored.
|
|
382
|
+
sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
|
|
506
383
|
}
|
|
507
384
|
async function refreshBridgeOwnership(options = {}) {
|
|
508
385
|
// Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
|
|
@@ -510,56 +387,15 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
510
387
|
// instead of returning early and observing spurious auto-connect failure.
|
|
511
388
|
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
512
389
|
bridgeOwnershipRefreshInFlight = (async () => {
|
|
513
|
-
//
|
|
514
|
-
//
|
|
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.
|
|
390
|
+
// Daemon model: this runtime is the unconditional bridge owner, so refresh
|
|
391
|
+
// just keeps the owned runtime in sync with channelBridgeActive.
|
|
519
392
|
if (!getChannelBridgeActive()) {
|
|
520
393
|
if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
|
|
521
394
|
return;
|
|
522
395
|
}
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
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 {}
|
|
536
|
-
await startOwnedRuntime(options);
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
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
|
-
}
|
|
552
|
-
notifyRemoteSuperseded();
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
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);
|
|
562
|
-
}
|
|
396
|
+
// Active -> ensure the owned runtime is up (idempotent; early-returns when
|
|
397
|
+
// already connected).
|
|
398
|
+
await startOwnedRuntime(options);
|
|
563
399
|
})();
|
|
564
400
|
try {
|
|
565
401
|
return await bridgeOwnershipRefreshInFlight;
|
|
@@ -584,6 +420,12 @@ async function reloadRuntimeConfig() {
|
|
|
584
420
|
if (backendChanged) {
|
|
585
421
|
const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
|
|
586
422
|
if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
|
|
423
|
+
// A start that was in flight when stopOwnedRuntime landed was signalled to
|
|
424
|
+
// bail (and disconnects the OLD backend it was connecting). Wait for it to
|
|
425
|
+
// FULLY settle before issuing the fresh start below — otherwise
|
|
426
|
+
// startOwnedRuntime's `if (bridgeRuntimeStarting) return` guard would drop
|
|
427
|
+
// the restart and the NEW backend would never connect (lost-restart race).
|
|
428
|
+
if (_inFlightStart) { try { await _inFlightStart; } catch {} }
|
|
587
429
|
setBackend(nextBackend);
|
|
588
430
|
// The persisted routing channelId belongs to the OLD getBackend() (e.g. a
|
|
589
431
|
// Discord snowflake) and is meaningless for the new one — sending to it
|
|
@@ -603,6 +445,9 @@ async function reloadRuntimeConfig() {
|
|
|
603
445
|
state.transcriptPath = "";
|
|
604
446
|
});
|
|
605
447
|
} catch {}
|
|
448
|
+
// stopOwnedRuntime above tore the owned runtime down; a same-session reload
|
|
449
|
+
// reconnects the NEW backend here. The in-flight start (if any) has already
|
|
450
|
+
// settled above, so this start is not dropped by the in-flight guard.
|
|
606
451
|
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
607
452
|
} else if (nextBackend !== previousBackend) {
|
|
608
453
|
try { await nextBackend.disconnect?.(); } catch {}
|
|
@@ -622,6 +467,5 @@ async function reloadRuntimeConfig() {
|
|
|
622
467
|
armBridgeOwnershipTimer,
|
|
623
468
|
clearBridgeOwnershipTimer,
|
|
624
469
|
notifyRemoteAcquired,
|
|
625
|
-
notifyRemoteSuperseded,
|
|
626
470
|
};
|
|
627
471
|
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
// Bridge ownership snapshot +
|
|
2
|
-
// channels
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readActiveInstance,
|
|
9
|
-
refreshActiveInstance,
|
|
10
|
-
OWNER_HEARTBEAT_INTERVAL_MS = 5e3,
|
|
11
|
-
}) {
|
|
1
|
+
// Bridge ownership snapshot + ownership logging. Under the machine-global
|
|
2
|
+
// channels daemon (singleton-owner lock in src/standalone) there is exactly one
|
|
3
|
+
// runtime per machine, so this process is the unconditional bridge owner — the
|
|
4
|
+
// OS seat lock and its file heartbeat / last-wins CAS are retired.
|
|
5
|
+
// active-instance.json is now a pure metadata advert; it is no longer read to
|
|
6
|
+
// decide ownership.
|
|
7
|
+
function createOwnerHeartbeat() {
|
|
12
8
|
let lastOwnershipNote = "";
|
|
13
|
-
let ownerHeartbeatTimer = null;
|
|
14
9
|
|
|
15
10
|
function logOwnership(note) {
|
|
16
11
|
if (lastOwnershipNote === note) return;
|
|
@@ -19,65 +14,17 @@ function createOwnerHeartbeat({
|
|
|
19
14
|
`);
|
|
20
15
|
}
|
|
21
16
|
function currentOwnerState() {
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
active,
|
|
25
|
-
// Strict last-wins: this process owns the bridge ONLY when active-instance
|
|
26
|
-
// names exactly this INSTANCE_ID. A newer remote session that claims the
|
|
27
|
-
// seat overwrites instanceId, so the old owner immediately reads owned=false
|
|
28
|
-
// and disconnects on its next refresh tick. No PID/terminal fallback —
|
|
29
|
-
// that used to let a co-terminal worker wrongly self-claim.
|
|
30
|
-
owned: active?.instanceId === getInstanceId()
|
|
31
|
-
};
|
|
17
|
+
// Daemon singleton: this runtime always owns the bridge.
|
|
18
|
+
return { owned: true };
|
|
32
19
|
}
|
|
33
20
|
function getBridgeOwnershipSnapshot() {
|
|
34
21
|
return currentOwnerState();
|
|
35
22
|
}
|
|
36
|
-
function claimBridgeOwnership(reason, options = {}) {
|
|
37
|
-
// Returns true only when THIS instance actually holds the seat after the
|
|
38
|
-
// write. With options.timeoutMs:0 (try-once) a contended lock throws
|
|
39
|
-
// ELOCKCONTENDED — the caller catches it and treats the seat as busy.
|
|
40
|
-
const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
|
|
41
|
-
const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
|
|
42
|
-
const claimed = res?.instanceId === getInstanceId();
|
|
43
|
-
if (claimed) logOwnership(`claimed owner (${reason})`);
|
|
44
|
-
return claimed;
|
|
45
|
-
}
|
|
46
|
-
function startOwnerHeartbeat() {
|
|
47
|
-
if (ownerHeartbeatTimer) return;
|
|
48
|
-
ownerHeartbeatTimer = setInterval(() => {
|
|
49
|
-
try {
|
|
50
|
-
// Last-wins guard: only refresh the seat if we STILL own it. If a newer
|
|
51
|
-
// remote session claimed active-instance.json since our last tick, do
|
|
52
|
-
// NOT overwrite it back — that would re-steal ownership and cause
|
|
53
|
-
// ping-pong / double backend connections. The bridgeOwnershipTimer's
|
|
54
|
-
// refreshBridgeOwnership() will observe owned=false and disconnect us.
|
|
55
|
-
// onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
|
|
56
|
-
// owner claiming the seat between currentOwnerState() and the locked
|
|
57
|
-
// write is never overwritten (see refreshActiveInstance CAS guard).
|
|
58
|
-
// Try-once (timeoutMs:0): the heartbeat must never block on the
|
|
59
|
-
// active-instance lock. On contention refreshActiveInstance throws and
|
|
60
|
-
// we simply skip this tick — the next tick catches up.
|
|
61
|
-
if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
|
|
62
|
-
} catch (e) {
|
|
63
|
-
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
64
|
-
}
|
|
65
|
-
}, OWNER_HEARTBEAT_INTERVAL_MS);
|
|
66
|
-
ownerHeartbeatTimer.unref?.();
|
|
67
|
-
}
|
|
68
|
-
function stopOwnerHeartbeat() {
|
|
69
|
-
if (!ownerHeartbeatTimer) return;
|
|
70
|
-
clearInterval(ownerHeartbeatTimer);
|
|
71
|
-
ownerHeartbeatTimer = null;
|
|
72
|
-
}
|
|
73
23
|
|
|
74
24
|
return {
|
|
75
25
|
logOwnership,
|
|
76
26
|
currentOwnerState,
|
|
77
27
|
getBridgeOwnershipSnapshot,
|
|
78
|
-
claimBridgeOwnership,
|
|
79
|
-
startOwnerHeartbeat,
|
|
80
|
-
stopOwnerHeartbeat,
|
|
81
28
|
};
|
|
82
29
|
}
|
|
83
30
|
|
|
@@ -14,6 +14,16 @@ function normalizeChannelNotifyParams(method, params) {
|
|
|
14
14
|
return params;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Pluggable notify sink. Under the per-TUI fork model notifies flow over
|
|
18
|
+
// node-IPC to the parent (process.send). Under the machine-global daemon there
|
|
19
|
+
// is no IPC parent: the daemon entry installs a sink that routes each notify to
|
|
20
|
+
// the CORRECT attached TUI over the transport's SSE fan-out (targeted, never
|
|
21
|
+
// broadcast). When a sink is installed it fully replaces the process.send path.
|
|
22
|
+
let _notifySink = null;
|
|
23
|
+
function setChannelNotifySink(fn) {
|
|
24
|
+
_notifySink = typeof fn === 'function' ? fn : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
function createParentBridge({ getInstanceId }) {
|
|
18
28
|
function sendNotifyToParent(method, params) {
|
|
19
29
|
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
@@ -22,6 +32,11 @@ function createParentBridge({ getInstanceId }) {
|
|
|
22
32
|
// silent_to_agent stays boolean — an internal routing flag the daemon
|
|
23
33
|
// router / agentNotify consume (=== true) before the CC zod boundary.
|
|
24
34
|
const outParams = normalizeChannelNotifyParams(method, params);
|
|
35
|
+
if (_notifySink) {
|
|
36
|
+
try { _notifySink(method, outParams); }
|
|
37
|
+
catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
25
40
|
if (!process.send) {
|
|
26
41
|
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
27
42
|
return;
|
|
@@ -85,4 +100,4 @@ function createParentBridge({ getInstanceId }) {
|
|
|
85
100
|
};
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
export { createParentBridge, normalizeChannelNotifyParams };
|
|
103
|
+
export { createParentBridge, normalizeChannelNotifyParams, setChannelNotifySink };
|