mixdog 0.9.18 → 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/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/tool-efficiency-diag.mjs +4 -1
- 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/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-gateway.mjs +14 -1
- package/src/runtime/channels/backends/discord.mjs +43 -1
- package/src/runtime/channels/index.mjs +145 -58
- 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 +38 -7
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- 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 +74 -5
- 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 +44 -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 +65 -5
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +5330 -5443
- package/src/tui/engine.mjs +49 -10
- 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,
|
|
@@ -789,23 +790,54 @@ async function startOwnedRuntime(options = {}) {
|
|
|
789
790
|
// backend WE started (not the freshly-swapped one), closing the
|
|
790
791
|
// both-backends-live window.
|
|
791
792
|
const startingBackend = backend;
|
|
792
|
-
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
|
|
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) {
|
|
806
831
|
bridgeRuntimeStarting = false;
|
|
832
|
+
process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
807
833
|
return;
|
|
808
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.
|
|
809
841
|
startOwnerHeartbeat();
|
|
810
842
|
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
811
843
|
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
@@ -849,12 +881,34 @@ async function startOwnedRuntime(options = {}) {
|
|
|
849
881
|
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
850
882
|
return;
|
|
851
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
|
+
}
|
|
852
906
|
bridgeRuntimeConnected = true;
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
}
|
|
858
912
|
// initProviders must complete before scheduler.start() — otherwise the
|
|
859
913
|
// scheduler's first fire can land before the registry is populated and
|
|
860
914
|
// return `Provider "<name>" not found or not enabled`. The previous
|
|
@@ -938,7 +992,14 @@ async function stopOwnedRuntime(reason) {
|
|
|
938
992
|
try {
|
|
939
993
|
// Only disconnect the backend when connect() actually completed; calling
|
|
940
994
|
// disconnect() mid-connect races the connect promise.
|
|
941
|
-
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
|
+
}
|
|
942
1003
|
} finally {
|
|
943
1004
|
bridgeRuntimeConnected = false;
|
|
944
1005
|
logOwnership(`standby: ${reason}`);
|
|
@@ -947,6 +1008,16 @@ async function stopOwnedRuntime(reason) {
|
|
|
947
1008
|
function refreshBridgeOwnershipSafe(options = {}) {
|
|
948
1009
|
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
949
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
|
+
}
|
|
950
1021
|
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
951
1022
|
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
952
1023
|
// exactly one session holds remote; losers fully release, no handover.
|
|
@@ -956,6 +1027,18 @@ function notifyRemoteSuperseded() {
|
|
|
956
1027
|
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
957
1028
|
} catch {}
|
|
958
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
|
+
}
|
|
959
1042
|
async function refreshBridgeOwnership(options = {}) {
|
|
960
1043
|
// Coalesce concurrent callers onto the in-flight refresh so backend tool
|
|
961
1044
|
// calls landing during normal login wait for the same connect attempt
|
|
@@ -972,40 +1055,44 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
972
1055
|
if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
|
|
973
1056
|
return;
|
|
974
1057
|
}
|
|
975
|
-
|
|
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;
|
|
976
1066
|
if (owned) {
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
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 {}
|
|
981
1071
|
await startOwnedRuntime(options);
|
|
982
1072
|
return;
|
|
983
1073
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
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) {
|
|
996
1085
|
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
997
1086
|
}
|
|
998
|
-
// Notify the parent UNCONDITIONALLY (not only when we were connected):
|
|
999
|
-
// a loser whose backend never connected (seat stolen mid-connect,
|
|
1000
|
-
// connect failed, or already in standby) must still drop its Remote
|
|
1001
|
-
// indicator. The parent handler is idempotent — it only acts while
|
|
1002
|
-
// remoteEnabled is still true — so repeat ticks are harmless.
|
|
1003
1087
|
notifyRemoteSuperseded();
|
|
1004
1088
|
return;
|
|
1005
1089
|
}
|
|
1006
|
-
//
|
|
1007
|
-
|
|
1008
|
-
|
|
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) {
|
|
1009
1096
|
await startOwnedRuntime(options);
|
|
1010
1097
|
}
|
|
1011
1098
|
})();
|
|
@@ -1515,10 +1602,12 @@ const {
|
|
|
1515
1602
|
lifecycle: {
|
|
1516
1603
|
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
1517
1604
|
getChannelBridgeActive: () => channelBridgeActive,
|
|
1605
|
+
getOwned: () => getBridgeOwnershipSnapshot().owned,
|
|
1518
1606
|
setChannelBridgeActive: (v) => { channelBridgeActive = v; },
|
|
1519
1607
|
writeBridgeState,
|
|
1520
1608
|
stopServerTyping,
|
|
1521
1609
|
claimBridgeOwnership,
|
|
1610
|
+
notifyRemoteAcquired,
|
|
1522
1611
|
refreshBridgeOwnership,
|
|
1523
1612
|
bindPersistedTranscriptIfAny,
|
|
1524
1613
|
stopOwnedRuntime,
|
|
@@ -1845,14 +1934,15 @@ async function init(_sharedMcp) {
|
|
|
1845
1934
|
async function start() {
|
|
1846
1935
|
channelBridgeActive = true;
|
|
1847
1936
|
writeBridgeState(true);
|
|
1848
|
-
// Opt-in remote, single-owner, last-wins.
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1851
|
-
//
|
|
1852
|
-
|
|
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.
|
|
1853
1943
|
const _bindingReadyStart = Date.now();
|
|
1854
1944
|
try {
|
|
1855
|
-
await
|
|
1945
|
+
await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
|
|
1856
1946
|
bindingReadyStatus = "resolved";
|
|
1857
1947
|
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
1858
1948
|
_bindingReadyResolve(true);
|
|
@@ -1862,13 +1952,10 @@ async function start() {
|
|
|
1862
1952
|
_bindingReadyResolve(e);
|
|
1863
1953
|
}
|
|
1864
1954
|
// Ownership timer: keep checking whether a newer remote session has taken
|
|
1865
|
-
// over (last-wins) so a superseded owner disconnects promptly.
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
}, 3e3);
|
|
1870
|
-
bridgeOwnershipTimer.unref?.();
|
|
1871
|
-
}
|
|
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();
|
|
1872
1959
|
// Hot-reload config on file change (schedules/webhooks/events).
|
|
1873
1960
|
if (!_configWatcher) {
|
|
1874
1961
|
try {
|
|
@@ -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.
|
|
@@ -190,25 +190,42 @@ function atomicWrite(targetPath, contents) {
|
|
|
190
190
|
catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// In-memory dedupeKey(filePath) -> absolute buffer path index. Replaces the
|
|
194
|
+
// per-event O(N) readdir+read+parse dedupe scan in bufferToDisk. Seeded once
|
|
195
|
+
// (lazily) from disk so cross-restart dedupe (one buffered ingest per
|
|
196
|
+
// transcriptPath) still holds; maintained incrementally on write. A stale
|
|
197
|
+
// entry (file drained/renamed) is caught by an existsSync guard at the call
|
|
198
|
+
// site, so no drain/replay format or TTL semantics change.
|
|
199
|
+
const _dedupeIndex = new Map()
|
|
200
|
+
let _dedupeIndexSeeded = false
|
|
201
|
+
function seedDedupeIndex(kind) {
|
|
202
|
+
if (_dedupeIndexSeeded) return
|
|
203
|
+
_dedupeIndexSeeded = true
|
|
204
|
+
let existing = []
|
|
205
|
+
try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
|
|
206
|
+
for (const name of existing) {
|
|
207
|
+
if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
|
|
208
|
+
try {
|
|
209
|
+
const prev = JSON.parse(fs.readFileSync(path.join(BUFFER_DIR, name), 'utf8'))
|
|
210
|
+
if (prev && prev.filePath != null) _dedupeIndex.set(prev.filePath, path.join(BUFFER_DIR, name))
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
193
215
|
function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
|
|
194
216
|
try {
|
|
195
217
|
fs.mkdirSync(BUFFER_DIR, { recursive: true })
|
|
196
218
|
if (dedupeKey != null) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Atomic overwrite (tmp+rename); preserves the original (oldest)
|
|
206
|
-
// ordering slot without exposing a half-written file to a drainer.
|
|
207
|
-
atomicWrite(full, JSON.stringify(payload, null, 2))
|
|
208
|
-
return full
|
|
209
|
-
}
|
|
210
|
-
} catch {}
|
|
219
|
+
// In-memory index (seeded once from disk) replaces the per-event O(N)
|
|
220
|
+
// readdir+read+parse scan. Overwrite the existing buffered file for this
|
|
221
|
+
// dedupeKey in place (atomic tmp+rename) to keep its oldest ordering slot.
|
|
222
|
+
seedDedupeIndex(kind)
|
|
223
|
+
const idx = _dedupeIndex.get(dedupeKey)
|
|
224
|
+
if (idx && fs.existsSync(idx)) {
|
|
225
|
+
atomicWrite(idx, JSON.stringify(payload, null, 2))
|
|
226
|
+
return idx
|
|
211
227
|
}
|
|
228
|
+
if (idx) _dedupeIndex.delete(dedupeKey)
|
|
212
229
|
}
|
|
213
230
|
enforceBufferCap()
|
|
214
231
|
const random = Math.random().toString(36).slice(2, 10)
|
|
@@ -216,6 +233,7 @@ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
|
|
|
216
233
|
// ordering under a lexicographic sort.
|
|
217
234
|
const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
|
|
218
235
|
atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
|
|
236
|
+
if (dedupeKey != null) _dedupeIndex.set(dedupeKey, bufferPath)
|
|
219
237
|
return bufferPath
|
|
220
238
|
} catch (bufErr) {
|
|
221
239
|
process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
|
|
@@ -76,6 +76,9 @@ class OutputForwarder {
|
|
|
76
76
|
lastFileSize = 0;
|
|
77
77
|
readFileSize = 0;
|
|
78
78
|
watchingPath = "";
|
|
79
|
+
// Set by readNewLines when a capped tick left more complete bytes on disk,
|
|
80
|
+
// so the poll callback re-arms a flush without waiting on a fresh fs event.
|
|
81
|
+
_pendingReadMore = false;
|
|
79
82
|
watcher = null;
|
|
80
83
|
idleTimer = null;
|
|
81
84
|
onIdleCallback = null;
|
|
@@ -96,6 +99,11 @@ class OutputForwarder {
|
|
|
96
99
|
// into the next pending text payload so we don't grow unbounded but
|
|
97
100
|
// also don't lose content.
|
|
98
101
|
static SEND_QUEUE_MAX = 200;
|
|
102
|
+
// Cap the bytes read per streaming tick so a large burst (e.g. a big tool
|
|
103
|
+
// result written at once) is amortized across ticks instead of allocating a
|
|
104
|
+
// multi-MB buffer and blocking the loop on one readSync. The cursor only
|
|
105
|
+
// advances by bytes actually consumed; the remainder is picked up next tick.
|
|
106
|
+
static MAX_TICK_READ_BYTES = 2 * 1024 * 1024;
|
|
99
107
|
// Persisted final-flush ledger so a forwarder restart can resume final
|
|
100
108
|
// forwarding instead of giving up after 5 short retries.
|
|
101
109
|
pendingFinalFlush = false;
|
|
@@ -217,12 +225,13 @@ class OutputForwarder {
|
|
|
217
225
|
}
|
|
218
226
|
}
|
|
219
227
|
/** Read new bytes from transcript file since readFileSize */
|
|
220
|
-
readNewLines() {
|
|
228
|
+
readNewLines(uncapped = false) {
|
|
221
229
|
if (!this.transcriptPath || !existsSync(this.transcriptPath)) {
|
|
222
230
|
return { lines: [], nextFileSize: this.readFileSize };
|
|
223
231
|
}
|
|
224
232
|
let fd = null;
|
|
225
233
|
try {
|
|
234
|
+
this._pendingReadMore = false;
|
|
226
235
|
const stat = this._pendingStat ?? statSync(this.transcriptPath);
|
|
227
236
|
this._pendingStat = null;
|
|
228
237
|
// File shrank below our cursor: the transcript was rotated out from
|
|
@@ -237,16 +246,33 @@ class OutputForwarder {
|
|
|
237
246
|
return { lines: [], nextFileSize: this.readFileSize };
|
|
238
247
|
}
|
|
239
248
|
const startOffset = this.readFileSize;
|
|
249
|
+
const available = stat.size - startOffset;
|
|
250
|
+
// Cap the per-tick read (streaming path). The final-flush path passes
|
|
251
|
+
// uncapped=true to drain the whole tail in one shot.
|
|
252
|
+
let readLen = uncapped ? available : Math.min(available, OutputForwarder.MAX_TICK_READ_BYTES);
|
|
240
253
|
fd = openSync(this.transcriptPath, "r");
|
|
241
|
-
|
|
242
|
-
readSync(fd, buf, 0,
|
|
254
|
+
let buf = Buffer.alloc(readLen);
|
|
255
|
+
readSync(fd, buf, 0, readLen, startOffset);
|
|
243
256
|
// Only advance readFileSize to the last newline boundary. A trailing
|
|
244
257
|
// partial line (writer still appending) must be re-read next tick;
|
|
245
258
|
// otherwise the parse-failed slice is silently consumed forever.
|
|
246
|
-
|
|
259
|
+
let lastNl = buf.lastIndexOf(0x0a);
|
|
260
|
+
// The capped window split a single JSONL line larger than the cap (no
|
|
261
|
+
// newline inside it). Fall back to reading the full available range this
|
|
262
|
+
// tick so the line boundary is found — matches the pre-cap behavior and
|
|
263
|
+
// avoids getting stuck forever on an oversize line.
|
|
264
|
+
if (lastNl < 0 && readLen < available) {
|
|
265
|
+
readLen = available;
|
|
266
|
+
buf = Buffer.alloc(readLen);
|
|
267
|
+
readSync(fd, buf, 0, readLen, startOffset);
|
|
268
|
+
lastNl = buf.lastIndexOf(0x0a);
|
|
269
|
+
}
|
|
247
270
|
const consumed = lastNl >= 0 ? lastNl + 1 : 0;
|
|
248
271
|
const nextFileSize = startOffset + consumed;
|
|
249
272
|
this.readFileSize = nextFileSize;
|
|
273
|
+
// Capped read left more complete bytes on disk: signal the poll callback
|
|
274
|
+
// to re-tick so a burst drains without waiting on another fs event.
|
|
275
|
+
this._pendingReadMore = readLen < available && nextFileSize > startOffset;
|
|
250
276
|
const text = consumed > 0 ? buf.slice(0, consumed).toString("utf8") : "";
|
|
251
277
|
return {
|
|
252
278
|
lines: text ? text.split("\n").filter((l) => l.trim()) : [],
|
|
@@ -264,8 +290,8 @@ class OutputForwarder {
|
|
|
264
290
|
lastToolName = "";
|
|
265
291
|
lastToolFilePath = "";
|
|
266
292
|
/** Extract new assistant text + tool logs from transcript since readFileSize */
|
|
267
|
-
extractNewText() {
|
|
268
|
-
const { lines: newLines, nextFileSize } = this.readNewLines();
|
|
293
|
+
extractNewText(uncapped = false) {
|
|
294
|
+
const { lines: newLines, nextFileSize } = this.readNewLines(uncapped);
|
|
269
295
|
let newText = "";
|
|
270
296
|
for (const l of newLines) {
|
|
271
297
|
try {
|
|
@@ -635,7 +661,9 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
635
661
|
await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
|
|
636
662
|
} catch {} // best-effort: remove reaction is non-critical
|
|
637
663
|
}
|
|
638
|
-
|
|
664
|
+
// Final flush drains the whole remaining tail (uncapped) so a turn's
|
|
665
|
+
// last write can't be split across ticks with nothing left to re-tick it.
|
|
666
|
+
const { text: newText, nextFileSize } = this.extractNewText(true);
|
|
639
667
|
if (newText) {
|
|
640
668
|
const finalItem = { type: "text", text: newText, nextFileSize, bufferText: newText, channelId };
|
|
641
669
|
try {
|
|
@@ -836,6 +864,9 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
836
864
|
if (hadText) {
|
|
837
865
|
this.resetIdleTimer();
|
|
838
866
|
}
|
|
867
|
+
// A capped tick left more complete bytes on disk: re-arm a flush so the
|
|
868
|
+
// burst drains even if the writer has gone quiet (no further fs event).
|
|
869
|
+
if (this._pendingReadMore) this.scheduleWatchFlush();
|
|
839
870
|
}).catch(err => process.stderr.write(`[output-forwarder] forwardNewText rejected: ${err?.message || err}\n`));
|
|
840
871
|
}, 100);
|
|
841
872
|
}
|
|
@@ -33,9 +33,15 @@ function createOwnerHeartbeat({
|
|
|
33
33
|
function getBridgeOwnershipSnapshot() {
|
|
34
34
|
return currentOwnerState();
|
|
35
35
|
}
|
|
36
|
-
function claimBridgeOwnership(reason) {
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
function claimBridgeOwnership(reason, options = {}) {
|
|
37
|
+
// Returns true only when THIS instance actually holds the seat after the
|
|
38
|
+
// write. With options.timeoutMs:0 (try-once) a contended lock throws
|
|
39
|
+
// ELOCKCONTENDED — the caller catches it and treats the seat as busy.
|
|
40
|
+
const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
|
|
41
|
+
const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
|
|
42
|
+
const claimed = res?.instanceId === getInstanceId();
|
|
43
|
+
if (claimed) logOwnership(`claimed owner (${reason})`);
|
|
44
|
+
return claimed;
|
|
39
45
|
}
|
|
40
46
|
function startOwnerHeartbeat() {
|
|
41
47
|
if (ownerHeartbeatTimer) return;
|
|
@@ -49,7 +55,10 @@ function createOwnerHeartbeat({
|
|
|
49
55
|
// onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
|
|
50
56
|
// owner claiming the seat between currentOwnerState() and the locked
|
|
51
57
|
// write is never overwritten (see refreshActiveInstance CAS guard).
|
|
52
|
-
|
|
58
|
+
// Try-once (timeoutMs:0): the heartbeat must never block on the
|
|
59
|
+
// active-instance lock. On contention refreshActiveInstance throws and
|
|
60
|
+
// we simply skip this tick — the next tick catches up.
|
|
61
|
+
if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
|
|
53
62
|
} catch (e) {
|
|
54
63
|
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
55
64
|
}
|
|
@@ -211,6 +211,34 @@ function writeActiveInstance(state) {
|
|
|
211
211
|
ensureRuntimeDirs();
|
|
212
212
|
writeJsonFile(ACTIVE_INSTANCE_FILE, state);
|
|
213
213
|
}
|
|
214
|
+
// Non-blocking ownership probe for the periodic refresh/heartbeat tick. Reads
|
|
215
|
+
// active-instance WITHOUT taking the lock (never blocks). Distinguishes:
|
|
216
|
+
// 'absent' — file does not exist → seat is claimable
|
|
217
|
+
// 'stale' — parseable but owner PID is dead → seat is claimable
|
|
218
|
+
// 'live' — parseable, owner PID alive → seat is held (state.instanceId)
|
|
219
|
+
// 'unknown' — file present but unreadable/partial (concurrent atomic rename)
|
|
220
|
+
// → INDETERMINATE; callers must treat as busy and NEVER claim.
|
|
221
|
+
// This is the read-side guard for "locked/unreadable = busy/unknown owner,
|
|
222
|
+
// never claimable/no-owner": a torn read during another writer's rename must
|
|
223
|
+
// not be mistaken for an empty seat.
|
|
224
|
+
function probeActiveOwner() {
|
|
225
|
+
try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
|
|
226
|
+
let raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
|
|
227
|
+
if (!raw) {
|
|
228
|
+
// Transient partial content during an atomic rename — retry once.
|
|
229
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
230
|
+
raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
|
|
231
|
+
if (!raw) {
|
|
232
|
+
// Re-check existence to disambiguate a completed delete (absent) from a
|
|
233
|
+
// still-unreadable file (unknown/busy).
|
|
234
|
+
try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
|
|
235
|
+
return { status: 'unknown', state: null };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const staleReason = activeInstanceStaleReason(raw);
|
|
239
|
+
if (staleReason) return { status: 'stale', state: raw, staleReason };
|
|
240
|
+
return { status: 'live', state: raw };
|
|
241
|
+
}
|
|
214
242
|
function buildActiveInstanceState(instanceId, meta) {
|
|
215
243
|
const gatewayMeta = Object.fromEntries(
|
|
216
244
|
Object.entries(meta || {}).filter(([k]) => k.startsWith('gateway_'))
|
|
@@ -233,6 +261,11 @@ function buildActiveInstanceState(instanceId, meta) {
|
|
|
233
261
|
}
|
|
234
262
|
function refreshActiveInstance(instanceId, meta, options) {
|
|
235
263
|
ensureRuntimeDirs();
|
|
264
|
+
// Periodic refresh/heartbeat callers pass options.timeoutMs:0 (try-once) so
|
|
265
|
+
// the ownership tick never blocks on the active-instance lock — contention
|
|
266
|
+
// throws ELOCKCONTENDED, which the caller treats as "busy, skip this tick".
|
|
267
|
+
const writeOpts = { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' };
|
|
268
|
+
if (options && Number.isFinite(options.timeoutMs)) writeOpts.timeoutMs = options.timeoutMs;
|
|
236
269
|
return updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
|
|
237
270
|
const prevForPreserve = curRaw;
|
|
238
271
|
const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
|
|
@@ -374,7 +407,7 @@ function refreshActiveInstance(instanceId, meta, options) {
|
|
|
374
407
|
next.ui_heartbeat_at = Date.now();
|
|
375
408
|
}
|
|
376
409
|
return { ...preservedExtra, ...next };
|
|
377
|
-
},
|
|
410
|
+
}, writeOpts);
|
|
378
411
|
}
|
|
379
412
|
const SERVER_PID_FILE = join(
|
|
380
413
|
RUNTIME_ROOT,
|
|
@@ -525,6 +558,7 @@ export {
|
|
|
525
558
|
notePreviousServerIfAny,
|
|
526
559
|
makeInstanceId,
|
|
527
560
|
readActiveInstance,
|
|
561
|
+
probeActiveOwner,
|
|
528
562
|
refreshActiveInstance,
|
|
529
563
|
releaseOwnedChannelLocks,
|
|
530
564
|
touchUiHeartbeat,
|