mixdog 0.9.17 → 0.9.19
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/package.json +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +5 -2
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
- package/src/runtime/channels/backends/discord.mjs +139 -7
- package/src/runtime/channels/index.mjs +290 -76
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +88 -7
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +66 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +74 -8
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +4744 -4806
- package/src/tui/engine.mjs +109 -20
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- package/src/tui/lib/voice-recorder.mjs +0 -469
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readActiveInstance,
|
|
43
43
|
refreshActiveInstance,
|
|
44
44
|
cleanupStaleRuntimeFiles,
|
|
45
|
+
probeActiveOwner,
|
|
45
46
|
cleanupInstanceRuntimeFiles,
|
|
46
47
|
releaseOwnedChannelLocks,
|
|
47
48
|
clearActiveInstance,
|
|
@@ -305,6 +306,11 @@ const forwarder = new OutputForwarder({
|
|
|
305
306
|
removeReaction: (ch, mid, emoji) => {
|
|
306
307
|
if (!channelBridgeActive) return Promise.resolve();
|
|
307
308
|
return backend.removeReaction(ch, mid, emoji);
|
|
309
|
+
},
|
|
310
|
+
// Watchdog backstop: force the backend to tear down + rebuild a wedged
|
|
311
|
+
// client so an over-budget send fails fast and the queue releases.
|
|
312
|
+
resetBackend: async () => {
|
|
313
|
+
if (typeof backend?._resetClient === "function") await backend._resetClient();
|
|
308
314
|
}
|
|
309
315
|
}, statusState);
|
|
310
316
|
forwarder.setOnIdle(() => {
|
|
@@ -323,7 +329,11 @@ forwarder.setOnIdle(() => {
|
|
|
323
329
|
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
324
330
|
function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
325
331
|
if (!transcriptPath) return;
|
|
326
|
-
forwarder.setContext(channelId, transcriptPath, {
|
|
332
|
+
forwarder.setContext(channelId, transcriptPath, {
|
|
333
|
+
replayFromStart: options.replayFromStart,
|
|
334
|
+
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
335
|
+
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
336
|
+
});
|
|
327
337
|
const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
|
|
328
338
|
forwarder.startWatch();
|
|
329
339
|
void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
|
|
@@ -336,14 +346,31 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
|
336
346
|
refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
|
|
337
347
|
if (options.persistStatus !== false) {
|
|
338
348
|
statusState.update((state) => {
|
|
349
|
+
const wasSameTranscript = typeof state.transcriptPath === "string"
|
|
350
|
+
&& state.transcriptPath
|
|
351
|
+
&& sameResolvedPath(state.transcriptPath, boundTranscriptPath);
|
|
352
|
+
const prevSentCount = Number(state.sentCount ?? 0);
|
|
353
|
+
const nextSentCount = Number(forwarder.sentCount ?? 0);
|
|
339
354
|
state.channelId = channelId;
|
|
340
355
|
state.transcriptPath = boundTranscriptPath;
|
|
341
356
|
state.lastFileSize = forwarder.lastFileSize;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
357
|
+
if (wasSameTranscript) {
|
|
358
|
+
state.sentCount = Math.max(
|
|
359
|
+
Number.isFinite(prevSentCount) ? prevSentCount : 0,
|
|
360
|
+
Number.isFinite(nextSentCount) ? nextSentCount : 0,
|
|
361
|
+
);
|
|
362
|
+
if (forwarder.lastHash) state.lastSentHash = forwarder.lastHash;
|
|
363
|
+
else if (typeof state.lastSentHash !== "string") state.lastSentHash = "";
|
|
364
|
+
if (!Number.isFinite(Number(state.lastSentTime))) state.lastSentTime = 0;
|
|
365
|
+
if (typeof state.sessionIdle !== "boolean") state.sessionIdle = false;
|
|
366
|
+
} else {
|
|
367
|
+
state.sentCount = forwarder.sentCount;
|
|
368
|
+
state.lastSentHash = forwarder.lastHash;
|
|
369
|
+
state.lastSentTime = 0;
|
|
370
|
+
state.sessionIdle = false;
|
|
371
|
+
}
|
|
372
|
+
if (options.cwd !== undefined) state.sessionCwd = options.cwd ?? null;
|
|
373
|
+
else if (!wasSameTranscript) state.sessionCwd = null;
|
|
347
374
|
});
|
|
348
375
|
}
|
|
349
376
|
}
|
|
@@ -421,7 +448,8 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
421
448
|
applyTranscriptBinding(channelId, explicitTranscriptPath, {
|
|
422
449
|
replayFromStart: Boolean(options.catchUp),
|
|
423
450
|
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
424
|
-
persistStatus: options.persistStatus
|
|
451
|
+
persistStatus: options.persistStatus,
|
|
452
|
+
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
425
453
|
});
|
|
426
454
|
if (options.catchUp || options.catchUpFromPersisted) {
|
|
427
455
|
await forwarder.forwardNewText();
|
|
@@ -452,6 +480,7 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
452
480
|
applyTranscriptBinding(channelId, bound.transcriptPath, {
|
|
453
481
|
replayFromStart,
|
|
454
482
|
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
483
|
+
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
455
484
|
persistStatus: options.persistStatus,
|
|
456
485
|
cwd: bound.sessionCwd,
|
|
457
486
|
});
|
|
@@ -497,6 +526,7 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
497
526
|
applyTranscriptBinding(channelId, pendingTranscriptPath, {
|
|
498
527
|
replayFromStart: !samePersisted,
|
|
499
528
|
catchUpFromPersisted: samePersisted,
|
|
529
|
+
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
500
530
|
cwd: pendingTranscriptCwd,
|
|
501
531
|
persistStatus: options.persistStatus,
|
|
502
532
|
});
|
|
@@ -512,7 +542,11 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
512
542
|
}
|
|
513
543
|
}
|
|
514
544
|
if (previousPath && options.fallbackPrevious !== false) {
|
|
515
|
-
applyTranscriptBinding(channelId, previousPath, {
|
|
545
|
+
applyTranscriptBinding(channelId, previousPath, {
|
|
546
|
+
catchUpFromPersisted: true,
|
|
547
|
+
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
548
|
+
cwd: statusState.read().sessionCwd
|
|
549
|
+
});
|
|
516
550
|
if (fs.existsSync(previousPath)) {
|
|
517
551
|
await forwarder.forwardNewText();
|
|
518
552
|
} else {
|
|
@@ -631,7 +665,8 @@ async function bindPersistedTranscriptIfAny() {
|
|
|
631
665
|
const bound = await rebindTranscriptContext(currentStatus.channelId, {
|
|
632
666
|
previousPath: getPersistedTranscriptPath(),
|
|
633
667
|
persistStatus: true,
|
|
634
|
-
catchUpFromPersisted: true
|
|
668
|
+
catchUpFromPersisted: true,
|
|
669
|
+
recoverUnsyncedTail: true,
|
|
635
670
|
});
|
|
636
671
|
if (bound) {
|
|
637
672
|
process.stderr.write(`mixdog: initial transcript bind: ${bound}
|
|
@@ -710,8 +745,40 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
|
|
|
710
745
|
webhookServer = null;
|
|
711
746
|
}
|
|
712
747
|
}
|
|
748
|
+
let _ownedRuntimeSelfHealing = false;
|
|
749
|
+
// Explicit restore path for an already-connected owner. The 3s ownership tick
|
|
750
|
+
// must not run this implicitly: re-binding status on every tick can move the
|
|
751
|
+
// transcript cursor to EOF, and probing the gateway during Discord's own
|
|
752
|
+
// reconnect window can reset a healthy reconnect loop. Keep this for explicit
|
|
753
|
+
// activation/reload recovery only.
|
|
754
|
+
async function selfHealOwnedRuntime(options = {}) {
|
|
755
|
+
const shouldRestoreBinding = options.restoreBinding === true;
|
|
756
|
+
const shouldResetBackend = options.resetBackend === true;
|
|
757
|
+
if (!shouldRestoreBinding && !shouldResetBackend) return;
|
|
758
|
+
if (_ownedRuntimeSelfHealing) return;
|
|
759
|
+
_ownedRuntimeSelfHealing = true;
|
|
760
|
+
try {
|
|
761
|
+
if (shouldRestoreBinding) {
|
|
762
|
+
await bindPersistedTranscriptIfAny().catch((e) => {
|
|
763
|
+
process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (shouldResetBackend && typeof backend?._resetClient === "function") {
|
|
767
|
+
await backend._resetClient().catch((e) => {
|
|
768
|
+
process.stderr.write(`mixdog: self-heal backend reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
// Nudge the forwarder to drain anything the rebind/reconnect surfaced.
|
|
772
|
+
void forwarder.forwardNewText().catch(() => {});
|
|
773
|
+
} finally {
|
|
774
|
+
_ownedRuntimeSelfHealing = false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
713
777
|
async function startOwnedRuntime(options = {}) {
|
|
714
|
-
if (bridgeRuntimeConnected)
|
|
778
|
+
if (bridgeRuntimeConnected) {
|
|
779
|
+
if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
715
782
|
if (bridgeRuntimeStarting) return;
|
|
716
783
|
if (!channelBridgeActive) return;
|
|
717
784
|
bridgeRuntimeStarting = true;
|
|
@@ -723,23 +790,54 @@ async function startOwnedRuntime(options = {}) {
|
|
|
723
790
|
// backend WE started (not the freshly-swapped one), closing the
|
|
724
791
|
// both-backends-live window.
|
|
725
792
|
const startingBackend = backend;
|
|
726
|
-
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
793
|
+
const claimAfterReady = options.claimAfterReady === true;
|
|
794
|
+
// Single-holder correctness: the seat is claimed BEFORE backend.connect(),
|
|
795
|
+
// never after. The old make-before-break (claim-after-ready) boot left two
|
|
796
|
+
// gateways connected and contending during the multi-second connect window;
|
|
797
|
+
// claiming first makes the previous owner observe owned=false on its next
|
|
798
|
+
// tick and drain+disconnect, so at most one gateway ever serves.
|
|
799
|
+
// - boot (claimAfterReady): last-wins acquire — this new remote session
|
|
800
|
+
// takes the seat outright.
|
|
801
|
+
// - refresh/owned-path: CAS onlyIfOwned — abort fast if the seat moved.
|
|
802
|
+
// backendReady=false marks the partial state until backend.connect() succeeds.
|
|
803
|
+
// Wrapped so ANY throw in the pre-connect claim (lock contention/error)
|
|
804
|
+
// resets bridgeRuntimeStarting — otherwise a transient lock error would
|
|
805
|
+
// leave it stuck true and permanently block every future ownership attempt.
|
|
806
|
+
try {
|
|
807
|
+
if (claimAfterReady) {
|
|
808
|
+
refreshActiveInstance(INSTANCE_ID, { backendReady: false });
|
|
809
|
+
logOwnership("boot claim (pre-connect, last-wins)");
|
|
810
|
+
} else {
|
|
811
|
+
// Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
|
|
812
|
+
// the active-instance lock. Contention throws → caught below and treated
|
|
813
|
+
// as "seat busy, abort this attempt"; the next 3s tick retries.
|
|
814
|
+
const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
|
|
815
|
+
// A successful CAS write always sets instanceId to ours; any other result
|
|
816
|
+
// (aborted write returning the stale/foreign/missing prior state) means the
|
|
817
|
+
// seat was not ours to claim — abort here rather than relying on the timer.
|
|
818
|
+
if (casResult?.instanceId !== INSTANCE_ID) {
|
|
819
|
+
bridgeRuntimeStarting = false;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// A newer session can claim between our write and this read. If we no longer
|
|
824
|
+
// own the seat, abort fast WITHOUT starting heartbeat/backend/scheduler/
|
|
825
|
+
// webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
|
|
826
|
+
if (!currentOwnerState().owned) {
|
|
827
|
+
bridgeRuntimeStarting = false;
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
} catch (e) {
|
|
740
831
|
bridgeRuntimeStarting = false;
|
|
832
|
+
process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
741
833
|
return;
|
|
742
834
|
}
|
|
835
|
+
// Arm ownership-loss detection BEFORE backend.connect() so a session that is
|
|
836
|
+
// superseded during the (multi-second) connect window is torn down: the 3s
|
|
837
|
+
// tick observes a newer owner and flips _ownedRuntimeStopRequested, and
|
|
838
|
+
// bailIfStopRequested below disconnects the backend WE connected.
|
|
839
|
+
armBridgeOwnershipTimer();
|
|
840
|
+
// Heartbeat is ownership-gated; safe to arm now that we hold the seat.
|
|
743
841
|
startOwnerHeartbeat();
|
|
744
842
|
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
745
843
|
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
@@ -783,12 +881,34 @@ async function startOwnedRuntime(options = {}) {
|
|
|
783
881
|
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
784
882
|
return;
|
|
785
883
|
}
|
|
884
|
+
// Post-connect ownership confirm (CAS). The seat was claimed BEFORE
|
|
885
|
+
// connect; a newer session may have superseded us during the connect
|
|
886
|
+
// window (the 3s tick would already be tearing us down). onlyIfOwned:
|
|
887
|
+
// never re-steal here. If the CAS aborts we LOST the seat — disconnect the
|
|
888
|
+
// backend WE connected and mark the bridge runtime not connected (do NOT
|
|
889
|
+
// clear active-instance; the newer owner holds it).
|
|
890
|
+
let ownConfirm;
|
|
891
|
+
try {
|
|
892
|
+
ownConfirm = refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
|
|
893
|
+
} catch { ownConfirm = null; }
|
|
894
|
+
if (ownConfirm?.instanceId !== INSTANCE_ID) {
|
|
895
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
896
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
897
|
+
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
898
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
899
|
+
bridgeRuntimeConnected = false;
|
|
900
|
+
cancelPendingTranscriptRearm();
|
|
901
|
+
try { forwarder.stopWatch(); } catch {}
|
|
902
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
903
|
+
notifyRemoteSuperseded();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
786
906
|
bridgeRuntimeConnected = true;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
907
|
+
if (claimAfterReady) {
|
|
908
|
+
// First confirmed ownership at boot — tell the parent it holds the seat
|
|
909
|
+
// exactly once (heartbeat/refresh re-pin ownership but never re-enter).
|
|
910
|
+
notifyRemoteAcquired();
|
|
911
|
+
}
|
|
792
912
|
// initProviders must complete before scheduler.start() — otherwise the
|
|
793
913
|
// scheduler's first fire can land before the registry is populated and
|
|
794
914
|
// return `Provider "<name>" not found or not enabled`. The previous
|
|
@@ -872,7 +992,14 @@ async function stopOwnedRuntime(reason) {
|
|
|
872
992
|
try {
|
|
873
993
|
// Only disconnect the backend when connect() actually completed; calling
|
|
874
994
|
// disconnect() mid-connect races the connect promise.
|
|
875
|
-
if (wasConnected)
|
|
995
|
+
if (wasConnected) {
|
|
996
|
+
// Drain in-flight outbound sends before disconnecting so a handoff
|
|
997
|
+
// (owned=false observed → ownership lost) never cuts off a reply
|
|
998
|
+
// mid-delivery. Bounded inside drainPendingSends so a wedged send can
|
|
999
|
+
// not stall teardown — we still disconnect promptly.
|
|
1000
|
+
try { await backend.drainPendingSends?.(); } catch {}
|
|
1001
|
+
await backend.disconnect();
|
|
1002
|
+
}
|
|
876
1003
|
} finally {
|
|
877
1004
|
bridgeRuntimeConnected = false;
|
|
878
1005
|
logOwnership(`standby: ${reason}`);
|
|
@@ -881,6 +1008,16 @@ async function stopOwnedRuntime(reason) {
|
|
|
881
1008
|
function refreshBridgeOwnershipSafe(options = {}) {
|
|
882
1009
|
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
883
1010
|
}
|
|
1011
|
+
// Ownership-loss / re-acquire detection timer. Armed BEFORE backend.connect()
|
|
1012
|
+
// (from startOwnedRuntime) so a session superseded during the connect window is
|
|
1013
|
+
// torn down promptly, and re-armed idempotently on every start path.
|
|
1014
|
+
function armBridgeOwnershipTimer() {
|
|
1015
|
+
if (bridgeOwnershipTimer) return;
|
|
1016
|
+
bridgeOwnershipTimer = setInterval(() => {
|
|
1017
|
+
refreshBridgeOwnershipSafe();
|
|
1018
|
+
}, 3e3);
|
|
1019
|
+
bridgeOwnershipTimer.unref?.();
|
|
1020
|
+
}
|
|
884
1021
|
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
885
1022
|
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
886
1023
|
// exactly one session holds remote; losers fully release, no handover.
|
|
@@ -890,6 +1027,18 @@ function notifyRemoteSuperseded() {
|
|
|
890
1027
|
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
891
1028
|
} catch {}
|
|
892
1029
|
}
|
|
1030
|
+
// Symmetric to notifyRemoteSuperseded: tell the parent session this worker
|
|
1031
|
+
// ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
|
|
1032
|
+
// Guarded by process.send so a manually-forked worker with no IPC parent is a
|
|
1033
|
+
// no-op instead of crashing. Callers fire this only on a genuine claim
|
|
1034
|
+
// transition (boot make-before-break, activate when not already owned) — never
|
|
1035
|
+
// on a heartbeat refresh — so the parent's idempotent handler sees it once.
|
|
1036
|
+
function notifyRemoteAcquired() {
|
|
1037
|
+
if (!process.send) return;
|
|
1038
|
+
try {
|
|
1039
|
+
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
|
|
1040
|
+
} catch {}
|
|
1041
|
+
}
|
|
893
1042
|
async function refreshBridgeOwnership(options = {}) {
|
|
894
1043
|
// Coalesce concurrent callers onto the in-flight refresh so backend tool
|
|
895
1044
|
// calls landing during normal login wait for the same connect attempt
|
|
@@ -906,35 +1055,44 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
906
1055
|
if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
|
|
907
1056
|
return;
|
|
908
1057
|
}
|
|
909
|
-
|
|
1058
|
+
// Non-blocking ownership probe (read-only, no lock): distinguishes a live
|
|
1059
|
+
// owner from a genuinely empty/stale seat from an INDETERMINATE read (a
|
|
1060
|
+
// concurrent atomic rename yields partial content). "Locked/unreadable =
|
|
1061
|
+
// busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
|
|
1062
|
+
// never claim a seat we merely failed to read (double-owner guard).
|
|
1063
|
+
const probe = probeActiveOwner();
|
|
1064
|
+
if (probe.status === 'unknown') return;
|
|
1065
|
+
const owned = probe.status === 'live' && probe.state?.instanceId === INSTANCE_ID;
|
|
910
1066
|
if (owned) {
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
|
|
1067
|
+
// Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
|
|
1068
|
+
// and skip the metadata touch — never block the tick. We still own, so
|
|
1069
|
+
// ensure/keep the owned runtime up.
|
|
1070
|
+
try { refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
|
|
915
1071
|
await startOwnedRuntime(options);
|
|
916
1072
|
return;
|
|
917
1073
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
if (bridgeRuntimeConnected) {
|
|
1074
|
+
if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== INSTANCE_ID) {
|
|
1075
|
+
// A different live remote session holds the seat (last-wins: we lost).
|
|
1076
|
+
// Go quiet (disconnect if connected) and tell the parent to drop remote
|
|
1077
|
+
// mode entirely (single-holder, no handover). Notify UNCONDITIONALLY —
|
|
1078
|
+
// a loser whose backend never connected must still drop its Remote
|
|
1079
|
+
// indicator; the parent handler is idempotent.
|
|
1080
|
+
// Also cover the STARTING phase: a worker stuck in backend.connect() must
|
|
1081
|
+
// get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
|
|
1082
|
+
// bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
|
|
1083
|
+
// once connect() resolves — otherwise the superseded connect lingers.
|
|
1084
|
+
if (bridgeRuntimeConnected || bridgeRuntimeStarting) {
|
|
930
1085
|
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
931
|
-
notifyRemoteSuperseded();
|
|
932
1086
|
}
|
|
1087
|
+
notifyRemoteSuperseded();
|
|
933
1088
|
return;
|
|
934
1089
|
}
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
1090
|
+
// Empty (absent) or stale (dead owner) seat — claim it. Try-once
|
|
1091
|
+
// (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
|
|
1092
|
+
// so treat as busy and skip this tick rather than block.
|
|
1093
|
+
let claimed = false;
|
|
1094
|
+
try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
|
|
1095
|
+
if (claimed) {
|
|
938
1096
|
await startOwnedRuntime(options);
|
|
939
1097
|
}
|
|
940
1098
|
})();
|
|
@@ -1444,10 +1602,12 @@ const {
|
|
|
1444
1602
|
lifecycle: {
|
|
1445
1603
|
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
1446
1604
|
getChannelBridgeActive: () => channelBridgeActive,
|
|
1605
|
+
getOwned: () => getBridgeOwnershipSnapshot().owned,
|
|
1447
1606
|
setChannelBridgeActive: (v) => { channelBridgeActive = v; },
|
|
1448
1607
|
writeBridgeState,
|
|
1449
1608
|
stopServerTyping,
|
|
1450
1609
|
claimBridgeOwnership,
|
|
1610
|
+
notifyRemoteAcquired,
|
|
1451
1611
|
refreshBridgeOwnership,
|
|
1452
1612
|
bindPersistedTranscriptIfAny,
|
|
1453
1613
|
stopOwnedRuntime,
|
|
@@ -1658,6 +1818,26 @@ backend.onMessage = (msg) => {
|
|
|
1658
1818
|
});
|
|
1659
1819
|
})();
|
|
1660
1820
|
};
|
|
1821
|
+
const NETWORK_ERR_RE = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|timeout|aborted|TimeoutError/i;
|
|
1822
|
+
function isNetworkError(err) {
|
|
1823
|
+
const s = `${err?.code ?? ""} ${err?.name ?? ""} ${err?.message ?? err ?? ""}`;
|
|
1824
|
+
return NETWORK_ERR_RE.test(s);
|
|
1825
|
+
}
|
|
1826
|
+
async function retryOnNetwork(fn, { attempts = 3, baseDelayMs = 300, label = "op" } = {}) {
|
|
1827
|
+
let lastErr;
|
|
1828
|
+
for (let i = 0; i < attempts; i++) {
|
|
1829
|
+
try {
|
|
1830
|
+
return await fn();
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
lastErr = err;
|
|
1833
|
+
if (i === attempts - 1 || !isNetworkError(err)) throw err;
|
|
1834
|
+
const delay = baseDelayMs * (i + 1);
|
|
1835
|
+
process.stderr.write(`mixdog: ${label} network failure (attempt ${i + 1}/${attempts}), retrying in ${delay}ms: ${err?.message || err}\n`);
|
|
1836
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
throw lastErr;
|
|
1840
|
+
}
|
|
1661
1841
|
async function handleInbound(msg, route, options = {}) {
|
|
1662
1842
|
const handleStartMs = Date.now();
|
|
1663
1843
|
let text = msg.text;
|
|
@@ -1668,11 +1848,19 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
1668
1848
|
text = text || "[voice message]";
|
|
1669
1849
|
} else {
|
|
1670
1850
|
try {
|
|
1671
|
-
const files = await
|
|
1851
|
+
const files = await retryOnNetwork(
|
|
1852
|
+
// Short per-attempt timeout (vs the 180s default) bounds worst-case
|
|
1853
|
+
// blockage of the serial inboundQueue on a bad voice message.
|
|
1854
|
+
() => backend.downloadAttachment(msg.chatId, msg.messageId, { timeoutMs: 20_000 }),
|
|
1855
|
+
{ label: "voice.download" }
|
|
1856
|
+
);
|
|
1672
1857
|
// concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
|
|
1673
1858
|
for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
|
|
1674
1859
|
const _t0 = Date.now();
|
|
1675
|
-
const transcript = await
|
|
1860
|
+
const transcript = await retryOnNetwork(
|
|
1861
|
+
() => transcribeVoice(f.path, { attachmentId: f.id }),
|
|
1862
|
+
{ label: "voice.transcription" }
|
|
1863
|
+
);
|
|
1676
1864
|
const _elapsed = Date.now() - _t0;
|
|
1677
1865
|
if (transcript) {
|
|
1678
1866
|
text = transcript;
|
|
@@ -1683,8 +1871,12 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
1683
1871
|
}
|
|
1684
1872
|
}
|
|
1685
1873
|
} catch (err) {
|
|
1686
|
-
|
|
1687
|
-
|
|
1874
|
+
const netFail = isNetworkError(err);
|
|
1875
|
+
process.stderr.write(`mixdog: voice.transcription error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
|
|
1876
|
+
const marker = netFail
|
|
1877
|
+
? "[attachment: voice transcription failed (network)]"
|
|
1878
|
+
: `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
1879
|
+
text = text ? `${text} ${marker}` : marker;
|
|
1688
1880
|
}
|
|
1689
1881
|
}
|
|
1690
1882
|
}
|
|
@@ -1742,14 +1934,15 @@ async function init(_sharedMcp) {
|
|
|
1742
1934
|
async function start() {
|
|
1743
1935
|
channelBridgeActive = true;
|
|
1744
1936
|
writeBridgeState(true);
|
|
1745
|
-
// Opt-in remote, single-owner, last-wins.
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
|
|
1937
|
+
// Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
|
|
1938
|
+
// seat up front: connect our own gateway first and claim only after it reports
|
|
1939
|
+
// READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
|
|
1940
|
+
// owner keeps serving, so a boot causes no send/receive gap. This is also the
|
|
1941
|
+
// SINGLE boot claim — the old "remote start" pre-claim + "re-activate
|
|
1942
|
+
// takeover" second claim (double reconnect) is gone.
|
|
1750
1943
|
const _bindingReadyStart = Date.now();
|
|
1751
1944
|
try {
|
|
1752
|
-
await
|
|
1945
|
+
await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
|
|
1753
1946
|
bindingReadyStatus = "resolved";
|
|
1754
1947
|
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
1755
1948
|
_bindingReadyResolve(true);
|
|
@@ -1759,13 +1952,10 @@ async function start() {
|
|
|
1759
1952
|
_bindingReadyResolve(e);
|
|
1760
1953
|
}
|
|
1761
1954
|
// Ownership timer: keep checking whether a newer remote session has taken
|
|
1762
|
-
// over (last-wins) so a superseded owner disconnects promptly.
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
}, 3e3);
|
|
1767
|
-
bridgeOwnershipTimer.unref?.();
|
|
1768
|
-
}
|
|
1955
|
+
// over (last-wins) so a superseded owner disconnects promptly. Normally
|
|
1956
|
+
// already armed by startOwnedRuntime before connect(); this is the fallback
|
|
1957
|
+
// for a start path that aborted pre-connect (idempotent).
|
|
1958
|
+
armBridgeOwnershipTimer();
|
|
1769
1959
|
// Hot-reload config on file change (schedules/webhooks/events).
|
|
1770
1960
|
if (!_configWatcher) {
|
|
1771
1961
|
try {
|
|
@@ -1953,15 +2143,39 @@ if (_isWorkerMode && process.send) {
|
|
|
1953
2143
|
})
|
|
1954
2144
|
void (async () => {
|
|
1955
2145
|
const startedAt = performance.now()
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
2146
|
+
const MAX_START_ATTEMPTS = 3
|
|
2147
|
+
const BASE_BACKOFF_MS = 250
|
|
2148
|
+
const isTransientStartErr = (err) =>
|
|
2149
|
+
err?.code === 'ELOCKTIMEOUT' || /atomic lock timeout/i.test(err?.message || '')
|
|
2150
|
+
let lastErr
|
|
2151
|
+
for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) {
|
|
2152
|
+
if (_channelsStopInFlight) return
|
|
2153
|
+
try {
|
|
2154
|
+
await start()
|
|
2155
|
+
bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
|
|
2156
|
+
process.send({ type: 'ready' })
|
|
2157
|
+
return
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
lastErr = e
|
|
2160
|
+
const transient = isTransientStartErr(e)
|
|
2161
|
+
bootProfile("worker:start-failed", { attempt, transient, error: e?.message || String(e) })
|
|
2162
|
+
process.stderr.write(`[channels-worker] start() failed (attempt ${attempt}/${MAX_START_ATTEMPTS}, transient=${transient}): ${e && (e.message || e)}\n`)
|
|
2163
|
+
if (!transient || attempt >= MAX_START_ATTEMPTS) break
|
|
2164
|
+
const backoff = BASE_BACKOFF_MS * attempt + Math.floor(Math.random() * BASE_BACKOFF_MS)
|
|
2165
|
+
await new Promise((r) => setTimeout(r, backoff))
|
|
2166
|
+
if (_channelsStopInFlight) return
|
|
2167
|
+
}
|
|
1964
2168
|
}
|
|
2169
|
+
// A stop landed while we were failing — let clean shutdown proceed, never exit over it.
|
|
2170
|
+
if (_channelsStopInFlight) return
|
|
2171
|
+
// Terminal failure: do NOT mask as a (degraded) ready. Exit non-zero so the
|
|
2172
|
+
// parent's exit-before-ready path respawns or rejects startRemote instead of
|
|
2173
|
+
// silently losing remote output forwarding.
|
|
2174
|
+
bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: lastErr?.message || String(lastErr) })
|
|
2175
|
+
process.stderr.write(`[channels-worker] start() giving up after ${MAX_START_ATTEMPTS} attempts: ${lastErr && (lastErr.message || lastErr)}\n`)
|
|
2176
|
+
// Exit 2 = terminal (non-transient) start failure: parent must reject, not respawn.
|
|
2177
|
+
// Exit 1 = exhausted transient retries: parent may respawn.
|
|
2178
|
+
process.exit(isTransientStartErr(lastErr) ? 1 : 2)
|
|
1965
2179
|
})()
|
|
1966
2180
|
}
|
|
1967
2181
|
|
|
@@ -86,15 +86,33 @@ ${err instanceof Error ? err.stack : ""}
|
|
|
86
86
|
// already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
|
|
87
87
|
// occurrence must NOT be fatal, only a run of 3+ in a row without an
|
|
88
88
|
// intervening distinct/successful event.
|
|
89
|
-
const BENIGN_CRASH_CODES = new Set([
|
|
89
|
+
const BENIGN_CRASH_CODES = new Set([
|
|
90
|
+
"EPERM", "EACCES", "EBUSY",
|
|
91
|
+
// Transient transport blips: a raw http/https ClientRequest (or an
|
|
92
|
+
// undici socket) can emit these when a keep-alive connection is torn
|
|
93
|
+
// down mid-flight (Discord gateway flapping, proxy resets). One blip
|
|
94
|
+
// must NOT kill the worker — it is benign; only a repeat storm within
|
|
95
|
+
// the streak window force-exits (see _fatalCrash).
|
|
96
|
+
"ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE", "EHOSTUNREACH",
|
|
97
|
+
"ENETUNREACH", "ENETDOWN", "EAI_AGAIN", "ERR_STREAM_PREMATURE_CLOSE",
|
|
98
|
+
"UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
|
|
99
|
+
"UND_ERR_BODY_TIMEOUT", "UND_ERR_ABORTED",
|
|
100
|
+
]);
|
|
101
|
+
// Some transient socket errors arrive with no `.code` (notably the classic
|
|
102
|
+
// "socket hang up" thrown by node:_http_client socketOnEnd — the exact crash
|
|
103
|
+
// this guard exists for). Match those by message so they classify benign too.
|
|
104
|
+
const BENIGN_CRASH_MESSAGE_RE = /socket hang up|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR_/i;
|
|
90
105
|
const BENIGN_CRASH_FATAL_THRESHOLD = 3;
|
|
91
106
|
// "In a row" needs a time dimension: benign errors minutes/hours apart are
|
|
92
107
|
// independent contention events, not a corrupted-state run. Only count a
|
|
93
108
|
// streak when hits land within this window of the previous one.
|
|
94
109
|
const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
|
|
95
110
|
function _isBenignCrash(err) {
|
|
96
|
-
const
|
|
97
|
-
|
|
111
|
+
const msg = String(err?.message || err);
|
|
112
|
+
const code = err?.code
|
|
113
|
+
|| (/\b(EPERM|EACCES|EBUSY|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EPIPE|EHOSTUNREACH|ENETUNREACH|ENETDOWN|EAI_AGAIN|UND_ERR_[A-Z_]+)\b/.exec(msg) || [])[0];
|
|
114
|
+
if (BENIGN_CRASH_CODES.has(code)) return true;
|
|
115
|
+
return BENIGN_CRASH_MESSAGE_RE.test(msg);
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
export {
|
|
@@ -28,6 +28,10 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
|
|
|
28
28
|
const now = Date.now();
|
|
29
29
|
if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
|
|
30
30
|
inboundSeen.set(key, now);
|
|
31
|
+
// Cross-process marker write MUST stay synchronous: a fire-and-forget wx
|
|
32
|
+
// write opens a duplicate-delivery window where two processes both return
|
|
33
|
+
// false before either callback observes EEXIST. Single small write+stat is
|
|
34
|
+
// cheap. Only the periodic sweep is moved off the hot path (async).
|
|
31
35
|
const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
|
|
32
36
|
try {
|
|
33
37
|
fs.writeFileSync(marker, String(now), { flag: "wx" });
|
|
@@ -39,24 +43,27 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
|
|
|
39
43
|
} catch {}
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
|
|
45
|
-
const fp = path.join(INBOUND_DEDUP_DIR, f);
|
|
46
|
-
try {
|
|
47
|
-
if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
|
|
48
|
-
} catch {
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
}
|
|
53
|
-
}
|
|
46
|
+
scheduleSweep(now);
|
|
54
47
|
for (const [k, t] of inboundSeen) {
|
|
55
48
|
if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
|
|
56
49
|
}
|
|
57
50
|
return false;
|
|
58
51
|
}
|
|
59
52
|
|
|
53
|
+
function scheduleSweep(now) {
|
|
54
|
+
if (Math.random() < 0.1) {
|
|
55
|
+
fs.readdir(INBOUND_DEDUP_DIR, (e, list) => {
|
|
56
|
+
if (e) return;
|
|
57
|
+
for (const f of list) {
|
|
58
|
+
const fp = path.join(INBOUND_DEDUP_DIR, f);
|
|
59
|
+
fs.stat(fp, (se, stat) => {
|
|
60
|
+
if (!se && now - stat.mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
function resolveInboundRoute(chatId, parentChatId) {
|
|
61
68
|
const config = getConfig();
|
|
62
69
|
// Single main channel: there is no per-channel label/mode map anymore.
|