mixdog 0.9.51 → 0.9.53

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.
Files changed (178) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/bench-run.mjs +2 -2
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/channel-daemon-smoke.mjs +630 -10
  10. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  11. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  12. package/scripts/code-graph-root-federation-test.mjs +273 -0
  13. package/scripts/compact-pressure-test.mjs +159 -1
  14. package/scripts/compact-smoke.mjs +80 -0
  15. package/scripts/context-mcp-metering-test.mjs +1350 -19
  16. package/scripts/deferred-tool-loading-test.mjs +17 -0
  17. package/scripts/desktop-session-bridge-test.mjs +704 -0
  18. package/scripts/freevar-smoke.mjs +7 -4
  19. package/scripts/gemini-provider-test.mjs +1053 -0
  20. package/scripts/interrupted-turn-history-test.mjs +371 -0
  21. package/scripts/lifecycle-api-test.mjs +137 -0
  22. package/scripts/max-output-recovery-test.mjs +86 -0
  23. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  24. package/scripts/memory-core-input-test.mjs +10 -0
  25. package/scripts/memory-pg-recovery-test.mjs +59 -0
  26. package/scripts/openai-oauth-ws-1006-retry-test.mjs +387 -6
  27. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  28. package/scripts/parent-abort-link-test.mjs +24 -0
  29. package/scripts/process-lifecycle-test.mjs +447 -0
  30. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  31. package/scripts/provider-contract-test.mjs +525 -0
  32. package/scripts/provider-toolcall-test.mjs +492 -11
  33. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  34. package/scripts/resource-admission-test.mjs +789 -0
  35. package/scripts/session-orphan-sweep-test.mjs +27 -1
  36. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  37. package/scripts/steering-drain-buckets-test.mjs +18 -0
  38. package/scripts/toolcall-args-test.mjs +14 -6
  39. package/scripts/tui-transcript-perf-test.mjs +5 -7
  40. package/src/cli.mjs +15 -2
  41. package/src/lib/keychain-cjs.cjs +36 -23
  42. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +27 -13
  43. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  44. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  45. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  46. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  47. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  48. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +114 -308
  49. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +31 -21
  50. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -286
  51. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +42 -5
  52. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  53. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +84 -150
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -119
  56. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  57. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  58. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  59. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  60. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  61. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +28 -74
  64. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  65. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +136 -20
  66. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +94 -33
  67. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +201 -123
  68. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +37 -28
  69. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  70. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  71. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  72. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +50 -45
  73. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  74. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  75. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  76. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +229 -111
  77. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +130 -63
  78. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  79. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +37 -224
  81. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  82. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +81 -42
  83. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  84. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  85. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  86. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  87. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +30 -7
  88. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  89. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  90. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  91. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  92. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  93. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  94. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +12 -1
  96. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  97. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  98. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  99. package/src/runtime/agent/orchestrator/session/store.mjs +353 -108
  100. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  101. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  102. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  103. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  104. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  107. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  108. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  109. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +317 -250
  112. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  113. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  115. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  116. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  117. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  118. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +125 -85
  119. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  120. package/src/runtime/channels/backends/discord.mjs +6 -6
  121. package/src/runtime/channels/lib/config.mjs +15 -2
  122. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  123. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  124. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  125. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  126. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  127. package/src/runtime/memory/index.mjs +46 -202
  128. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  129. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  130. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  131. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  132. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  133. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -17
  134. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  135. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  136. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  137. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  138. package/src/runtime/shared/atomic-file.mjs +28 -150
  139. package/src/runtime/shared/config.mjs +58 -13
  140. package/src/runtime/shared/llm/cost.mjs +14 -4
  141. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  142. package/src/runtime/shared/process-lifecycle.mjs +436 -0
  143. package/src/runtime/shared/process-shutdown.mjs +33 -4
  144. package/src/runtime/shared/resource-admission.mjs +364 -0
  145. package/src/runtime/shared/staged-child-result.mjs +19 -0
  146. package/src/session-runtime/channel-config-api.mjs +7 -7
  147. package/src/session-runtime/config-lifecycle.mjs +20 -17
  148. package/src/session-runtime/context-status.mjs +38 -27
  149. package/src/session-runtime/cwd-plugins.mjs +9 -7
  150. package/src/session-runtime/env.mjs +1 -2
  151. package/src/session-runtime/hitch-profile.mjs +45 -0
  152. package/src/session-runtime/lifecycle-api.mjs +60 -13
  153. package/src/session-runtime/mcp-glue.mjs +6 -11
  154. package/src/session-runtime/provider-init-key.mjs +17 -0
  155. package/src/session-runtime/provider-request-tools.mjs +72 -0
  156. package/src/session-runtime/runtime-core.mjs +49 -105
  157. package/src/session-runtime/runtime-paths.mjs +20 -0
  158. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  159. package/src/session-runtime/session-turn-api.mjs +5 -4
  160. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  161. package/src/session-runtime/tool-catalog.mjs +390 -104
  162. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  163. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  164. package/src/standalone/agent-tool.mjs +29 -200
  165. package/src/standalone/channel-admin.mjs +29 -0
  166. package/src/standalone/channel-daemon-client.mjs +200 -38
  167. package/src/standalone/channel-daemon-transport.mjs +136 -9
  168. package/src/standalone/channel-worker.mjs +79 -12
  169. package/src/tui/App.jsx +11 -5
  170. package/src/tui/dist/index.mjs +275 -343
  171. package/src/tui/engine/session-api-ext.mjs +50 -6
  172. package/src/tui/engine/session-api.mjs +1 -1
  173. package/src/tui/engine/turn.mjs +10 -6
  174. package/src/tui/engine.mjs +13 -4
  175. package/src/tui/index.jsx +5 -1
  176. package/src/tui/lib/voice-setup.mjs +21 -5
  177. package/scripts/_devtools-stub.mjs +0 -1
  178. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -90,6 +90,8 @@ import { createQueryHandlers } from './lib/query-handlers.mjs'
90
90
  import { createSessionIngestRuntime } from './lib/session-ingest-runtime.mjs'
91
91
  import { createMemoryActionHandlers } from './lib/memory-action-handlers.mjs'
92
92
  import { createHttpRouter } from './lib/http-router.mjs'
93
+ import { createMemoryPortAdvertiser } from './lib/memory-port-advertiser.mjs'
94
+ import { createMemoryDaemonLifecycle } from './lib/memory-daemon-lifecycle.mjs'
93
95
  import {
94
96
  readMainConfig,
95
97
  readRecapEnabled,
@@ -141,189 +143,31 @@ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
141
143
  ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
142
144
  : path.join(os.tmpdir(), 'mixdog')
143
145
 
144
- let _periodicAdvertiseInstalled = false
145
- let _periodicAdvertiseTimer = null
146
- // Single module-level advertise retry chain. A newer advertiseMemoryPort call
147
- // cancels the older chain so a delayed retry never replays a stale boundPort.
148
- let _advertiseRetryTimer = null
149
- let _advertiseGeneration = 0
150
- // Track the most recently advertised port so the periodic tick re-reads it
151
- // every interval. Without this the setInterval closure binds the FIRST port
152
- // (the upstream we proxied to) and keeps re-advertising the dead upstream
153
- // port after fork-proxy promotion swaps in our own locally-bound port.
154
- let _currentAdvertisedPort = null
155
-
156
146
  const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? process.pid
157
147
  const _isPidAliveLocal = isPidAliveLocal
148
+ const _memoryPortAdvertiser = createMemoryPortAdvertiser({
149
+ readServiceAdvert: _readServiceAdvert,
150
+ writeServiceAdvert: _writeServiceAdvert,
151
+ parsePositivePid,
152
+ isPidAliveLocal: _isPidAliveLocal,
153
+ memoryServerPid: MEMORY_SERVER_PID,
154
+ log: __mixdogMemoryLog,
155
+ })
156
+ const { advertiseMemoryPort } = _memoryPortAdvertiser
158
157
  const MEMORY_DAEMON_MODE = process.env.MIXDOG_MEMORY_DAEMON === '1'
159
158
  const MEMORY_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000)
160
- let _idleShutdownTimer = null
161
-
162
- function touchDaemonIdleTimer(reason = 'activity') {
163
- if (!MEMORY_DAEMON_MODE || MEMORY_IDLE_TTL_MS <= 0) return
164
- if (_idleShutdownTimer) {
165
- try { clearTimeout(_idleShutdownTimer) } catch {}
166
- _idleShutdownTimer = null
167
- }
168
- _idleShutdownTimer = setTimeout(() => {
169
- __mixdogMemoryLog(`[memory-service] daemon idle TTL elapsed after ${reason}; shutting down\n`)
170
- stop()
171
- .then(() => process.exit(0))
172
- .catch((e) => {
173
- __mixdogMemoryLog(`[memory-service] daemon idle shutdown failed: ${e?.message || e}\n`)
174
- process.exit(1)
175
- })
176
- }, MEMORY_IDLE_TTL_MS)
177
- _idleShutdownTimer.unref?.()
178
- }
179
-
180
- // ── Connected-client tracking + prompt shutdown ───────────────────────────
181
- // The daemon is shared by multiple proxy clients (TUI host + channels worker,
182
- // potentially several sessions). Each client registers/deregisters over HTTP
183
- // (see /client/register, /client/deregister in lib/http-router.mjs). When the
184
- // last client goes away we arm a short grace timer (default 10s) so a quick
185
- // reconnect keeps the daemon warm, but an actually-closed CLI reaps the daemon
186
- // in seconds instead of waiting out the 10-minute idle TTL (kept as backstop).
187
159
  const MEMORY_CLIENT_GRACE_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_CLIENT_GRACE_MS) || 10_000)
188
- const _connectedClients = new Map() // clientPid -> lastSeenMs
189
- let _everHadClient = false
190
- let _clientGraceTimer = null
191
- let _clientSweepTimer = null
192
-
193
- function _clientShutdownEnabled() {
194
- return MEMORY_DAEMON_MODE && MEMORY_CLIENT_GRACE_MS > 0
195
- }
196
-
197
- function pruneDeadClients() {
198
- for (const pid of [..._connectedClients.keys()]) {
199
- if (!_isPidAliveLocal(pid)) _connectedClients.delete(pid)
200
- }
201
- }
202
-
203
- function cancelClientGrace() {
204
- if (_clientGraceTimer) {
205
- try { clearTimeout(_clientGraceTimer) } catch {}
206
- _clientGraceTimer = null
207
- }
208
- }
209
-
210
- function armClientGrace(reason = 'last client gone') {
211
- if (!_clientShutdownEnabled() || _clientGraceTimer) return
212
- _clientGraceTimer = setTimeout(() => {
213
- _clientGraceTimer = null
214
- pruneDeadClients()
215
- if (_connectedClients.size > 0) return
216
- __mixdogMemoryLog(`[memory-service] daemon client grace elapsed (${reason}); shutting down\n`)
217
- stop()
218
- .then(() => process.exit(0))
219
- .catch((e) => {
220
- __mixdogMemoryLog(`[memory-service] daemon client-grace shutdown failed: ${e?.message || e}\n`)
221
- process.exit(1)
222
- })
223
- }, MEMORY_CLIENT_GRACE_MS)
224
- _clientGraceTimer.unref?.()
225
- }
226
-
227
- function startClientSweep() {
228
- if (_clientSweepTimer || !_clientShutdownEnabled()) return
229
- // Reap clients that died without deregistering so a crashed CLI still frees
230
- // the daemon in grace-scale time rather than waiting for the idle TTL.
231
- const interval = Math.max(1000, Math.min(MEMORY_CLIENT_GRACE_MS, 5000))
232
- _clientSweepTimer = setInterval(() => {
233
- pruneDeadClients()
234
- if (_everHadClient && _connectedClients.size === 0) armClientGrace('all clients gone (sweep)')
235
- }, interval)
236
- _clientSweepTimer.unref?.()
237
- }
238
-
239
- function registerClient(clientPid) {
240
- const pid = parsePositivePid(clientPid)
241
- if (!pid) return true
242
- // Reject registration once shutdown has begun. stop() sets _stopPromise
243
- // synchronously (before its first await) and, in daemon mode, always ends
244
- // in process.exit — so a daemon that is draining will never revive. Signal
245
- // the proxy (via a distinct 503) to respawn a fresh daemon instead of
246
- // binding to this dying one, which would fail the subsequent /api/tool.
247
- if (_stopPromise) return false
248
- _connectedClients.set(pid, Date.now())
249
- _everHadClient = true
250
- cancelClientGrace()
251
- startClientSweep()
252
- return true
253
- }
254
-
255
- function deregisterClient(clientPid) {
256
- const pid = parsePositivePid(clientPid)
257
- if (pid) _connectedClients.delete(pid)
258
- pruneDeadClients()
259
- if (_everHadClient && _connectedClients.size === 0) armClientGrace('last client deregistered')
260
- }
261
-
262
- function advertiseMemoryPort(boundPort, attempt = 0) {
263
- if (!Number.isFinite(boundPort) || boundPort <= 0) return
264
- // A fresh top-level advertise (attempt 0) supersedes any pending retry chain:
265
- // last write wins, so a delayed retry never clobbers a newer boundPort.
266
- if (attempt === 0) {
267
- _currentAdvertisedPort = boundPort
268
- _advertiseGeneration++
269
- if (_advertiseRetryTimer) { try { clearTimeout(_advertiseRetryTimer) } catch {} ; _advertiseRetryTimer = null }
270
- }
271
- const generation = _advertiseGeneration
272
- if (!_periodicAdvertiseInstalled) {
273
- _periodicAdvertiseInstalled = true
274
- _periodicAdvertiseTimer = setInterval(() => {
275
- try {
276
- if (_currentAdvertisedPort != null) {
277
- advertiseMemoryPort(_currentAdvertisedPort)
278
- }
279
- } catch {}
280
- }, 30_000)
281
- _periodicAdvertiseTimer.unref?.()
282
- }
283
- try {
284
- // Single-writer discovery file (discovery/memory.json), plain atomic rename
285
- // with NO .lock: memory_port discovery can never be starved by the shared
286
- // active-instance.json lock. Conflict guard preserved: a live OTHER memory
287
- // owner advertising a different port is not clobbered.
288
- const cur = _readServiceAdvert('memory')
289
- const curMemPort = Number(cur?.port)
290
- const curMemPid = parsePositivePid(cur?.pid)
291
- const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
292
- const otherOwnerAlive =
293
- curMemPid != null &&
294
- curMemPid !== MEMORY_SERVER_PID &&
295
- _isPidAliveLocal(curMemPid)
296
- if (portConflict && otherOwnerAlive) {
297
- __mixdogMemoryLog(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
298
- if (generation === _advertiseGeneration) _advertiseRetryTimer = null
299
- return
300
- }
301
- _writeServiceAdvert('memory', {
302
- port: boundPort,
303
- ...(MEMORY_SERVER_PID ? { pid: MEMORY_SERVER_PID } : {}),
304
- })
305
- if (generation === _advertiseGeneration) _advertiseRetryTimer = null
306
- } catch (e) {
307
- // Boot path must not serially block on the default 8s lock wait: use a short
308
- // lock timeout and treat lock contention/timeout as transient so pg_port /
309
- // memory_port still eventually publish via unref'd, backed-off bg retries.
310
- const transient =
311
- e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES' ||
312
- e?.code === 'ELOCKTIMEOUT' || e?.code === 'ELOCKCONTENDED'
313
- if (transient && attempt < 5 && generation === _advertiseGeneration) {
314
- const delay = Math.min(2000, 50 * 2 ** attempt)
315
- // Fire-time generation re-check: even if clearTimeout was missed, a
316
- // retry from a superseded chain must never republish an old boundPort.
317
- _advertiseRetryTimer = setTimeout(() => {
318
- if (generation !== _advertiseGeneration) return
319
- advertiseMemoryPort(boundPort, attempt + 1)
320
- }, delay)
321
- _advertiseRetryTimer.unref?.()
322
- return
323
- }
324
- __mixdogMemoryLog(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
325
- }
326
- }
160
+ const _daemonLifecycle = createMemoryDaemonLifecycle({
161
+ daemonMode: MEMORY_DAEMON_MODE,
162
+ idleTtlMs: MEMORY_IDLE_TTL_MS,
163
+ clientGraceMs: MEMORY_CLIENT_GRACE_MS,
164
+ parsePositivePid,
165
+ isPidAlive: _isPidAliveLocal,
166
+ isStopping: () => _stopPromise != null,
167
+ stop,
168
+ log: __mixdogMemoryLog,
169
+ })
170
+ const { touchDaemonIdleTimer, registerClient, deregisterClient } = _daemonLifecycle
327
171
 
328
172
  const LOCK_FILE = path.join(DATA_DIR, '.memory-service.lock')
329
173
  // Owner-election lock. Separate from LOCK_FILE so single-instance mode keeps
@@ -853,27 +697,9 @@ export async function stop() {
853
697
  if (_stopPromise) return _stopPromise
854
698
  _stopPromise = (async () => {
855
699
  _stopCycles()
856
- if (_periodicAdvertiseTimer) {
857
- try { clearInterval(_periodicAdvertiseTimer) } catch {}
858
- _periodicAdvertiseTimer = null
859
- }
860
- if (_advertiseRetryTimer) {
861
- try { clearTimeout(_advertiseRetryTimer) } catch {}
862
- _advertiseRetryTimer = null
863
- }
864
- _advertiseGeneration++
865
- _periodicAdvertiseInstalled = false
866
- _currentAdvertisedPort = null
700
+ _memoryPortAdvertiser.reset()
867
701
  _embeddingWarmup.reset()
868
- if (_idleShutdownTimer) {
869
- try { clearTimeout(_idleShutdownTimer) } catch {}
870
- _idleShutdownTimer = null
871
- }
872
- cancelClientGrace()
873
- if (_clientSweepTimer) {
874
- try { clearInterval(_clientSweepTimer) } catch {}
875
- _clientSweepTimer = null
876
- }
702
+ _daemonLifecycle.reset()
877
703
  await stopLlmWorker()
878
704
  resetHttpListenErrorHandler()
879
705
  if (_httpBoundPort != null || _httpReadyPromise) {
@@ -1009,12 +835,14 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
1009
835
  // removes port file) then exit(0). Prevents taskkill /F from bypassing
1010
836
  // graceful shutdown and leaving pgdata in an inconsistent checkpoint state.
1011
837
  let _stopInFlight = false
838
+ let _syncPgStopRequested = false
1012
839
  const _workerSignalHandler = (sig) => {
1013
840
  if (_stopInFlight) {
1014
841
  __mixdogMemoryLog(`[memory-worker] ${sig} — stop already in flight, ignoring\n`)
1015
842
  return
1016
843
  }
1017
844
  _stopInFlight = true
845
+ _syncPgStopRequested = true
1018
846
  __mixdogMemoryLog(`[memory-worker] received ${sig} — calling stop() for clean shutdown\n`)
1019
847
  const _exitTimer = setTimeout(() => {
1020
848
  __mixdogMemoryLog(`[memory-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
@@ -1037,7 +865,14 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
1037
865
  // stop() path runs, orphaning PG mid-write. Best-effort sync pg_ctl stop on
1038
866
  // exit (no-op after a completed graceful stop). Skip in secondary mode — we
1039
867
  // do not own the shared PG there.
1040
- if (!memorySecondaryMode()) process.on('exit', () => { try { stopPgForShutdownSync() } catch {} })
868
+ if (!memorySecondaryMode()) process.on('exit', () => {
869
+ // Do not stop shared PG on an unexpected Memory crash. The singleton proxy
870
+ // will respawn a daemon that can attach to the still-healthy cluster. The
871
+ // sync stop is only a last resort after an explicit shutdown signal began.
872
+ if (_syncPgStopRequested) {
873
+ try { stopPgForShutdownSync() } catch {}
874
+ }
875
+ })
1041
876
 
1042
877
  // callId → AbortController for in-flight IPC calls (cancel handler uses this).
1043
878
  const _inFlightCalls = new Map()
@@ -1112,9 +947,18 @@ if (IS_MEMORY_ENTRY && process.env.MIXDOG_WORKER_MODE !== '1') {
1112
947
  ;(async () => {
1113
948
  acquireLock()
1114
949
  process.on('exit', releaseLock)
1115
- if (!memorySecondaryMode()) process.on('exit', () => { try { stopPgForShutdownSync() } catch {} })
1116
- process.on('SIGINT', () => { stop().finally(() => process.exit(0)) })
1117
- process.on('SIGTERM', () => { stop().finally(() => process.exit(0)) })
950
+ let syncPgStopRequested = false
951
+ if (!memorySecondaryMode()) process.on('exit', () => {
952
+ if (syncPgStopRequested) {
953
+ try { stopPgForShutdownSync() } catch {}
954
+ }
955
+ })
956
+ const stopFromSignal = () => {
957
+ syncPgStopRequested = true
958
+ stop().finally(() => process.exit(0))
959
+ }
960
+ process.on('SIGINT', stopFromSignal)
961
+ process.on('SIGTERM', stopFromSignal)
1118
962
  await init()
1119
963
  const transport = new StdioServerTransport()
1120
964
  await mcp.connect(transport)
@@ -38,6 +38,7 @@ import {
38
38
  import { resolveProjectId, resolveProjectScope } from './project-id-resolver.mjs'
39
39
  import { resolvePluginData } from '../../shared/plugin-paths.mjs'
40
40
  import { getMetaValue, isBootstrapComplete } from './memory.mjs'
41
+ import { createToolCallHandler } from './tool-call-handler.mjs'
41
42
 
42
43
  export function createMemoryActionHandlers({
43
44
  getDb,
@@ -879,59 +880,7 @@ export function createMemoryActionHandlers({
879
880
  return { text: `unknown memory action: ${action}`, isError: true }
880
881
  }
881
882
 
882
- async function handleToolCall(name, args, signal) {
883
- try {
884
- if (name === 'search_memories') {
885
- const result = await handleSearch(args || {}, signal)
886
- return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
887
- }
888
- if (name === 'recall') {
889
- // recall is aiWrapped in the unified build; in standalone mode map it to
890
- // search_memories so the advertised tool name actually works. Forward
891
- // every advertised arg so id/limit/offset/sort/includeArchived/
892
- // includeMembers/includeRaw reach handleSearch instead of being dropped.
893
- const a = args || {}
894
- const hasQuery = Array.isArray(a.query)
895
- ? a.query.some((value) => String(value || '').trim())
896
- : String(a.query ?? '').trim() !== ''
897
- const recallIds = hasQuery
898
- ? []
899
- : (Array.isArray(a.id) ? a.id : [a.id])
900
- .map((value) => Number(value))
901
- .filter((value) => Number.isInteger(value) && value > 0)
902
- const searchArgs = {
903
- ...(a.query !== undefined ? { query: a.query } : {}),
904
- ...(recallIds.length > 0 ? { ids: recallIds } : {}),
905
- ...(a.period ? { period: a.period } : {}),
906
- ...(a.limit !== undefined ? { limit: a.limit } : {}),
907
- ...(a.offset !== undefined ? { offset: a.offset } : {}),
908
- ...(a.sort !== undefined ? { sort: a.sort } : {}),
909
- ...(a.category !== undefined ? { category: a.category } : {}),
910
- ...(a.includeArchived !== undefined ? { includeArchived: a.includeArchived } : {}),
911
- ...(a.includeMembers !== undefined ? { includeMembers: a.includeMembers } : {}),
912
- ...(a.includeRaw !== undefined ? { includeRaw: a.includeRaw } : {}),
913
- ...(a.cwd ? { cwd: a.cwd } : {}),
914
- ...(a.projectScope ? { projectScope: a.projectScope } : {}),
915
- ...(a.sessionId ? { sessionId: a.sessionId } : {}),
916
- ...(a.session_id ? { session_id: a.session_id } : {}),
917
- ...(a.currentSession !== undefined ? { currentSession: a.currentSession } : {}),
918
- // Hint only — never a filter. Marks the caller's own session as
919
- // "(current)" in the multi-session grouped browse output.
920
- ...(a.currentSessionId ? { currentSessionId: a.currentSessionId } : {}),
921
- }
922
- const result = await handleSearch(searchArgs, signal)
923
- return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
924
- }
925
- if (name === 'memory') {
926
- const result = await handleMemoryAction(args || {}, signal)
927
- return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
928
- }
929
- return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true }
930
- } catch (err) {
931
- const msg = err instanceof Error ? err.message : String(err)
932
- return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true }
933
- }
934
- }
883
+ const handleToolCall = createToolCallHandler({ handleSearch, handleMemoryAction })
935
884
 
936
885
  return { handleMemoryAction, handleToolCall }
937
886
  }
@@ -0,0 +1,115 @@
1
+ // Idle and connected-client lifecycle for the shared memory daemon.
2
+ // Timer and client state live here; the facade injects shutdown ownership.
3
+
4
+ export function createMemoryDaemonLifecycle({
5
+ daemonMode,
6
+ idleTtlMs,
7
+ clientGraceMs,
8
+ parsePositivePid,
9
+ isPidAlive,
10
+ isStopping,
11
+ stop,
12
+ log,
13
+ }) {
14
+ let _idleShutdownTimer = null
15
+ const _connectedClients = new Map()
16
+ let _everHadClient = false
17
+ let _clientGraceTimer = null
18
+ let _clientSweepTimer = null
19
+
20
+ function touchDaemonIdleTimer(reason = 'activity') {
21
+ if (!daemonMode || idleTtlMs <= 0) return
22
+ if (_idleShutdownTimer) {
23
+ try { clearTimeout(_idleShutdownTimer) } catch {}
24
+ _idleShutdownTimer = null
25
+ }
26
+ _idleShutdownTimer = setTimeout(() => {
27
+ log(`[memory-service] daemon idle TTL elapsed after ${reason}; shutting down\n`)
28
+ stop()
29
+ .then(() => process.exit(0))
30
+ .catch((e) => {
31
+ log(`[memory-service] daemon idle shutdown failed: ${e?.message || e}\n`)
32
+ process.exit(1)
33
+ })
34
+ }, idleTtlMs)
35
+ _idleShutdownTimer.unref?.()
36
+ }
37
+
38
+ function clientShutdownEnabled() {
39
+ return daemonMode && clientGraceMs > 0
40
+ }
41
+
42
+ function pruneDeadClients() {
43
+ for (const pid of [..._connectedClients.keys()]) {
44
+ if (!isPidAlive(pid)) _connectedClients.delete(pid)
45
+ }
46
+ }
47
+
48
+ function cancelClientGrace() {
49
+ if (_clientGraceTimer) {
50
+ try { clearTimeout(_clientGraceTimer) } catch {}
51
+ _clientGraceTimer = null
52
+ }
53
+ }
54
+
55
+ function armClientGrace(reason = 'last client gone') {
56
+ if (!clientShutdownEnabled() || _clientGraceTimer) return
57
+ _clientGraceTimer = setTimeout(() => {
58
+ _clientGraceTimer = null
59
+ pruneDeadClients()
60
+ if (_connectedClients.size > 0) return
61
+ log(`[memory-service] daemon client grace elapsed (${reason}); shutting down\n`)
62
+ stop()
63
+ .then(() => process.exit(0))
64
+ .catch((e) => {
65
+ log(`[memory-service] daemon client-grace shutdown failed: ${e?.message || e}\n`)
66
+ process.exit(1)
67
+ })
68
+ }, clientGraceMs)
69
+ _clientGraceTimer.unref?.()
70
+ }
71
+
72
+ function startClientSweep() {
73
+ if (_clientSweepTimer || !clientShutdownEnabled()) return
74
+ const interval = Math.max(1000, Math.min(clientGraceMs, 5000))
75
+ _clientSweepTimer = setInterval(() => {
76
+ pruneDeadClients()
77
+ if (_everHadClient && _connectedClients.size === 0) armClientGrace('all clients gone (sweep)')
78
+ }, interval)
79
+ _clientSweepTimer.unref?.()
80
+ }
81
+
82
+ function registerClient(clientPid) {
83
+ const pid = parsePositivePid(clientPid)
84
+ if (!pid) return true
85
+ if (isStopping()) return false
86
+ _connectedClients.set(pid, Date.now())
87
+ _everHadClient = true
88
+ cancelClientGrace()
89
+ startClientSweep()
90
+ return true
91
+ }
92
+
93
+ function deregisterClient(clientPid) {
94
+ const pid = parsePositivePid(clientPid)
95
+ if (pid) _connectedClients.delete(pid)
96
+ pruneDeadClients()
97
+ if (_everHadClient && _connectedClients.size === 0) armClientGrace('last client deregistered')
98
+ }
99
+
100
+ function reset() {
101
+ if (_idleShutdownTimer) {
102
+ try { clearTimeout(_idleShutdownTimer) } catch {}
103
+ _idleShutdownTimer = null
104
+ }
105
+ cancelClientGrace()
106
+ if (_clientSweepTimer) {
107
+ try { clearInterval(_clientSweepTimer) } catch {}
108
+ _clientSweepTimer = null
109
+ }
110
+ _connectedClients.clear()
111
+ _everHadClient = false
112
+ }
113
+
114
+ return { touchDaemonIdleTimer, registerClient, deregisterClient, reset }
115
+ }
@@ -0,0 +1,105 @@
1
+ // Memory service discovery advertisement lifecycle.
2
+ // Owns the retry/periodic timer state so there is exactly one advertise chain.
3
+
4
+ export function createMemoryPortAdvertiser({
5
+ readServiceAdvert,
6
+ writeServiceAdvert,
7
+ parsePositivePid,
8
+ isPidAliveLocal,
9
+ memoryServerPid,
10
+ log,
11
+ }) {
12
+ let _periodicAdvertiseInstalled = false
13
+ let _periodicAdvertiseTimer = null
14
+ // Single module-level advertise retry chain. A newer advertiseMemoryPort call
15
+ // cancels the older chain so a delayed retry never replays a stale boundPort.
16
+ let _advertiseRetryTimer = null
17
+ let _advertiseGeneration = 0
18
+ // Track the most recently advertised port so the periodic tick re-reads it
19
+ // every interval. Without this the setInterval closure binds the FIRST port
20
+ // (the upstream we proxied to) and keeps re-advertising the dead upstream
21
+ // port after fork-proxy promotion swaps in our own locally-bound port.
22
+ let _currentAdvertisedPort = null
23
+
24
+ function advertiseMemoryPort(boundPort, attempt = 0) {
25
+ if (!Number.isFinite(boundPort) || boundPort <= 0) return
26
+ // A fresh top-level advertise (attempt 0) supersedes any pending retry chain:
27
+ // last write wins, so a delayed retry never clobbers a newer boundPort.
28
+ if (attempt === 0) {
29
+ _currentAdvertisedPort = boundPort
30
+ _advertiseGeneration++
31
+ if (_advertiseRetryTimer) { try { clearTimeout(_advertiseRetryTimer) } catch {} ; _advertiseRetryTimer = null }
32
+ }
33
+ const generation = _advertiseGeneration
34
+ if (!_periodicAdvertiseInstalled) {
35
+ _periodicAdvertiseInstalled = true
36
+ _periodicAdvertiseTimer = setInterval(() => {
37
+ try {
38
+ if (_currentAdvertisedPort != null) {
39
+ advertiseMemoryPort(_currentAdvertisedPort)
40
+ }
41
+ } catch {}
42
+ }, 30_000)
43
+ _periodicAdvertiseTimer.unref?.()
44
+ }
45
+ try {
46
+ // Single-writer discovery file (discovery/memory.json), plain atomic rename
47
+ // with NO .lock: memory_port discovery can never be starved by the shared
48
+ // active-instance.json lock. Conflict guard preserved: a live OTHER memory
49
+ // owner advertising a different port is not clobbered.
50
+ const cur = readServiceAdvert('memory')
51
+ const curMemPort = Number(cur?.port)
52
+ const curMemPid = parsePositivePid(cur?.pid)
53
+ const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
54
+ const otherOwnerAlive =
55
+ curMemPid != null &&
56
+ curMemPid !== memoryServerPid &&
57
+ isPidAliveLocal(curMemPid)
58
+ if (portConflict && otherOwnerAlive) {
59
+ log(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${memoryServerPid}\n`)
60
+ if (generation === _advertiseGeneration) _advertiseRetryTimer = null
61
+ return
62
+ }
63
+ writeServiceAdvert('memory', {
64
+ port: boundPort,
65
+ ...(memoryServerPid ? { pid: memoryServerPid } : {}),
66
+ })
67
+ if (generation === _advertiseGeneration) _advertiseRetryTimer = null
68
+ } catch (e) {
69
+ // Boot path must not serially block on the default 8s lock wait: use a short
70
+ // lock timeout and treat lock contention/timeout as transient so pg_port /
71
+ // memory_port still eventually publish via unref'd, backed-off bg retries.
72
+ const transient =
73
+ e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES' ||
74
+ e?.code === 'ELOCKTIMEOUT' || e?.code === 'ELOCKCONTENDED'
75
+ if (transient && attempt < 5 && generation === _advertiseGeneration) {
76
+ const delay = Math.min(2000, 50 * 2 ** attempt)
77
+ // Fire-time generation re-check: even if clearTimeout was missed, a
78
+ // retry from a superseded chain must never republish an old boundPort.
79
+ _advertiseRetryTimer = setTimeout(() => {
80
+ if (generation !== _advertiseGeneration) return
81
+ advertiseMemoryPort(boundPort, attempt + 1)
82
+ }, delay)
83
+ _advertiseRetryTimer.unref?.()
84
+ return
85
+ }
86
+ log(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
87
+ }
88
+ }
89
+
90
+ function reset() {
91
+ if (_periodicAdvertiseTimer) {
92
+ try { clearInterval(_periodicAdvertiseTimer) } catch {}
93
+ _periodicAdvertiseTimer = null
94
+ }
95
+ if (_advertiseRetryTimer) {
96
+ try { clearTimeout(_advertiseRetryTimer) } catch {}
97
+ _advertiseRetryTimer = null
98
+ }
99
+ _advertiseGeneration++
100
+ _periodicAdvertiseInstalled = false
101
+ _currentAdvertisedPort = null
102
+ }
103
+
104
+ return { advertiseMemoryPort, reset }
105
+ }