mixdog 0.9.20 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/tool-efficiency-diag.mjs +1 -1
  7. package/src/defaults/skills/setup/SKILL.md +327 -0
  8. package/src/rules/lead/02-channels.md +3 -3
  9. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  15. package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
  16. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  17. package/src/runtime/channels/lib/worker-main.mjs +7 -1
  18. package/src/runtime/memory/index.mjs +90 -0
  19. package/src/runtime/memory/lib/http-router.mjs +39 -0
  20. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  21. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  22. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  23. package/src/runtime/memory/lib/memory.mjs +39 -0
  24. package/src/runtime/shared/atomic-file.mjs +138 -80
  25. package/src/runtime/shared/child-guardian.mjs +61 -3
  26. package/src/session-runtime/runtime-core.mjs +75 -17
  27. package/src/standalone/channel-worker.mjs +63 -7
  28. package/src/standalone/folder-dialog.mjs +4 -1
  29. package/src/standalone/memory-runtime-proxy.mjs +98 -11
  30. package/src/standalone/seeds.mjs +28 -1
  31. package/src/tui/App.jsx +1 -0
  32. package/src/tui/app/doctor.mjs +175 -0
  33. package/src/tui/app/slash-commands.mjs +1 -0
  34. package/src/tui/app/slash-dispatch.mjs +9 -0
  35. package/src/tui/dist/index.mjs +297 -71
  36. package/src/tui/engine/session-api.mjs +19 -0
@@ -42,6 +42,9 @@ export function createHttpRouter({
42
42
  handleMemoryAction,
43
43
  handleToolCall,
44
44
  stop,
45
+ registerClient,
46
+ deregisterClient,
47
+ getDraining,
45
48
  getCycle1CallLlm,
46
49
  getTraceDb,
47
50
  setTraceDb,
@@ -120,6 +123,31 @@ export function createHttpRouter({
120
123
 
121
124
  const requestHandler = async (req, res) => {
122
125
  touchDaemonIdleTimer(`${req.method || 'HTTP'} ${req.url || '/'}`)
126
+ // Connected-client lifecycle. Proxies register on first use and
127
+ // deregister on CLI shutdown; the daemon reaps itself shortly after the
128
+ // last client leaves (see registerClient/deregisterClient in index.mjs).
129
+ if (req.method === 'POST' && (req.url === '/client/register' || req.url === '/client/deregister')) {
130
+ if (!isLocalOrigin(req)) {
131
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
132
+ return
133
+ }
134
+ let body = {}
135
+ try { body = await readBody(req) } catch {}
136
+ const clientPid = Number(body?.clientPid)
137
+ if (req.url === '/client/register') {
138
+ const accepted = registerClient?.(clientPid)
139
+ if (accepted === false) {
140
+ // Daemon is draining — distinct signal so the proxy respawns a fresh
141
+ // daemon and retries its pending RPC instead of binding to this one.
142
+ sendJson(res, { ok: false, draining: true, error: 'memory worker draining' }, 503)
143
+ return
144
+ }
145
+ } else {
146
+ deregisterClient?.(clientPid)
147
+ }
148
+ sendJson(res, { ok: true })
149
+ return
150
+ }
123
151
  if (req.method === 'POST' && req.url === '/session-reset') {
124
152
  const ts = Date.now()
125
153
  setBootTimestamp(ts)
@@ -133,6 +161,10 @@ export function createHttpRouter({
133
161
  }
134
162
 
135
163
  if (req.method === 'GET' && req.url === '/health') {
164
+ if (getDraining?.()) {
165
+ sendJson(res, { status: 'draining' }, 503)
166
+ return
167
+ }
136
168
  if (!getInitialized()) {
137
169
  sendJson(res, { status: 'starting' }, 503)
138
170
  return
@@ -591,6 +623,13 @@ export function createHttpRouter({
591
623
  sendJson(res, { content: [{ type: 'text', text: 'forbidden: cross-origin' }], isError: true }, 403)
592
624
  return
593
625
  }
626
+ // Reject tool calls that arrive after shutdown has begun. The error text
627
+ // carries the "draining" token so the proxy treats it as transient,
628
+ // respawns a fresh daemon, and retries the RPC (including write RPCs).
629
+ if (getDraining?.()) {
630
+ sendJson(res, { content: [{ type: 'text', text: 'memory worker draining' }], isError: true }, 503)
631
+ return
632
+ }
594
633
  // Owner-side cancel plumbing: the fork-proxy worker forwards parent
595
634
  // 'cancel' IPC by issuing POST /api/cancel with the same callId. Track
596
635
  // each in-flight /api/tool by its caller-supplied X-Mixdog-Call-Id so
@@ -11,6 +11,12 @@ import { __mixdogMemoryLog, throwIfAborted, resourceDir } from './memory-cycle2-
11
11
 
12
12
  export const CYCLE2_ACTIVE_TARGET_CAP = 100
13
13
 
14
+ // Minimum number of active (promoted) rows cycle2 must preserve. When the
15
+ // active pool is at or below this floor, active recheck/demotion is skipped
16
+ // and no archiving path may push active below it — prevents draining the
17
+ // pool to zero. Configurable via config.active_floor.
18
+ export const CYCLE2_ACTIVE_MIN_FLOOR = 20
19
+
14
20
  // Status-based verb whitelist. 3-tier policy: pending → active/archived,
15
21
  // active → active/archived/update/merge.
16
22
  const STATUS_ALLOWED_VERBS = {
@@ -86,11 +86,15 @@ export async function applyBatchStatusVerdicts(db, batch, nowMs) {
86
86
  )
87
87
  let promoted = 0
88
88
  let archived = 0
89
+ let archived_active = 0
89
90
  for (const r of (res.rows ?? [])) {
90
91
  if (r.was_pending && r.new_status === 'active') promoted += 1
91
- else if (r.new_status === 'archived') archived += 1
92
+ else if (r.new_status === 'archived') {
93
+ archived += 1
94
+ if (!r.was_pending) archived_active += 1
95
+ }
92
96
  }
93
- return { promoted, archived }
97
+ return { promoted, archived, archived_active }
94
98
  }
95
99
 
96
100
  // Generic status update for archived/active terminal transitions.
@@ -186,24 +190,49 @@ export async function applyMerge(db, targetId, sourceIds, options = {}) {
186
190
  )
187
191
  continue
188
192
  }
193
+ // Floor guard: archiving an active source row shrinks the active pool, so
194
+ // it must reserve from the shared floor budget. Over-budget merges are
195
+ // skipped (source stays active); pending sources never reserve.
196
+ const srcActive = srcRow.status === 'active'
197
+ if (srcActive && options.floorGuard && !options.floorGuard.reserve()) {
198
+ __mixdogMemoryLog(`[cycle2] merge source archive skipped (floor guard): target=${targetId} src=${sid}\n`)
199
+ continue
200
+ }
201
+ let archivedPrevStatus = null
189
202
  try {
190
- // One source merge is the mutation unit: DB reassignment/archive plus
191
- // embedding cleanup. The next abort checkpoint is before the next source.
203
+ // Archive is the guarded mutation unit: the reservation is only kept if
204
+ // this transaction commits. Embedding cleanup is deliberately OUTSIDE
205
+ // this try — a post-commit cleanup failure must NOT refund the budget,
206
+ // since the active row is already archived.
192
207
  await db.transaction(async (tx) => {
193
208
  await tx.query(
194
209
  `UPDATE entries SET chunk_root = $1, project_id = $2 WHERE chunk_root = $3 AND id != $4 AND is_root = 0`,
195
210
  [targetId, target.project_id, sid, sid],
196
211
  )
197
- await tx.query(
198
- `UPDATE entries SET status = 'archived' WHERE id = $1 AND is_root = 1`,
212
+ // Capture prev status in the same statement so a stale snapshot (the
213
+ // row was already archived by a concurrent write) can refund below.
214
+ const ar = await tx.query(
215
+ `WITH pre AS (SELECT id, status AS prev FROM entries WHERE id = $1 AND is_root = 1)
216
+ UPDATE entries SET status = 'archived'
217
+ FROM pre WHERE entries.id = pre.id AND entries.is_root = 1
218
+ RETURNING pre.prev AS prev_status`,
199
219
  [sid],
200
220
  )
221
+ archivedPrevStatus = ar.rows?.[0]?.prev_status ?? null
201
222
  })
202
- await deleteRootEmbedding(db, sid)
203
- moved += 1
204
223
  } catch (err) {
224
+ if (srcActive && options.floorGuard) options.floorGuard.refund()
205
225
  __mixdogMemoryLog(`[cycle2] merge failed (target=${targetId} src=${sid}): ${err.message}\n`)
226
+ continue
206
227
  }
228
+ // Archive committed. If the source was no longer active at UPDATE time,
229
+ // the reservation did not cover a real active→archived transition — refund.
230
+ if (srcActive && options.floorGuard && archivedPrevStatus !== 'active') options.floorGuard.refund()
231
+ // Archive committed — best-effort embedding cleanup only. The next abort
232
+ // checkpoint is before the next source.
233
+ moved += 1
234
+ try { await deleteRootEmbedding(db, sid) }
235
+ catch (err) { __mixdogMemoryLog(`[cycle2] merge embedding cleanup failed (target=${targetId} src=${sid}): ${err.message}\n`) }
207
236
  }
208
237
  return moved
209
238
  }
@@ -245,6 +274,7 @@ async function _llmJudgePair(summaryA, summaryB, siblingContext = [], options =
245
274
 
246
275
  export async function runPhaseMerge(db, options = {}) {
247
276
  const signal = options?.signal
277
+ const floorGuard = options?.floorGuard
248
278
  throwIfAborted(signal)
249
279
  // PG-side lateral nearest-neighbor via HNSW index — replaces JS O(n²) double loop.
250
280
  const pairRes = await db.query(
@@ -296,7 +326,9 @@ export async function runPhaseMerge(db, options = {}) {
296
326
  if (mergedIds.has(a.id) || mergedIds.has(b.id)) return
297
327
  const keeper = _pickKeeper(a, b)
298
328
  const loser = keeper.id === a.id ? b : a
299
- const moved = await applyMerge(db, keeper.id, [loser.id], { signal })
329
+ // phase_merge only pairs active rows, so the loser archive is an active
330
+ // demotion — thread the floor budget through applyMerge.
331
+ const moved = await applyMerge(db, keeper.id, [loser.id], { signal, floorGuard })
300
332
  if (moved > 0) {
301
333
  merged += moved
302
334
  mergedIds.add(loser.id)
@@ -372,6 +404,9 @@ export async function runPhaseMerge(db, options = {}) {
372
404
  )
373
405
  throwIfAborted(signal)
374
406
  if (!verdictMerge) continue
407
+ // Core-overlap archive is an active demotion — reserve floor budget and
408
+ // refund if the guarded UPDATE turns out not to fire.
409
+ if (floorGuard && !floorGuard.reserve()) continue
375
410
  // Archiving one overlap and deleting its embedding is one mutation unit;
376
411
  // cancellation resumes at the next row boundary.
377
412
  const r = await db.query(
@@ -387,6 +422,8 @@ export async function runPhaseMerge(db, options = {}) {
387
422
  if (Number(r.rowCount ?? r.affectedRows ?? 0) > 0) {
388
423
  coreOverlap++
389
424
  await deleteRootEmbedding(db, Number(row.entry_id))
425
+ } else if (floorGuard) {
426
+ floorGuard.refund()
390
427
  }
391
428
  }
392
429
  throwIfAborted(signal)
@@ -6,6 +6,7 @@
6
6
  // memory-cycle2-mutations.mjs — status/update/merge writers + runPhaseMerge
7
7
  // memory-cycle2-gate.mjs — prompt/parse/validate + runUnifiedGate + cascade
8
8
  import { flushEmbeddingDirty } from './memory-embed.mjs'
9
+ import { refreshHotActive } from './memory.mjs'
9
10
  import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
10
11
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
11
12
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
@@ -13,7 +14,7 @@ import {
13
14
  applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
15
  } from './memory-cycle2-mutations.mjs'
15
16
  import {
16
- CYCLE2_ACTIVE_TARGET_CAP, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
17
+ CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
17
18
  NON_ARCHIVE_VERBS, requiredCoreIdForAction,
18
19
  } from './memory-cycle2-gate.mjs'
19
20
 
@@ -48,6 +49,7 @@ function mergeCycle2Results(a, b) {
48
49
  archived: Number(a.archived || 0) + Number(b.archived || 0),
49
50
  merged: Number(a.merged || 0) + Number(b.merged || 0),
50
51
  updated: Number(a.updated || 0) + Number(b.updated || 0),
52
+ floor_protected: Number(a.floor_protected || 0) + Number(b.floor_protected || 0),
51
53
  kept: Number(a.kept || 0) + Number(b.kept || 0),
52
54
  rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
53
55
  merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
@@ -199,6 +201,9 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
199
201
  const activeTargetCap = Number.isFinite(Number(config.active_target_cap))
200
202
  ? Math.max(1, Number(config.active_target_cap))
201
203
  : CYCLE2_ACTIVE_TARGET_CAP
204
+ const activeFloor = Number.isFinite(Number(config.active_floor))
205
+ ? Math.max(0, Number(config.active_floor))
206
+ : CYCLE2_ACTIVE_MIN_FLOOR
202
207
  const nowMs = Date.now()
203
208
 
204
209
  const stats = {
@@ -211,6 +216,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
211
216
  rescore: { updated: 0 },
212
217
  phase_merge: { merged: 0, llm_calls: 0, tier1_pairs: 0, tier2_pairs: 0, core_overlap: 0 },
213
218
  cascade: { evaluated: 0, dropped: 0 },
219
+ floor_protected: 0,
214
220
  }
215
221
 
216
222
  if (dataDir) {
@@ -231,6 +237,20 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
231
237
  const activeCount = Number(activeCountRes.rows[0]?.c ?? 0)
232
238
  const reviewActiveRows = activeCount > activeTargetCap
233
239
 
240
+ // Shared floor-demotion budget: the number of active rows that may be
241
+ // archived this cycle before the active pool would breach the minimum
242
+ // floor. EVERY archiving path that removes an active row must reserve()
243
+ // from this single budget (status demotions, cascade drops, applyMerge
244
+ // sources, and phase_merge) so no path can drain active below the floor.
245
+ // Reservations are refunded when the underlying write turns out not to
246
+ // demote (optimistic-guard miss / merge failure).
247
+ const floorGuard = {
248
+ remaining: Math.max(0, activeCount - activeFloor),
249
+ protected: 0,
250
+ reserve() { if (this.remaining <= 0) { this.protected += 1; return false } this.remaining -= 1; return true },
251
+ refund(n = 1) { this.remaining += Math.max(0, n) },
252
+ }
253
+
234
254
  // Rolling active re-review quota. Under cap, the unified selection below
235
255
  // pulls only pending rows, so an already-promoted entry that later drifts
236
256
  // stale or turns out to restate a rule file never gets re-judged — the
@@ -244,7 +264,9 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
244
264
  // restatements. Embedding dedup is skipped on purpose: rule restatements
245
265
  // are often cross-language paraphrases whose cosine never clears the merge
246
266
  // threshold, but the LLM gate catches the semantic overlap.
247
- const activeRecheckQuota = reviewActiveRows
267
+ // Floor guard: when the active pool is at/below the minimum floor, skip the
268
+ // rolling active recheck entirely so demotions cannot drain it further.
269
+ const activeRecheckQuota = (reviewActiveRows || activeCount <= activeFloor)
248
270
  ? 0
249
271
  : Math.max(0, Math.min(Number(config.active_recheck_quota ?? 8), batchSize - 1))
250
272
  const pendingLimit = batchSize - activeRecheckQuota
@@ -460,7 +482,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
460
482
  )
461
483
  continue
462
484
  }
463
- const moved = await applyMerge(db, targetId, sourceIds, { signal })
485
+ const moved = await applyMerge(db, targetId, sourceIds, { signal, floorGuard })
464
486
  throwIfAborted(signal)
465
487
  if (moved > 0) {
466
488
  stats.merged += moved
@@ -489,23 +511,62 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
489
511
  // Status verdicts are applied as one SQL batch; checkpoint before the
490
512
  // batch and then again at the next cycle2 unit boundary.
491
513
  throwIfAborted(signal)
492
- let activeDemotionsInBatch = 0
514
+ // Reserve floor budget for each active→archived demotion; over-budget
515
+ // demotions are dropped (the row stays active, still reviewed_at-bumped
516
+ // via reviewedIds). Pending→archived never reserves (was never active).
517
+ const guardedBatch = []
518
+ let reservedDemotions = 0
493
519
  for (const item of statusBatch) {
494
- if (item.new_status === 'archived' && !item.was_pending) activeDemotionsInBatch += 1
520
+ if (item.new_status === 'archived' && !item.was_pending) {
521
+ if (!floorGuard.reserve()) continue
522
+ reservedDemotions += 1
523
+ }
524
+ guardedBatch.push(item)
495
525
  }
496
- const activeCountForClamp = Math.max(0, activeCount - activeDemotionsInBatch)
497
- const clampRes = clampPendingPromotions(statusBatch, rowsById, activeCountForClamp, activeTargetCap)
526
+ const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
527
+ const clampRes = clampPendingPromotions(guardedBatch, rowsById, activeCountForClamp, activeTargetCap)
498
528
  stats.promotion_clamped = clampRes.clamped
499
529
  const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
500
530
  stats.promoted += batchRes.promoted
501
531
  stats.archived += batchRes.archived
532
+ // Spend on CONFIRMED demotions only: refund reservations the optimistic
533
+ // guard skipped (concurrent write moved the row).
534
+ const confirmedActive = Number(batchRes.archived_active || 0)
535
+ if (reservedDemotions > confirmedActive) floorGuard.refund(reservedDemotions - confirmedActive)
502
536
  }
503
537
 
504
538
  if (cascadeDropArchiveIds.length > 0) {
505
539
  throwIfAborted(signal)
506
- const r = await db.query(`UPDATE entries SET status = 'archived' WHERE id = ANY($1::bigint[]) AND is_root = 1`, [cascadeDropArchiveIds])
507
- stats.cascade.dropped += Number(r.rowCount ?? r.affectedRows ?? 0)
508
- stats.archived += Number(r.rowCount ?? r.affectedRows ?? 0)
540
+ // Floor guard: a cascade drop of an active row reduces the pool, so
541
+ // reserve budget; pending rows dropped here never were active. The
542
+ // snapshot status can be stale (statusBatch above / concurrent writes),
543
+ // so reserve optimistically then refund against the ACTUAL number of
544
+ // active→archived transitions the UPDATE performed (prev_status read in
545
+ // the same statement).
546
+ let reservedActive = 0
547
+ const toDrop = []
548
+ for (const id of cascadeDropArchiveIds) {
549
+ const row = rowsById.get(Number(id))
550
+ if (row && row.status === 'active') {
551
+ if (!floorGuard.reserve()) continue
552
+ reservedActive += 1
553
+ }
554
+ toDrop.push(id)
555
+ }
556
+ if (toDrop.length > 0) {
557
+ const r = await db.query(`
558
+ WITH pre AS (SELECT id, status AS prev FROM entries WHERE id = ANY($1::bigint[]) AND is_root = 1)
559
+ UPDATE entries SET status = 'archived'
560
+ FROM pre
561
+ WHERE entries.id = pre.id AND entries.is_root = 1
562
+ RETURNING entries.id, pre.prev AS prev_status
563
+ `, [toDrop])
564
+ const dropped = r.rows ?? []
565
+ stats.cascade.dropped += dropped.length
566
+ stats.archived += dropped.length
567
+ const actualArchivedActive = dropped.filter(x => x.prev_status === 'active').length
568
+ if (reservedActive > actualArchivedActive) floorGuard.refund(reservedActive - actualArchivedActive)
569
+ }
509
570
  }
510
571
  if (reviewedIds.length > 0) {
511
572
  throwIfAborted(signal)
@@ -570,9 +631,12 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
570
631
  }
571
632
 
572
633
  // phase_merge: cosine dedup over active entries.
573
- const phaseMergeStats = await runPhaseMerge(db, { ...options, signal })
634
+ const phaseMergeStats = await runPhaseMerge(db, { ...options, signal, floorGuard })
574
635
  throwIfAborted(signal)
575
636
  stats.phase_merge = phaseMergeStats
637
+ // Surface how many active-row demotions were withheld to hold the floor
638
+ // across every archiving path this cycle (status/cascade/merge/phase_merge).
639
+ stats.floor_protected = floorGuard.protected
576
640
 
577
641
  // Core-candidate nomination (proposal mode): flag strong durable active
578
642
  // roots as core-memory candidates for user approval. Runs AFTER phase_merge
@@ -621,6 +685,18 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
621
685
  __mixdogMemoryLog(`[cycle2] chronic sweep failed: ${err.message}\n`)
622
686
  }
623
687
 
688
+ // Refresh the hot-active materialized view now that every promotion/archival/
689
+ // dedup mutation for this cycle has landed, so the recall hot path reads a
690
+ // current active set. refreshHotActive is itself best-effort (logs + swallows);
691
+ // the guard here also keeps a refresh failure from failing the cycle.
692
+ try {
693
+ throwIfAborted(signal)
694
+ await refreshHotActive(db)
695
+ } catch (err) {
696
+ if (signal?.aborted) throw signal.reason ?? err
697
+ __mixdogMemoryLog(`[cycle2] mv_hot_active refresh failed: ${err.message}\n`)
698
+ }
699
+
624
700
  __mixdogMemoryLog(
625
701
  `[cycle2] rescore=${stats.rescore.updated}` +
626
702
  ` core_backfill=${stats.core_embedding_backfill}` +
@@ -296,6 +296,45 @@ async function ensureHotActiveSearchObjects(db) {
296
296
  await db.exec(`CREATE INDEX IF NOT EXISTS mv_hot_active_score ON mv_hot_active(score DESC)`)
297
297
  }
298
298
 
299
+ // Refresh mv_hot_active. Owned by cycle2 — call after promotion/archival/dedup
300
+ // mutations land so the recall hot path reads the current active set. The MV is
301
+ // created WITH NO DATA (memory.mjs:243), and an unpopulated MV CANNOT be
302
+ // refreshed CONCURRENTLY, so the first refresh is non-concurrent; once populated
303
+ // we refresh CONCURRENTLY (the mv_hot_active_id UNIQUE index — created at
304
+ // :251/:293 — makes this legal) to avoid an AccessExclusive lock on the recall
305
+ // read path. Best-effort: every failure is logged and swallowed so a refresh
306
+ // error never crashes the cycle. Returns true when the view was refreshed.
307
+ export async function refreshHotActive(db) {
308
+ try {
309
+ const r = await db.query(
310
+ `SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`,
311
+ )
312
+ if (!r.rows.length) {
313
+ __mixdogMemoryLog('[memory] refreshHotActive: mv_hot_active not found; skipping\n')
314
+ return false
315
+ }
316
+ if (Boolean(r.rows[0].relispopulated)) {
317
+ try {
318
+ await db.exec(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_hot_active`)
319
+ return true
320
+ } catch (err) {
321
+ // CONCURRENTLY needs a populated MV + unique index; if either is not
322
+ // true (e.g. MV was reset since the catalog read), fall back to a plain
323
+ // refresh so the view still ends up fresh (brief AccessExclusive lock).
324
+ __mixdogMemoryLog(`[memory] refreshHotActive CONCURRENTLY failed, retrying non-concurrent: ${err?.message || err}\n`)
325
+ await db.exec(`REFRESH MATERIALIZED VIEW mv_hot_active`)
326
+ return true
327
+ }
328
+ }
329
+ // First refresh of a WITH-NO-DATA view — must be non-concurrent.
330
+ await db.exec(`REFRESH MATERIALIZED VIEW mv_hot_active`)
331
+ return true
332
+ } catch (err) {
333
+ __mixdogMemoryLog(`[memory] refreshHotActive failed: ${err?.message || err}\n`)
334
+ return false
335
+ }
336
+ }
337
+
299
338
  async function resetEmbeddingColumnsForDims(db, dimCount) {
300
339
  const entriesDims = await getEmbeddingColumnDims(db, 'entries')
301
340
  const coreDims = await getEmbeddingColumnDims(db, 'core_entries')