mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { loadConfig, createBackend } from "./config.mjs";
|
|
3
|
+
import { WebhookServer } from "./webhook.mjs";
|
|
4
|
+
import { EventPipeline } from "./event-pipeline.mjs";
|
|
5
|
+
import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
|
|
6
|
+
import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
|
|
7
|
+
import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
|
|
8
|
+
import {
|
|
9
|
+
refreshActiveInstance,
|
|
10
|
+
releaseOwnedChannelLocks,
|
|
11
|
+
clearActiveInstance,
|
|
12
|
+
probeActiveOwner,
|
|
13
|
+
RUNTIME_ROOT,
|
|
14
|
+
} from "./runtime-paths.mjs";
|
|
15
|
+
// Owned-runtime lifecycle extracted from channels/index.mjs (behavior-
|
|
16
|
+
// preserving): bridge-ownership claim/refresh/loss, backend connect/disconnect,
|
|
17
|
+
// scheduler + webhook/event runtime, owner heartbeat gating, and config
|
|
18
|
+
// hot-reload. Owns its own in-flight flags + timers; shares config / backend /
|
|
19
|
+
// bridgeRuntimeConnected / webhookServer / eventPipeline with the worker via
|
|
20
|
+
// get/set so file-level reference semantics are preserved.
|
|
21
|
+
export function createOwnedRuntime({
|
|
22
|
+
getConfig,
|
|
23
|
+
setConfig,
|
|
24
|
+
getBackend,
|
|
25
|
+
setBackend,
|
|
26
|
+
getBridgeRuntimeConnected,
|
|
27
|
+
setBridgeRuntimeConnected,
|
|
28
|
+
getWebhookServer,
|
|
29
|
+
setWebhookServer,
|
|
30
|
+
getEventPipeline,
|
|
31
|
+
setEventPipeline,
|
|
32
|
+
getChannelBridgeActive,
|
|
33
|
+
instanceId,
|
|
34
|
+
TERMINAL_LEAD_PID,
|
|
35
|
+
forwarder,
|
|
36
|
+
scheduler,
|
|
37
|
+
statusState,
|
|
38
|
+
logOwnership,
|
|
39
|
+
currentOwnerState,
|
|
40
|
+
claimBridgeOwnership,
|
|
41
|
+
startOwnerHeartbeat,
|
|
42
|
+
stopOwnerHeartbeat,
|
|
43
|
+
bindPersistedTranscriptIfAny,
|
|
44
|
+
cancelPendingTranscriptRearm,
|
|
45
|
+
schedulePendingTranscriptRearm,
|
|
46
|
+
stopServerTyping,
|
|
47
|
+
wireWebhookHandlers,
|
|
48
|
+
wireEventQueueHandlers,
|
|
49
|
+
memoryDrainBuffer,
|
|
50
|
+
}) {
|
|
51
|
+
let bridgeRuntimeStarting = false;
|
|
52
|
+
let _ownedRuntimeStopRequested = false;
|
|
53
|
+
let bridgeOwnershipRefreshInFlight = null;
|
|
54
|
+
let bridgeOwnershipTimer = null;
|
|
55
|
+
let _memoryDrainTimer = null;
|
|
56
|
+
// Event-driven ownership signal: an fs.watch on the runtime dir fires the
|
|
57
|
+
// ownership refresh the instant active-instance.json changes (a newer owner
|
|
58
|
+
// claims, or the owner releases/clears it), instead of waiting up to 3s for
|
|
59
|
+
// the poll tick. This shrinks the double-owner window on takeover (the old
|
|
60
|
+
// owner observes owned=false and tears down in ms) and signals ownership
|
|
61
|
+
// loss to contenders immediately on release. The 3s timer stays as fallback.
|
|
62
|
+
let activeInstanceWatcher = null;
|
|
63
|
+
let _activeInstanceWatchDebounce = null;
|
|
64
|
+
function armActiveInstanceWatcher() {
|
|
65
|
+
if (activeInstanceWatcher) return;
|
|
66
|
+
try {
|
|
67
|
+
activeInstanceWatcher = fs.watch(RUNTIME_ROOT, { persistent: false }, (_event, filename) => {
|
|
68
|
+
if (filename && filename !== 'active-instance.json') return;
|
|
69
|
+
// Coalesce the burst of events an atomic rename/truncate emits.
|
|
70
|
+
if (_activeInstanceWatchDebounce) return;
|
|
71
|
+
_activeInstanceWatchDebounce = setTimeout(() => {
|
|
72
|
+
_activeInstanceWatchDebounce = null;
|
|
73
|
+
refreshBridgeOwnershipSafe();
|
|
74
|
+
}, 50);
|
|
75
|
+
_activeInstanceWatchDebounce.unref?.();
|
|
76
|
+
});
|
|
77
|
+
// fs.watch emits 'error' (and an unhandled one CRASHES the worker) when
|
|
78
|
+
// the watched dir is removed or the handle is invalidated (common on
|
|
79
|
+
// Windows). Close the dead handle and fall back to the 3s poll, which is
|
|
80
|
+
// still armed — the event signal is a latency optimization, never the
|
|
81
|
+
// sole ownership mechanism.
|
|
82
|
+
activeInstanceWatcher.on('error', (err) => {
|
|
83
|
+
process.stderr.write(`[ownership] active-instance watch error, falling back to poll: ${err?.message || err}\n`);
|
|
84
|
+
clearActiveInstanceWatcher();
|
|
85
|
+
});
|
|
86
|
+
activeInstanceWatcher.unref?.();
|
|
87
|
+
} catch { activeInstanceWatcher = null; }
|
|
88
|
+
}
|
|
89
|
+
function clearActiveInstanceWatcher() {
|
|
90
|
+
if (_activeInstanceWatchDebounce) { clearTimeout(_activeInstanceWatchDebounce); _activeInstanceWatchDebounce = null; }
|
|
91
|
+
if (activeInstanceWatcher) { try { activeInstanceWatcher.close(); } catch {} activeInstanceWatcher = null; }
|
|
92
|
+
}
|
|
93
|
+
function clearBridgeOwnershipTimer() {
|
|
94
|
+
if (bridgeOwnershipTimer) {
|
|
95
|
+
clearInterval(bridgeOwnershipTimer);
|
|
96
|
+
bridgeOwnershipTimer = null;
|
|
97
|
+
}
|
|
98
|
+
clearActiveInstanceWatcher();
|
|
99
|
+
}
|
|
100
|
+
function shouldStartEventPipelineRuntime() {
|
|
101
|
+
return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
|
|
102
|
+
}
|
|
103
|
+
function ensureEventPipelineRuntime() {
|
|
104
|
+
if (!getEventPipeline()) {
|
|
105
|
+
setEventPipeline(new EventPipeline(getConfig().events, getConfig().channelId));
|
|
106
|
+
wireEventQueueHandlers(getEventPipeline().getQueue());
|
|
107
|
+
}
|
|
108
|
+
return getEventPipeline();
|
|
109
|
+
}
|
|
110
|
+
function ensureWebhookServerRuntime() {
|
|
111
|
+
if (!getWebhookServer()) {
|
|
112
|
+
setWebhookServer(new WebhookServer(getConfig().webhook));
|
|
113
|
+
}
|
|
114
|
+
wireWebhookHandlers();
|
|
115
|
+
return getWebhookServer();
|
|
116
|
+
}
|
|
117
|
+
async function stopWebhookAndEventRuntime() {
|
|
118
|
+
if (getWebhookServer()) {
|
|
119
|
+
await getWebhookServer().stop();
|
|
120
|
+
setWebhookServer(null);
|
|
121
|
+
}
|
|
122
|
+
if (getEventPipeline()) {
|
|
123
|
+
getEventPipeline().stop();
|
|
124
|
+
setEventPipeline(null);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
|
|
128
|
+
if (shouldStartEventPipelineRuntime()) {
|
|
129
|
+
const pipeline = ensureEventPipelineRuntime();
|
|
130
|
+
if (reload) {
|
|
131
|
+
pipeline.reloadConfig(getConfig().events, getConfig().channelId);
|
|
132
|
+
wireEventQueueHandlers(pipeline.getQueue());
|
|
133
|
+
}
|
|
134
|
+
pipeline.start();
|
|
135
|
+
} else if (getEventPipeline()) {
|
|
136
|
+
getEventPipeline().stop();
|
|
137
|
+
setEventPipeline(null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (getConfig().webhook?.enabled === true) {
|
|
141
|
+
const server = ensureWebhookServerRuntime();
|
|
142
|
+
if (reload) {
|
|
143
|
+
// server.reloadConfig is async (it awaits the current server's
|
|
144
|
+
// close() before re-listening). Chain start() onto its resolution
|
|
145
|
+
// so we don't race the bound port — calling start() synchronously
|
|
146
|
+
// here would re-listen before close() finishes and surface
|
|
147
|
+
// EADDRINUSE on the same port.
|
|
148
|
+
server.reloadConfig(getConfig().webhook, {
|
|
149
|
+
autoStart: false
|
|
150
|
+
}).then(() => {
|
|
151
|
+
// A stopWebhookAndEventRuntime() / deactivate landing during the async
|
|
152
|
+
// close()+reload window nulls out getWebhookServer() (and webhook.enabled may
|
|
153
|
+
// have flipped off). Without this guard the resolved continuation would
|
|
154
|
+
// re-listen and resurrect an orphan listener that no teardown tracks.
|
|
155
|
+
if (getWebhookServer() !== server || getConfig().webhook?.enabled !== true) {
|
|
156
|
+
try { server.stop(); } catch {}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
wireWebhookHandlers();
|
|
160
|
+
server.start();
|
|
161
|
+
}).catch((err) => {
|
|
162
|
+
process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
server.start();
|
|
166
|
+
}
|
|
167
|
+
} else if (getWebhookServer()) {
|
|
168
|
+
getWebhookServer().stop();
|
|
169
|
+
setWebhookServer(null);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let _ownedRuntimeSelfHealing = false;
|
|
173
|
+
// Explicit restore path for an already-connected owner. The 3s ownership tick
|
|
174
|
+
// must not run this implicitly: re-binding status on every tick can move the
|
|
175
|
+
// transcript cursor to EOF, and probing the gateway during Discord's own
|
|
176
|
+
// reconnect window can reset a healthy reconnect loop. Keep this for explicit
|
|
177
|
+
// activation/reload recovery only.
|
|
178
|
+
async function selfHealOwnedRuntime(options = {}) {
|
|
179
|
+
const shouldRestoreBinding = options.restoreBinding === true;
|
|
180
|
+
const shouldResetBackend = options.resetBackend === true;
|
|
181
|
+
if (!shouldRestoreBinding && !shouldResetBackend) return;
|
|
182
|
+
if (_ownedRuntimeSelfHealing) return;
|
|
183
|
+
_ownedRuntimeSelfHealing = true;
|
|
184
|
+
try {
|
|
185
|
+
if (shouldRestoreBinding) {
|
|
186
|
+
await bindPersistedTranscriptIfAny().catch((e) => {
|
|
187
|
+
process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (shouldResetBackend && typeof getBackend()?._resetClient === "function") {
|
|
191
|
+
await getBackend()._resetClient().catch((e) => {
|
|
192
|
+
process.stderr.write(`mixdog: self-heal getBackend() reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Nudge the forwarder to drain anything the rebind/reconnect surfaced.
|
|
196
|
+
void forwarder.forwardNewText().catch(() => {});
|
|
197
|
+
} finally {
|
|
198
|
+
_ownedRuntimeSelfHealing = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function startOwnedRuntime(options = {}) {
|
|
202
|
+
if (getBridgeRuntimeConnected()) {
|
|
203
|
+
if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (bridgeRuntimeStarting) return;
|
|
207
|
+
if (!getChannelBridgeActive()) return;
|
|
208
|
+
bridgeRuntimeStarting = true;
|
|
209
|
+
_ownedRuntimeStopRequested = false;
|
|
210
|
+
// Capture the getBackend() instance that THIS start operation will connect. A
|
|
211
|
+
// reloadRuntimeConfig() hot-swap can replace the global `getBackend()` while this
|
|
212
|
+
// start is still awaiting connect(); using the captured instance for both
|
|
213
|
+
// connect() and the bail-path disconnect() guarantees we tear down the
|
|
214
|
+
// getBackend() WE started (not the freshly-swapped one), closing the
|
|
215
|
+
// both-backends-live window.
|
|
216
|
+
const startingBackend = getBackend();
|
|
217
|
+
const claimAfterReady = options.claimAfterReady === true;
|
|
218
|
+
// Auto-start intent: claim the seat ONLY if it is vacant/stale (never steal a
|
|
219
|
+
// live owner). Threaded from worker start() (MIXDOG_REMOTE_INTENT=auto).
|
|
220
|
+
const claimIfVacant = options.claimIfVacant === true;
|
|
221
|
+
// Single-holder correctness: the seat is claimed BEFORE getBackend().connect(),
|
|
222
|
+
// never after. The old make-before-break (claim-after-ready) boot left two
|
|
223
|
+
// gateways connected and contending during the multi-second connect window;
|
|
224
|
+
// claiming first makes the previous owner observe owned=false on its next
|
|
225
|
+
// tick and drain+disconnect, so at most one gateway ever serves.
|
|
226
|
+
// - boot (claimAfterReady): last-wins acquire — this new remote session
|
|
227
|
+
// takes the seat outright.
|
|
228
|
+
// - refresh/owned-path: CAS onlyIfOwned — abort fast if the seat moved.
|
|
229
|
+
// backendReady=false marks the partial state until getBackend().connect() succeeds.
|
|
230
|
+
// Wrapped so ANY throw in the pre-connect claim (lock contention/error)
|
|
231
|
+
// resets bridgeRuntimeStarting — otherwise a transient lock error would
|
|
232
|
+
// leave it stuck true and permanently block every future ownership attempt.
|
|
233
|
+
try {
|
|
234
|
+
if (claimAfterReady) {
|
|
235
|
+
if (claimIfVacant) {
|
|
236
|
+
// Auto-start claim-if-vacant. Probe first: a live owner other than us
|
|
237
|
+
// holds the seat -> back off SILENTLY (no claim, no acquire notify) so
|
|
238
|
+
// this session stays non-remote. Then claim atomically via the
|
|
239
|
+
// onlyIfVacant CAS (guards the probe->write TOCTOU: a live owner landing
|
|
240
|
+
// in that gap aborts the write, leaving the seat untouched).
|
|
241
|
+
const probe = probeActiveOwner();
|
|
242
|
+
if (probe.status === 'live' && probe.state?.instanceId && probe.state.instanceId !== instanceId) {
|
|
243
|
+
bridgeRuntimeStarting = false;
|
|
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
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
bridgeRuntimeStarting = false;
|
|
280
|
+
process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
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
|
+
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
291
|
+
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
292
|
+
// drainBuffer(); unref'd so it never holds the worker open.
|
|
293
|
+
if (!_memoryDrainTimer) {
|
|
294
|
+
_memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
|
|
295
|
+
_memoryDrainTimer.unref?.();
|
|
296
|
+
}
|
|
297
|
+
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
298
|
+
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
299
|
+
// webhook/binding launches below would revive owner state after stop).
|
|
300
|
+
// Idempotent: stop's sync teardown already ran; re-running disconnect +
|
|
301
|
+
// teardown is safe and covers both the pre-connected window (stop could
|
|
302
|
+
// not disconnect an in-flight getBackend()) and the post-connected window
|
|
303
|
+
// (stop did disconnect; redo to be defensive).
|
|
304
|
+
const bailIfStopRequested = async () => {
|
|
305
|
+
if (!_ownedRuntimeStopRequested) return false;
|
|
306
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
307
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
308
|
+
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
309
|
+
try { clearActiveInstance(instanceId); } catch {}
|
|
310
|
+
setBridgeRuntimeConnected(false);
|
|
311
|
+
_ownedRuntimeStopRequested = false;
|
|
312
|
+
return true;
|
|
313
|
+
};
|
|
314
|
+
const restoreBinding = options.restoreBinding !== false;
|
|
315
|
+
const bindPersistedTranscriptTask = restoreBinding
|
|
316
|
+
? bindPersistedTranscriptIfAny().catch((e) => {
|
|
317
|
+
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
318
|
+
})
|
|
319
|
+
: null;
|
|
320
|
+
// Await getBackend().connect() so callers (and bindingReady) only resolve after
|
|
321
|
+
// the Discord binding is real. Previously this was fire-and-forget and
|
|
322
|
+
// refreshBridgeOwnership returned immediately, letting bindingReady fire
|
|
323
|
+
// before getBackend() listeners were attached.
|
|
324
|
+
try {
|
|
325
|
+
await startingBackend.connect();
|
|
326
|
+
if (await bailIfStopRequested()) {
|
|
327
|
+
cancelPendingTranscriptRearm();
|
|
328
|
+
try { forwarder.stopWatch(); } catch {}
|
|
329
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Post-connect ownership confirm (CAS). The seat was claimed BEFORE
|
|
333
|
+
// connect; a newer session may have superseded us during the connect
|
|
334
|
+
// window (the 3s tick would already be tearing us down). onlyIfOwned:
|
|
335
|
+
// never re-steal here. If the CAS aborts we LOST the seat — disconnect the
|
|
336
|
+
// getBackend() WE connected and mark the bridge runtime not connected (do NOT
|
|
337
|
+
// clear active-instance; the newer owner holds it).
|
|
338
|
+
let ownConfirm;
|
|
339
|
+
try {
|
|
340
|
+
ownConfirm = refreshActiveInstance(instanceId, { backendReady: true }, { onlyIfOwned: true });
|
|
341
|
+
} catch { ownConfirm = null; }
|
|
342
|
+
if (ownConfirm?.instanceId !== instanceId) {
|
|
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
|
+
}
|
|
354
|
+
setBridgeRuntimeConnected(true);
|
|
355
|
+
// Fresh confirmed ownership — tell the parent it holds the seat so it flips
|
|
356
|
+
// remote ON. Reached ONLY on a not-connected -> connected transition (the
|
|
357
|
+
// top-of-fn early-return skips already-connected re-ticks), so this fires
|
|
358
|
+
// exactly once per acquire and covers EVERY win path, not just boot:
|
|
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).
|
|
362
|
+
// The parent's acquired handler is idempotent (no-op when already remote),
|
|
363
|
+
// so notifying post-connect — never pre-connect — means a connect FAILURE
|
|
364
|
+
// below leaves the parent non-remote instead of stuck remote-with-no-bridge
|
|
365
|
+
// (finding 2).
|
|
366
|
+
notifyRemoteAcquired();
|
|
367
|
+
// initProviders must complete before scheduler.start() — otherwise the
|
|
368
|
+
// scheduler's first fire can land before the registry is populated and
|
|
369
|
+
// return `Provider "<name>" not found or not enabled`. The previous
|
|
370
|
+
// fire-and-forget call let scheduler.start() race ahead of init.
|
|
371
|
+
try {
|
|
372
|
+
const agentCfg = loadAgentConfig();
|
|
373
|
+
await initProviders(agentCfg.providers || {});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
376
|
+
}
|
|
377
|
+
if (await bailIfStopRequested()) {
|
|
378
|
+
cancelPendingTranscriptRearm();
|
|
379
|
+
try { forwarder.stopWatch(); } catch {}
|
|
380
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
scheduler.start();
|
|
384
|
+
startSnapshotWriter(scheduler);
|
|
385
|
+
syncOwnedWebhookAndEventRuntime();
|
|
386
|
+
if (restoreBinding) {
|
|
387
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
388
|
+
const pendingTranscriptPath = forwarder.transcriptPath;
|
|
389
|
+
if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
|
|
390
|
+
// Pre-connect bind may have armed rearm while !getBridgeRuntimeConnected();
|
|
391
|
+
// the first tick then exits without rescheduling. Re-arm now that we own.
|
|
392
|
+
schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
|
|
393
|
+
} else {
|
|
394
|
+
void forwarder.forwardNewText().catch((err) => {
|
|
395
|
+
process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
process.stderr.write(`mixdog: running with ${getBackend().name} getBackend()\n`);
|
|
400
|
+
logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
process.stderr.write(`mixdog: getBackend() connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
403
|
+
cancelPendingTranscriptRearm();
|
|
404
|
+
try { forwarder.stopWatch(); } catch {}
|
|
405
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
406
|
+
// Roll back partial owner-side state advertised before connect() ran:
|
|
407
|
+
// heartbeat and active-instance entry.
|
|
408
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
409
|
+
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
410
|
+
try { clearActiveInstance(instanceId); } catch {}
|
|
411
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
412
|
+
} finally {
|
|
413
|
+
bridgeRuntimeStarting = false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function stopOwnedRuntime(reason) {
|
|
417
|
+
// Cancel any pending transcript re-arm poll BEFORE the connected/starting
|
|
418
|
+
// early-return below. Otherwise a poll armed against a not-yet-existing
|
|
419
|
+
// transcript could fire after teardown and reinstall the fs.watch handle
|
|
420
|
+
// (startWatch is not owner-gated), leaking a live watcher past shutdown.
|
|
421
|
+
cancelPendingTranscriptRearm();
|
|
422
|
+
// startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
|
|
423
|
+
// claims channel locks BEFORE awaiting getBackend().connect(). If shutdown lands
|
|
424
|
+
// during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
|
|
425
|
+
// still false) we still need to tear that partial state down — otherwise
|
|
426
|
+
// the port stays bound and active-instance.json stays stale.
|
|
427
|
+
if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting) return;
|
|
428
|
+
// If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
|
|
429
|
+
// startOwnedRuntime() to abort right after its getBackend().connect() resolves.
|
|
430
|
+
// Without this the in-flight start re-marks connected and re-launches
|
|
431
|
+
// scheduler/webhook/heartbeat after we tear them down here.
|
|
432
|
+
if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
|
|
433
|
+
const wasConnected = getBridgeRuntimeConnected();
|
|
434
|
+
stopServerTyping();
|
|
435
|
+
// Release the transcript fs.watch handle plus the forwarder's debounce/retry
|
|
436
|
+
// timers on standby. Without this the watcher keeps firing scheduleWatchFlush
|
|
437
|
+
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
438
|
+
// file handle + timers for the rest of the process lifetime.
|
|
439
|
+
try { forwarder.stopWatch(); } catch {}
|
|
440
|
+
stopOwnerHeartbeat();
|
|
441
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
442
|
+
scheduler.stop();
|
|
443
|
+
stopSnapshotWriter();
|
|
444
|
+
await stopWebhookAndEventRuntime();
|
|
445
|
+
releaseOwnedChannelLocks(instanceId);
|
|
446
|
+
clearActiveInstance(instanceId);
|
|
447
|
+
try {
|
|
448
|
+
// Only disconnect the getBackend() when connect() actually completed; calling
|
|
449
|
+
// disconnect() mid-connect races the connect promise.
|
|
450
|
+
if (wasConnected) {
|
|
451
|
+
// Drain in-flight outbound sends before disconnecting so a handoff
|
|
452
|
+
// (owned=false observed → ownership lost) never cuts off a reply
|
|
453
|
+
// mid-delivery. Bounded inside drainPendingSends so a wedged send can
|
|
454
|
+
// not stall teardown — we still disconnect promptly.
|
|
455
|
+
try { await getBackend().drainPendingSends?.(); } catch {}
|
|
456
|
+
await getBackend().disconnect();
|
|
457
|
+
}
|
|
458
|
+
} finally {
|
|
459
|
+
setBridgeRuntimeConnected(false);
|
|
460
|
+
logOwnership(`standby: ${reason}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function refreshBridgeOwnershipSafe(options = {}) {
|
|
464
|
+
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
465
|
+
}
|
|
466
|
+
// Ownership-loss / re-acquire detection timer. Armed BEFORE getBackend().connect()
|
|
467
|
+
// (from startOwnedRuntime) so a session superseded during the connect window is
|
|
468
|
+
// torn down promptly, and re-armed idempotently on every start path.
|
|
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
|
+
}
|
|
478
|
+
// Guarded IPC send to the parent: no-ops when there is no channel or it is
|
|
479
|
+
// already disconnected, and swallows both the synchronous throw and the async
|
|
480
|
+
// error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
|
|
481
|
+
// connected check and delivery). Log-and-continue — never crash the worker.
|
|
482
|
+
function sendToParent(message) {
|
|
483
|
+
if (!process.send || process.connected === false) return;
|
|
484
|
+
try {
|
|
485
|
+
process.send(message, undefined, undefined, err => {
|
|
486
|
+
if (err) process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`);
|
|
487
|
+
});
|
|
488
|
+
} catch (err) {
|
|
489
|
+
process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
493
|
+
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
494
|
+
// exactly one session holds remote; losers fully release, no handover.
|
|
495
|
+
function notifyRemoteSuperseded() {
|
|
496
|
+
sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
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.
|
|
504
|
+
function notifyRemoteAcquired() {
|
|
505
|
+
sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
|
|
506
|
+
}
|
|
507
|
+
async function refreshBridgeOwnership(options = {}) {
|
|
508
|
+
// Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
|
|
509
|
+
// calls landing during normal login wait for the same connect attempt
|
|
510
|
+
// instead of returning early and observing spurious auto-connect failure.
|
|
511
|
+
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
512
|
+
bridgeOwnershipRefreshInFlight = (async () => {
|
|
513
|
+
// Opt-in remote, single-owner, last-wins. Only a remote session with an
|
|
514
|
+
// active bridge participates. If this instance is the active owner (its
|
|
515
|
+
// instanceId is the one advertised in active-instance.json) it ensures
|
|
516
|
+
// the owned runtime is up. If a newer remote session has since claimed
|
|
517
|
+
// ownership (last-wins overwrite), this instance is no longer owner and
|
|
518
|
+
// quietly tears its getBackend() down on the next tick. No proxy, no steal.
|
|
519
|
+
if (!getChannelBridgeActive()) {
|
|
520
|
+
if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Non-blocking ownership probe (read-only, no lock): distinguishes a live
|
|
524
|
+
// owner from a genuinely empty/stale seat from an INDETERMINATE read (a
|
|
525
|
+
// concurrent atomic rename yields partial content). "Locked/unreadable =
|
|
526
|
+
// busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
|
|
527
|
+
// never claim a seat we merely failed to read (double-owner guard).
|
|
528
|
+
const probe = probeActiveOwner();
|
|
529
|
+
if (probe.status === 'unknown') return;
|
|
530
|
+
const owned = probe.status === 'live' && probe.state?.instanceId === instanceId;
|
|
531
|
+
if (owned) {
|
|
532
|
+
// Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
|
|
533
|
+
// and skip the metadata touch — never block the tick. We still own, so
|
|
534
|
+
// ensure/keep the owned runtime up.
|
|
535
|
+
try { refreshActiveInstance(instanceId, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
|
|
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
|
+
}
|
|
563
|
+
})();
|
|
564
|
+
try {
|
|
565
|
+
return await bridgeOwnershipRefreshInFlight;
|
|
566
|
+
} finally {
|
|
567
|
+
bridgeOwnershipRefreshInFlight = null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function reloadRuntimeConfig() {
|
|
572
|
+
const previousBackend = getBackend();
|
|
573
|
+
const previousBackendName = previousBackend?.name || "";
|
|
574
|
+
setConfig(loadConfig());
|
|
575
|
+
scheduler.reloadConfig(
|
|
576
|
+
getConfig().nonInteractive ?? [],
|
|
577
|
+
getConfig().interactive ?? [],
|
|
578
|
+
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
579
|
+
getConfig().channelId,
|
|
580
|
+
{ restart: getBridgeRuntimeConnected() }
|
|
581
|
+
);
|
|
582
|
+
const nextBackend = createBackend(getConfig());
|
|
583
|
+
const backendChanged = (nextBackend?.name || "") !== previousBackendName;
|
|
584
|
+
if (backendChanged) {
|
|
585
|
+
const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
|
|
586
|
+
if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
|
|
587
|
+
setBackend(nextBackend);
|
|
588
|
+
// The persisted routing channelId belongs to the OLD getBackend() (e.g. a
|
|
589
|
+
// Discord snowflake) and is meaningless for the new one — sending to it
|
|
590
|
+
// would 400 "chat not found". There is no id mapping between platforms, so
|
|
591
|
+
// CLEAR the stale binding: drop the forwarder's context + watcher and wipe
|
|
592
|
+
// status.channelId/transcriptPath. The next inbound from the new getBackend()
|
|
593
|
+
// rebinds the correct chat via applyTranscriptBinding(). Only done on
|
|
594
|
+
// backendChanged — same-getBackend() reloads keep their binding untouched.
|
|
595
|
+
// (active-instance is cleared by stopOwnedRuntime on the restart path; we
|
|
596
|
+
// don't re-advertise here to avoid resurrecting a just-cleared entry.)
|
|
597
|
+
try { forwarder.stopWatch(); } catch {}
|
|
598
|
+
forwarder.channelId = "";
|
|
599
|
+
forwarder.transcriptPath = "";
|
|
600
|
+
try {
|
|
601
|
+
statusState.update((state) => {
|
|
602
|
+
state.channelId = "";
|
|
603
|
+
state.transcriptPath = "";
|
|
604
|
+
});
|
|
605
|
+
} catch {}
|
|
606
|
+
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
607
|
+
} else if (nextBackend !== previousBackend) {
|
|
608
|
+
try { await nextBackend.disconnect?.(); } catch {}
|
|
609
|
+
}
|
|
610
|
+
if (getBridgeRuntimeConnected()) {
|
|
611
|
+
syncOwnedWebhookAndEventRuntime({ reload: true });
|
|
612
|
+
} else {
|
|
613
|
+
await stopWebhookAndEventRuntime();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
startOwnedRuntime,
|
|
618
|
+
stopOwnedRuntime,
|
|
619
|
+
refreshBridgeOwnership,
|
|
620
|
+
refreshBridgeOwnershipSafe,
|
|
621
|
+
reloadRuntimeConfig,
|
|
622
|
+
armBridgeOwnershipTimer,
|
|
623
|
+
clearBridgeOwnershipTimer,
|
|
624
|
+
notifyRemoteAcquired,
|
|
625
|
+
notifyRemoteSuperseded,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
@@ -279,6 +279,14 @@ function refreshActiveInstance(instanceId, meta, options) {
|
|
|
279
279
|
if (options?.onlyIfOwned && (!prev?.instanceId || prev.instanceId !== instanceId)) {
|
|
280
280
|
return undefined;
|
|
281
281
|
}
|
|
282
|
+
// CAS guard (opt-in via options.onlyIfVacant): auto-start claim-if-vacant.
|
|
283
|
+
// Abort the write when a live (non-stale) owner OTHER than us already holds
|
|
284
|
+
// the seat — an auto-start must never steal from a live owner. A stale/dead
|
|
285
|
+
// prior owner leaves prev=null above, so it does NOT block the claim; an
|
|
286
|
+
// absent seat (prev=null) is claimable too.
|
|
287
|
+
if (options?.onlyIfVacant && prev?.instanceId && prev.instanceId !== instanceId) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
282
290
|
// Drop stale fields (pid/startedAt) written by older server versions.
|
|
283
291
|
const { pid: _legacyPid, startedAt: _legacyStartedAt, ...prevRest } = prev ?? {};
|
|
284
292
|
const identity = buildRuntimeIdentity();
|
|
@@ -393,18 +401,21 @@ function refreshActiveInstance(instanceId, meta, options) {
|
|
|
393
401
|
}
|
|
394
402
|
}
|
|
395
403
|
}
|
|
396
|
-
//
|
|
397
|
-
// the seat
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
+
// Headless worker-owned seat: THIS process is the channels worker AND no
|
|
405
|
+
// distinct terminal lead owns the seat (ownerLeadPid resolves to our own
|
|
406
|
+
// pid). A worker never runs the TUI render loop, so it owns no heartbeat —
|
|
407
|
+
// it must NOT keep refreshing a ui_heartbeat_at it does not own. An
|
|
408
|
+
// inherited value (written by a prior TUI session, carried forward in
|
|
409
|
+
// prevRest) would otherwise be perpetually renewed here and falsely mask a
|
|
410
|
+
// dead render loop, so DROP it and let pid-only judgment stand.
|
|
411
|
+
// When a distinct terminal lead owns the seat (ownerLeadPid !== our pid),
|
|
412
|
+
// this branch is a no-op: the TUI's own heartbeat is carried forward
|
|
413
|
+
// untouched, so a stale (zombie) render loop still evicts the seat even
|
|
414
|
+
// while the worker pid is alive.
|
|
404
415
|
const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
|
|
405
416
|
&& identity.ownerLeadPid === process.pid;
|
|
406
417
|
if (workerOwnsSeat) {
|
|
407
|
-
next.ui_heartbeat_at
|
|
418
|
+
delete next.ui_heartbeat_at;
|
|
408
419
|
}
|
|
409
420
|
return { ...preservedExtra, ...next };
|
|
410
421
|
}, writeOpts);
|