mixdog 0.9.19 → 0.9.20
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 +93 -29
- package/package.json +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- 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/recall-usecase-cases.json +1 -1
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +150 -45
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- 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 +547 -0
- 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 +771 -0
- package/src/runtime/memory/index.mjs +73 -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 +772 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- 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/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- 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 +2046 -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 +4 -0
- package/src/standalone/memory-runtime-proxy.mjs +56 -6
- package/src/tui/App.jsx +39 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- 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 +1990 -1527
- 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 +545 -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,547 @@
|
|
|
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
|
+
} from "./runtime-paths.mjs";
|
|
14
|
+
// Owned-runtime lifecycle extracted from channels/index.mjs (behavior-
|
|
15
|
+
// preserving): bridge-ownership claim/refresh/loss, backend connect/disconnect,
|
|
16
|
+
// scheduler + webhook/event runtime, owner heartbeat gating, and config
|
|
17
|
+
// hot-reload. Owns its own in-flight flags + timers; shares config / backend /
|
|
18
|
+
// bridgeRuntimeConnected / webhookServer / eventPipeline with the worker via
|
|
19
|
+
// get/set so file-level reference semantics are preserved.
|
|
20
|
+
export function createOwnedRuntime({
|
|
21
|
+
getConfig,
|
|
22
|
+
setConfig,
|
|
23
|
+
getBackend,
|
|
24
|
+
setBackend,
|
|
25
|
+
getBridgeRuntimeConnected,
|
|
26
|
+
setBridgeRuntimeConnected,
|
|
27
|
+
getWebhookServer,
|
|
28
|
+
setWebhookServer,
|
|
29
|
+
getEventPipeline,
|
|
30
|
+
setEventPipeline,
|
|
31
|
+
getChannelBridgeActive,
|
|
32
|
+
instanceId,
|
|
33
|
+
TERMINAL_LEAD_PID,
|
|
34
|
+
forwarder,
|
|
35
|
+
scheduler,
|
|
36
|
+
statusState,
|
|
37
|
+
logOwnership,
|
|
38
|
+
currentOwnerState,
|
|
39
|
+
claimBridgeOwnership,
|
|
40
|
+
startOwnerHeartbeat,
|
|
41
|
+
stopOwnerHeartbeat,
|
|
42
|
+
bindPersistedTranscriptIfAny,
|
|
43
|
+
cancelPendingTranscriptRearm,
|
|
44
|
+
schedulePendingTranscriptRearm,
|
|
45
|
+
stopServerTyping,
|
|
46
|
+
wireWebhookHandlers,
|
|
47
|
+
wireEventQueueHandlers,
|
|
48
|
+
memoryDrainBuffer,
|
|
49
|
+
}) {
|
|
50
|
+
let bridgeRuntimeStarting = false;
|
|
51
|
+
let _ownedRuntimeStopRequested = false;
|
|
52
|
+
let bridgeOwnershipRefreshInFlight = null;
|
|
53
|
+
let bridgeOwnershipTimer = null;
|
|
54
|
+
let _memoryDrainTimer = null;
|
|
55
|
+
function clearBridgeOwnershipTimer() {
|
|
56
|
+
if (bridgeOwnershipTimer) {
|
|
57
|
+
clearInterval(bridgeOwnershipTimer);
|
|
58
|
+
bridgeOwnershipTimer = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function shouldStartEventPipelineRuntime() {
|
|
62
|
+
return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
|
|
63
|
+
}
|
|
64
|
+
function ensureEventPipelineRuntime() {
|
|
65
|
+
if (!getEventPipeline()) {
|
|
66
|
+
setEventPipeline(new EventPipeline(getConfig().events, getConfig().channelId));
|
|
67
|
+
wireEventQueueHandlers(getEventPipeline().getQueue());
|
|
68
|
+
}
|
|
69
|
+
return getEventPipeline();
|
|
70
|
+
}
|
|
71
|
+
function ensureWebhookServerRuntime() {
|
|
72
|
+
if (!getWebhookServer()) {
|
|
73
|
+
setWebhookServer(new WebhookServer(getConfig().webhook));
|
|
74
|
+
}
|
|
75
|
+
wireWebhookHandlers();
|
|
76
|
+
return getWebhookServer();
|
|
77
|
+
}
|
|
78
|
+
async function stopWebhookAndEventRuntime() {
|
|
79
|
+
if (getWebhookServer()) {
|
|
80
|
+
await getWebhookServer().stop();
|
|
81
|
+
setWebhookServer(null);
|
|
82
|
+
}
|
|
83
|
+
if (getEventPipeline()) {
|
|
84
|
+
getEventPipeline().stop();
|
|
85
|
+
setEventPipeline(null);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
|
|
89
|
+
if (shouldStartEventPipelineRuntime()) {
|
|
90
|
+
const pipeline = ensureEventPipelineRuntime();
|
|
91
|
+
if (reload) {
|
|
92
|
+
pipeline.reloadConfig(getConfig().events, getConfig().channelId);
|
|
93
|
+
wireEventQueueHandlers(pipeline.getQueue());
|
|
94
|
+
}
|
|
95
|
+
pipeline.start();
|
|
96
|
+
} else if (getEventPipeline()) {
|
|
97
|
+
getEventPipeline().stop();
|
|
98
|
+
setEventPipeline(null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (getConfig().webhook?.enabled === true) {
|
|
102
|
+
const server = ensureWebhookServerRuntime();
|
|
103
|
+
if (reload) {
|
|
104
|
+
// server.reloadConfig is async (it awaits the current server's
|
|
105
|
+
// close() before re-listening). Chain start() onto its resolution
|
|
106
|
+
// so we don't race the bound port — calling start() synchronously
|
|
107
|
+
// here would re-listen before close() finishes and surface
|
|
108
|
+
// EADDRINUSE on the same port.
|
|
109
|
+
server.reloadConfig(getConfig().webhook, {
|
|
110
|
+
autoStart: false
|
|
111
|
+
}).then(() => {
|
|
112
|
+
// A stopWebhookAndEventRuntime() / deactivate landing during the async
|
|
113
|
+
// close()+reload window nulls out getWebhookServer() (and webhook.enabled may
|
|
114
|
+
// have flipped off). Without this guard the resolved continuation would
|
|
115
|
+
// re-listen and resurrect an orphan listener that no teardown tracks.
|
|
116
|
+
if (getWebhookServer() !== server || getConfig().webhook?.enabled !== true) {
|
|
117
|
+
try { server.stop(); } catch {}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
wireWebhookHandlers();
|
|
121
|
+
server.start();
|
|
122
|
+
}).catch((err) => {
|
|
123
|
+
process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
server.start();
|
|
127
|
+
}
|
|
128
|
+
} else if (getWebhookServer()) {
|
|
129
|
+
getWebhookServer().stop();
|
|
130
|
+
setWebhookServer(null);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let _ownedRuntimeSelfHealing = false;
|
|
134
|
+
// Explicit restore path for an already-connected owner. The 3s ownership tick
|
|
135
|
+
// must not run this implicitly: re-binding status on every tick can move the
|
|
136
|
+
// transcript cursor to EOF, and probing the gateway during Discord's own
|
|
137
|
+
// reconnect window can reset a healthy reconnect loop. Keep this for explicit
|
|
138
|
+
// activation/reload recovery only.
|
|
139
|
+
async function selfHealOwnedRuntime(options = {}) {
|
|
140
|
+
const shouldRestoreBinding = options.restoreBinding === true;
|
|
141
|
+
const shouldResetBackend = options.resetBackend === true;
|
|
142
|
+
if (!shouldRestoreBinding && !shouldResetBackend) return;
|
|
143
|
+
if (_ownedRuntimeSelfHealing) return;
|
|
144
|
+
_ownedRuntimeSelfHealing = true;
|
|
145
|
+
try {
|
|
146
|
+
if (shouldRestoreBinding) {
|
|
147
|
+
await bindPersistedTranscriptIfAny().catch((e) => {
|
|
148
|
+
process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (shouldResetBackend && typeof getBackend()?._resetClient === "function") {
|
|
152
|
+
await getBackend()._resetClient().catch((e) => {
|
|
153
|
+
process.stderr.write(`mixdog: self-heal getBackend() reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Nudge the forwarder to drain anything the rebind/reconnect surfaced.
|
|
157
|
+
void forwarder.forwardNewText().catch(() => {});
|
|
158
|
+
} finally {
|
|
159
|
+
_ownedRuntimeSelfHealing = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function startOwnedRuntime(options = {}) {
|
|
163
|
+
if (getBridgeRuntimeConnected()) {
|
|
164
|
+
if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (bridgeRuntimeStarting) return;
|
|
168
|
+
if (!getChannelBridgeActive()) return;
|
|
169
|
+
bridgeRuntimeStarting = true;
|
|
170
|
+
_ownedRuntimeStopRequested = false;
|
|
171
|
+
// Capture the getBackend() instance that THIS start operation will connect. A
|
|
172
|
+
// reloadRuntimeConfig() hot-swap can replace the global `getBackend()` while this
|
|
173
|
+
// start is still awaiting connect(); using the captured instance for both
|
|
174
|
+
// connect() and the bail-path disconnect() guarantees we tear down the
|
|
175
|
+
// getBackend() WE started (not the freshly-swapped one), closing the
|
|
176
|
+
// both-backends-live window.
|
|
177
|
+
const startingBackend = getBackend();
|
|
178
|
+
const claimAfterReady = options.claimAfterReady === true;
|
|
179
|
+
// Single-holder correctness: the seat is claimed BEFORE getBackend().connect(),
|
|
180
|
+
// never after. The old make-before-break (claim-after-ready) boot left two
|
|
181
|
+
// gateways connected and contending during the multi-second connect window;
|
|
182
|
+
// claiming first makes the previous owner observe owned=false on its next
|
|
183
|
+
// tick and drain+disconnect, so at most one gateway ever serves.
|
|
184
|
+
// - boot (claimAfterReady): last-wins acquire — this new remote session
|
|
185
|
+
// takes the seat outright.
|
|
186
|
+
// - refresh/owned-path: CAS onlyIfOwned — abort fast if the seat moved.
|
|
187
|
+
// backendReady=false marks the partial state until getBackend().connect() succeeds.
|
|
188
|
+
// Wrapped so ANY throw in the pre-connect claim (lock contention/error)
|
|
189
|
+
// resets bridgeRuntimeStarting — otherwise a transient lock error would
|
|
190
|
+
// leave it stuck true and permanently block every future ownership attempt.
|
|
191
|
+
try {
|
|
192
|
+
if (claimAfterReady) {
|
|
193
|
+
refreshActiveInstance(instanceId, { backendReady: false });
|
|
194
|
+
logOwnership("boot claim (pre-connect, last-wins)");
|
|
195
|
+
} else {
|
|
196
|
+
// Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
|
|
197
|
+
// the active-instance lock. Contention throws → caught below and treated
|
|
198
|
+
// as "seat busy, abort this attempt"; the next 3s tick retries.
|
|
199
|
+
const casResult = refreshActiveInstance(instanceId, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
|
|
200
|
+
// A successful CAS write always sets instanceId to ours; any other result
|
|
201
|
+
// (aborted write returning the stale/foreign/missing prior state) means the
|
|
202
|
+
// seat was not ours to claim — abort here rather than relying on the timer.
|
|
203
|
+
if (casResult?.instanceId !== instanceId) {
|
|
204
|
+
bridgeRuntimeStarting = false;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// A newer session can claim between our write and this read. If we no longer
|
|
209
|
+
// own the seat, abort fast WITHOUT starting heartbeat/getBackend()/scheduler/
|
|
210
|
+
// webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
|
|
211
|
+
if (!currentOwnerState().owned) {
|
|
212
|
+
bridgeRuntimeStarting = false;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
bridgeRuntimeStarting = false;
|
|
217
|
+
process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Arm ownership-loss detection BEFORE getBackend().connect() so a session that is
|
|
221
|
+
// superseded during the (multi-second) connect window is torn down: the 3s
|
|
222
|
+
// tick observes a newer owner and flips _ownedRuntimeStopRequested, and
|
|
223
|
+
// bailIfStopRequested below disconnects the getBackend() WE connected.
|
|
224
|
+
armBridgeOwnershipTimer();
|
|
225
|
+
// Heartbeat is ownership-gated; safe to arm now that we hold the seat.
|
|
226
|
+
startOwnerHeartbeat();
|
|
227
|
+
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
228
|
+
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
229
|
+
// drainBuffer(); unref'd so it never holds the worker open.
|
|
230
|
+
if (!_memoryDrainTimer) {
|
|
231
|
+
_memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
|
|
232
|
+
_memoryDrainTimer.unref?.();
|
|
233
|
+
}
|
|
234
|
+
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
235
|
+
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
236
|
+
// webhook/binding launches below would revive owner state after stop).
|
|
237
|
+
// Idempotent: stop's sync teardown already ran; re-running disconnect +
|
|
238
|
+
// teardown is safe and covers both the pre-connected window (stop could
|
|
239
|
+
// not disconnect an in-flight getBackend()) and the post-connected window
|
|
240
|
+
// (stop did disconnect; redo to be defensive).
|
|
241
|
+
const bailIfStopRequested = async () => {
|
|
242
|
+
if (!_ownedRuntimeStopRequested) return false;
|
|
243
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
244
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
245
|
+
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
246
|
+
try { clearActiveInstance(instanceId); } catch {}
|
|
247
|
+
setBridgeRuntimeConnected(false);
|
|
248
|
+
_ownedRuntimeStopRequested = false;
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
const restoreBinding = options.restoreBinding !== false;
|
|
252
|
+
const bindPersistedTranscriptTask = restoreBinding
|
|
253
|
+
? bindPersistedTranscriptIfAny().catch((e) => {
|
|
254
|
+
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
255
|
+
})
|
|
256
|
+
: null;
|
|
257
|
+
// Await getBackend().connect() so callers (and bindingReady) only resolve after
|
|
258
|
+
// the Discord binding is real. Previously this was fire-and-forget and
|
|
259
|
+
// refreshBridgeOwnership returned immediately, letting bindingReady fire
|
|
260
|
+
// before getBackend() listeners were attached.
|
|
261
|
+
try {
|
|
262
|
+
await startingBackend.connect();
|
|
263
|
+
if (await bailIfStopRequested()) {
|
|
264
|
+
cancelPendingTranscriptRearm();
|
|
265
|
+
try { forwarder.stopWatch(); } catch {}
|
|
266
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Post-connect ownership confirm (CAS). The seat was claimed BEFORE
|
|
270
|
+
// connect; a newer session may have superseded us during the connect
|
|
271
|
+
// window (the 3s tick would already be tearing us down). onlyIfOwned:
|
|
272
|
+
// never re-steal here. If the CAS aborts we LOST the seat — disconnect the
|
|
273
|
+
// getBackend() WE connected and mark the bridge runtime not connected (do NOT
|
|
274
|
+
// clear active-instance; the newer owner holds it).
|
|
275
|
+
let ownConfirm;
|
|
276
|
+
try {
|
|
277
|
+
ownConfirm = refreshActiveInstance(instanceId, { backendReady: true }, { onlyIfOwned: true });
|
|
278
|
+
} catch { ownConfirm = null; }
|
|
279
|
+
if (ownConfirm?.instanceId !== instanceId) {
|
|
280
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
281
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
282
|
+
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
283
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
284
|
+
setBridgeRuntimeConnected(false);
|
|
285
|
+
cancelPendingTranscriptRearm();
|
|
286
|
+
try { forwarder.stopWatch(); } catch {}
|
|
287
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
288
|
+
notifyRemoteSuperseded();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
setBridgeRuntimeConnected(true);
|
|
292
|
+
if (claimAfterReady) {
|
|
293
|
+
// First confirmed ownership at boot — tell the parent it holds the seat
|
|
294
|
+
// exactly once (heartbeat/refresh re-pin ownership but never re-enter).
|
|
295
|
+
notifyRemoteAcquired();
|
|
296
|
+
}
|
|
297
|
+
// initProviders must complete before scheduler.start() — otherwise the
|
|
298
|
+
// scheduler's first fire can land before the registry is populated and
|
|
299
|
+
// return `Provider "<name>" not found or not enabled`. The previous
|
|
300
|
+
// fire-and-forget call let scheduler.start() race ahead of init.
|
|
301
|
+
try {
|
|
302
|
+
const agentCfg = loadAgentConfig();
|
|
303
|
+
await initProviders(agentCfg.providers || {});
|
|
304
|
+
} catch (e) {
|
|
305
|
+
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
306
|
+
}
|
|
307
|
+
if (await bailIfStopRequested()) {
|
|
308
|
+
cancelPendingTranscriptRearm();
|
|
309
|
+
try { forwarder.stopWatch(); } catch {}
|
|
310
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
scheduler.start();
|
|
314
|
+
startSnapshotWriter(scheduler);
|
|
315
|
+
syncOwnedWebhookAndEventRuntime();
|
|
316
|
+
if (restoreBinding) {
|
|
317
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
318
|
+
const pendingTranscriptPath = forwarder.transcriptPath;
|
|
319
|
+
if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
|
|
320
|
+
// Pre-connect bind may have armed rearm while !getBridgeRuntimeConnected();
|
|
321
|
+
// the first tick then exits without rescheduling. Re-arm now that we own.
|
|
322
|
+
schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
|
|
323
|
+
} else {
|
|
324
|
+
void forwarder.forwardNewText().catch((err) => {
|
|
325
|
+
process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
process.stderr.write(`mixdog: running with ${getBackend().name} getBackend()\n`);
|
|
330
|
+
logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
process.stderr.write(`mixdog: getBackend() connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
333
|
+
cancelPendingTranscriptRearm();
|
|
334
|
+
try { forwarder.stopWatch(); } catch {}
|
|
335
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
336
|
+
// Roll back partial owner-side state advertised before connect() ran:
|
|
337
|
+
// heartbeat and active-instance entry.
|
|
338
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
339
|
+
try { releaseOwnedChannelLocks(instanceId); } catch {}
|
|
340
|
+
try { clearActiveInstance(instanceId); } catch {}
|
|
341
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
342
|
+
} finally {
|
|
343
|
+
bridgeRuntimeStarting = false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function stopOwnedRuntime(reason) {
|
|
347
|
+
// Cancel any pending transcript re-arm poll BEFORE the connected/starting
|
|
348
|
+
// early-return below. Otherwise a poll armed against a not-yet-existing
|
|
349
|
+
// transcript could fire after teardown and reinstall the fs.watch handle
|
|
350
|
+
// (startWatch is not owner-gated), leaking a live watcher past shutdown.
|
|
351
|
+
cancelPendingTranscriptRearm();
|
|
352
|
+
// startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
|
|
353
|
+
// claims channel locks BEFORE awaiting getBackend().connect(). If shutdown lands
|
|
354
|
+
// during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
|
|
355
|
+
// still false) we still need to tear that partial state down — otherwise
|
|
356
|
+
// the port stays bound and active-instance.json stays stale.
|
|
357
|
+
if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting) return;
|
|
358
|
+
// If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
|
|
359
|
+
// startOwnedRuntime() to abort right after its getBackend().connect() resolves.
|
|
360
|
+
// Without this the in-flight start re-marks connected and re-launches
|
|
361
|
+
// scheduler/webhook/heartbeat after we tear them down here.
|
|
362
|
+
if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
|
|
363
|
+
const wasConnected = getBridgeRuntimeConnected();
|
|
364
|
+
stopServerTyping();
|
|
365
|
+
// Release the transcript fs.watch handle plus the forwarder's debounce/retry
|
|
366
|
+
// timers on standby. Without this the watcher keeps firing scheduleWatchFlush
|
|
367
|
+
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
368
|
+
// file handle + timers for the rest of the process lifetime.
|
|
369
|
+
try { forwarder.stopWatch(); } catch {}
|
|
370
|
+
stopOwnerHeartbeat();
|
|
371
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
372
|
+
scheduler.stop();
|
|
373
|
+
stopSnapshotWriter();
|
|
374
|
+
await stopWebhookAndEventRuntime();
|
|
375
|
+
releaseOwnedChannelLocks(instanceId);
|
|
376
|
+
clearActiveInstance(instanceId);
|
|
377
|
+
try {
|
|
378
|
+
// Only disconnect the getBackend() when connect() actually completed; calling
|
|
379
|
+
// disconnect() mid-connect races the connect promise.
|
|
380
|
+
if (wasConnected) {
|
|
381
|
+
// Drain in-flight outbound sends before disconnecting so a handoff
|
|
382
|
+
// (owned=false observed → ownership lost) never cuts off a reply
|
|
383
|
+
// mid-delivery. Bounded inside drainPendingSends so a wedged send can
|
|
384
|
+
// not stall teardown — we still disconnect promptly.
|
|
385
|
+
try { await getBackend().drainPendingSends?.(); } catch {}
|
|
386
|
+
await getBackend().disconnect();
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
setBridgeRuntimeConnected(false);
|
|
390
|
+
logOwnership(`standby: ${reason}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function refreshBridgeOwnershipSafe(options = {}) {
|
|
394
|
+
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
395
|
+
}
|
|
396
|
+
// Ownership-loss / re-acquire detection timer. Armed BEFORE getBackend().connect()
|
|
397
|
+
// (from startOwnedRuntime) so a session superseded during the connect window is
|
|
398
|
+
// torn down promptly, and re-armed idempotently on every start path.
|
|
399
|
+
function armBridgeOwnershipTimer() {
|
|
400
|
+
if (bridgeOwnershipTimer) return;
|
|
401
|
+
bridgeOwnershipTimer = setInterval(() => {
|
|
402
|
+
refreshBridgeOwnershipSafe();
|
|
403
|
+
}, 3e3);
|
|
404
|
+
bridgeOwnershipTimer.unref?.();
|
|
405
|
+
}
|
|
406
|
+
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
407
|
+
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
408
|
+
// exactly one session holds remote; losers fully release, no handover.
|
|
409
|
+
function notifyRemoteSuperseded() {
|
|
410
|
+
if (!process.send) return;
|
|
411
|
+
try {
|
|
412
|
+
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
413
|
+
} catch {}
|
|
414
|
+
}
|
|
415
|
+
// Symmetric to notifyRemoteSuperseded: tell the parent session this worker
|
|
416
|
+
// ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
|
|
417
|
+
// Guarded by process.send so a manually-forked worker with no IPC parent is a
|
|
418
|
+
// no-op instead of crashing. Callers fire this only on a genuine claim
|
|
419
|
+
// transition (boot make-before-break, activate when not already owned) — never
|
|
420
|
+
// on a heartbeat refresh — so the parent's idempotent handler sees it once.
|
|
421
|
+
function notifyRemoteAcquired() {
|
|
422
|
+
if (!process.send) return;
|
|
423
|
+
try {
|
|
424
|
+
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
|
|
425
|
+
} catch {}
|
|
426
|
+
}
|
|
427
|
+
async function refreshBridgeOwnership(options = {}) {
|
|
428
|
+
// Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
|
|
429
|
+
// calls landing during normal login wait for the same connect attempt
|
|
430
|
+
// instead of returning early and observing spurious auto-connect failure.
|
|
431
|
+
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
432
|
+
bridgeOwnershipRefreshInFlight = (async () => {
|
|
433
|
+
// Opt-in remote, single-owner, last-wins. Only a remote session with an
|
|
434
|
+
// active bridge participates. If this instance is the active owner (its
|
|
435
|
+
// instanceId is the one advertised in active-instance.json) it ensures
|
|
436
|
+
// the owned runtime is up. If a newer remote session has since claimed
|
|
437
|
+
// ownership (last-wins overwrite), this instance is no longer owner and
|
|
438
|
+
// quietly tears its getBackend() down on the next tick. No proxy, no steal.
|
|
439
|
+
if (!getChannelBridgeActive()) {
|
|
440
|
+
if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Non-blocking ownership probe (read-only, no lock): distinguishes a live
|
|
444
|
+
// owner from a genuinely empty/stale seat from an INDETERMINATE read (a
|
|
445
|
+
// concurrent atomic rename yields partial content). "Locked/unreadable =
|
|
446
|
+
// busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
|
|
447
|
+
// never claim a seat we merely failed to read (double-owner guard).
|
|
448
|
+
const probe = probeActiveOwner();
|
|
449
|
+
if (probe.status === 'unknown') return;
|
|
450
|
+
const owned = probe.status === 'live' && probe.state?.instanceId === instanceId;
|
|
451
|
+
if (owned) {
|
|
452
|
+
// Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
|
|
453
|
+
// and skip the metadata touch — never block the tick. We still own, so
|
|
454
|
+
// ensure/keep the owned runtime up.
|
|
455
|
+
try { refreshActiveInstance(instanceId, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
|
|
456
|
+
await startOwnedRuntime(options);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== instanceId) {
|
|
460
|
+
// A different live remote session holds the seat (last-wins: we lost).
|
|
461
|
+
// Go quiet (disconnect if connected) and tell the parent to drop remote
|
|
462
|
+
// mode entirely (single-holder, no handover). Notify UNCONDITIONALLY —
|
|
463
|
+
// a loser whose getBackend() never connected must still drop its Remote
|
|
464
|
+
// indicator; the parent handler is idempotent.
|
|
465
|
+
// Also cover the STARTING phase: a worker stuck in getBackend().connect() must
|
|
466
|
+
// get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
|
|
467
|
+
// bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
|
|
468
|
+
// once connect() resolves — otherwise the superseded connect lingers.
|
|
469
|
+
if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
|
|
470
|
+
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
471
|
+
}
|
|
472
|
+
notifyRemoteSuperseded();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// Empty (absent) or stale (dead owner) seat — claim it. Try-once
|
|
476
|
+
// (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
|
|
477
|
+
// so treat as busy and skip this tick rather than block.
|
|
478
|
+
let claimed = false;
|
|
479
|
+
try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
|
|
480
|
+
if (claimed) {
|
|
481
|
+
await startOwnedRuntime(options);
|
|
482
|
+
}
|
|
483
|
+
})();
|
|
484
|
+
try {
|
|
485
|
+
return await bridgeOwnershipRefreshInFlight;
|
|
486
|
+
} finally {
|
|
487
|
+
bridgeOwnershipRefreshInFlight = null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function reloadRuntimeConfig() {
|
|
492
|
+
const previousBackend = getBackend();
|
|
493
|
+
const previousBackendName = previousBackend?.name || "";
|
|
494
|
+
setConfig(loadConfig());
|
|
495
|
+
scheduler.reloadConfig(
|
|
496
|
+
getConfig().nonInteractive ?? [],
|
|
497
|
+
getConfig().interactive ?? [],
|
|
498
|
+
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
499
|
+
getConfig().channelId,
|
|
500
|
+
{ restart: getBridgeRuntimeConnected() }
|
|
501
|
+
);
|
|
502
|
+
const nextBackend = createBackend(getConfig());
|
|
503
|
+
const backendChanged = (nextBackend?.name || "") !== previousBackendName;
|
|
504
|
+
if (backendChanged) {
|
|
505
|
+
const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
|
|
506
|
+
if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
|
|
507
|
+
setBackend(nextBackend);
|
|
508
|
+
// The persisted routing channelId belongs to the OLD getBackend() (e.g. a
|
|
509
|
+
// Discord snowflake) and is meaningless for the new one — sending to it
|
|
510
|
+
// would 400 "chat not found". There is no id mapping between platforms, so
|
|
511
|
+
// CLEAR the stale binding: drop the forwarder's context + watcher and wipe
|
|
512
|
+
// status.channelId/transcriptPath. The next inbound from the new getBackend()
|
|
513
|
+
// rebinds the correct chat via applyTranscriptBinding(). Only done on
|
|
514
|
+
// backendChanged — same-getBackend() reloads keep their binding untouched.
|
|
515
|
+
// (active-instance is cleared by stopOwnedRuntime on the restart path; we
|
|
516
|
+
// don't re-advertise here to avoid resurrecting a just-cleared entry.)
|
|
517
|
+
try { forwarder.stopWatch(); } catch {}
|
|
518
|
+
forwarder.channelId = "";
|
|
519
|
+
forwarder.transcriptPath = "";
|
|
520
|
+
try {
|
|
521
|
+
statusState.update((state) => {
|
|
522
|
+
state.channelId = "";
|
|
523
|
+
state.transcriptPath = "";
|
|
524
|
+
});
|
|
525
|
+
} catch {}
|
|
526
|
+
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
527
|
+
} else if (nextBackend !== previousBackend) {
|
|
528
|
+
try { await nextBackend.disconnect?.(); } catch {}
|
|
529
|
+
}
|
|
530
|
+
if (getBridgeRuntimeConnected()) {
|
|
531
|
+
syncOwnedWebhookAndEventRuntime({ reload: true });
|
|
532
|
+
} else {
|
|
533
|
+
await stopWebhookAndEventRuntime();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
startOwnedRuntime,
|
|
538
|
+
stopOwnedRuntime,
|
|
539
|
+
refreshBridgeOwnership,
|
|
540
|
+
refreshBridgeOwnershipSafe,
|
|
541
|
+
reloadRuntimeConfig,
|
|
542
|
+
armBridgeOwnershipTimer,
|
|
543
|
+
clearBridgeOwnershipTimer,
|
|
544
|
+
notifyRemoteAcquired,
|
|
545
|
+
notifyRemoteSuperseded,
|
|
546
|
+
};
|
|
547
|
+
}
|