mixdog 0.9.14 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +567 -229
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +126 -7
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -2,6 +2,8 @@ import http from 'node:http'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createStandaloneMemoryRuntime } from '../../../standalone/memory-runtime-proxy.mjs'
5
7
 
6
8
  const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
7
9
  ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
@@ -10,6 +12,54 @@ const ACTIVE_INSTANCE_FILE = path.join(RUNTIME_ROOT, 'active-instance.json')
10
12
 
11
13
  let _portCache = null // { port, mtime, ts }
12
14
 
15
+ // Shared memory-runtime proxy handle for respawn. The channels worker is the
16
+ // one process that can find the daemon dead (no port / ECONNREFUSED) while a
17
+ // remote session is still alive. Rather than dead-lettering forever, it asks
18
+ // the SAME proxy the host uses (createStandaloneMemoryRuntime.start()) to
19
+ // re-ensure the singleton daemon. Owner-claim + fork logic is NOT duplicated
20
+ // here — start() reuses the on-disk singleton owner file, so a live daemon is
21
+ // reused and only a genuinely dead one is respawned.
22
+ const MEMORY_ENTRY = fileURLToPath(new URL('../../memory/index.mjs', import.meta.url))
23
+ const MEMORY_DATA_DIR = process.env.MIXDOG_DATA_DIR
24
+ ? path.resolve(process.env.MIXDOG_DATA_DIR)
25
+ : RUNTIME_ROOT
26
+ let _memoryProxy = null
27
+ let _ensuringDaemon = null
28
+ function getMemoryProxy() {
29
+ if (!_memoryProxy) {
30
+ _memoryProxy = createStandaloneMemoryRuntime({ entry: MEMORY_ENTRY, dataDir: MEMORY_DATA_DIR })
31
+ }
32
+ return _memoryProxy
33
+ }
34
+ // Ensure the daemon exists via the shared proxy. Reentrancy-guarded so a burst
35
+ // of failed appends/drains coalesces into a single start(). On success the
36
+ // port cache is invalidated so the next getMemoryPort() re-reads the freshly
37
+ // published active-instance.json. Never throws — callers keep buffering on
38
+ // failure, so a respawn miss degrades to the existing retry path, not a crash.
39
+ async function ensureMemoryDaemon() {
40
+ if (_ensuringDaemon) return _ensuringDaemon
41
+ _ensuringDaemon = (async () => {
42
+ try {
43
+ const res = await getMemoryProxy().start()
44
+ _portCache = null
45
+ return res?.port ?? null
46
+ } catch (e) {
47
+ process.stderr.write(`[memory-client] ensureMemoryDaemon failed (${e.message})\n`)
48
+ return null
49
+ } finally {
50
+ _ensuringDaemon = null
51
+ }
52
+ })()
53
+ return _ensuringDaemon
54
+ }
55
+
56
+ function isConnRefusedLike(err) {
57
+ const code = String(err?.code || '')
58
+ const msg = String(err?.message || err || '')
59
+ return code === 'ECONNREFUSED'
60
+ || /ECONNREFUSED|missing memory_port|memory-service timeout/i.test(msg)
61
+ }
62
+
13
63
  async function getMemoryPort() {
14
64
  const now = Date.now()
15
65
  if (_portCache && (now - _portCache.ts) < 5_000) return _portCache.port
@@ -103,6 +153,11 @@ export async function appendEntry(data) {
103
153
  return await memoryFetch('POST', '/entry', payload, 3_000)
104
154
  } catch (e) {
105
155
  process.stderr.write(`[memory-client] appendEntry failed (${e.message}) — buffering\n`)
156
+ // No-port / connection-refused means the daemon is likely dead. Ask the
157
+ // shared proxy to re-ensure the singleton (reentrancy-guarded, non-throwing)
158
+ // so buffered entries have a live target on the next periodic drain instead
159
+ // of being retried against nothing until quarantine.
160
+ if (isConnRefusedLike(e)) void ensureMemoryDaemon()
106
161
  const bufferPath = bufferToDisk('entry', payload)
107
162
  return bufferPath ? { ok: false, buffered: true, path: bufferPath } : { ok: false }
108
163
  }
@@ -221,7 +276,13 @@ function retryName(name, n) {
221
276
  export async function drainBuffer() {
222
277
  if (_draining) return { ok: true, skipped: 'in-progress' }
223
278
  const port = await getMemoryPort()
224
- if (!port) return { ok: false, reason: 'no-port' }
279
+ if (!port) {
280
+ // No published port: the daemon is down while buffered work is waiting.
281
+ // Respawn it via the shared proxy (reused singleton — no fork dup) and
282
+ // let the next tick drain against the freshly published port.
283
+ void ensureMemoryDaemon()
284
+ return { ok: false, reason: 'no-port' }
285
+ }
225
286
  _draining = true
226
287
  let drained = 0
227
288
  let failed = 0
@@ -261,6 +322,10 @@ export async function drainBuffer() {
261
322
  } catch (e) {
262
323
  const attempts = parseRetry(name) + 1
263
324
  failed++
325
+ // Connection-refused mid-drain: the daemon died between the port read
326
+ // and this POST. Re-ensure it via the shared proxy so the next tick
327
+ // has a live target (the file is aged/kept below, never dropped here).
328
+ if (isConnRefusedLike(e)) void ensureMemoryDaemon()
264
329
  if (attempts >= MAX_DRAIN_ATTEMPTS) {
265
330
  // Quarantine, don't drop: move to dead/ so a poison record neither
266
331
  // wedges the queue nor silently vanishes (recoverable for triage).
@@ -99,6 +99,18 @@ function loadEndpointConfig(name) {
99
99
  // reference the same `id` and are merged latest-wins at read time.
100
100
  const DELIVERY_INDEX_MAX_IDS = 2000;
101
101
  const DELIVERY_LOG_MAX_LINES = 10_000;
102
+ // A "pending" claim normally resolves to a terminal status (done/failed) or
103
+ // "processing" within seconds. If a row is stuck at "pending" past this TTL
104
+ // (crash between the pending write and the terminal write, with no restart
105
+ // warm to clear it), treat it as abandoned rather than always-kept —
106
+ // otherwise an unbounded number of stuck-pending ids could accumulate and
107
+ // defeat DELIVERY_INDEX_MAX_IDS entirely.
108
+ const DELIVERY_PENDING_TTL_MS = 10 * 60 * 1000;
109
+ function _isFreshPending(entry) {
110
+ const ts = Date.parse(entry?.ts ?? "");
111
+ if (Number.isNaN(ts)) return true; // no timestamp to judge staleness — keep (fail safe toward dedup).
112
+ return Date.now() - ts < DELIVERY_PENDING_TTL_MS;
113
+ }
102
114
  /** @type {Map<string, Map<string, object>>} */
103
115
  const _deliveryIndexByEndpoint = new Map();
104
116
  /** @type {Set<string>} */
@@ -139,7 +151,16 @@ function _retainedDeliveryIds(entries) {
139
151
  const done = [];
140
152
  const other = [];
141
153
  for (const e of entries) {
142
- if (e.status === "received" || e.status === "processing") inflight.push(e);
154
+ // "pending" is a non-terminal claim too (see _isBlockingDeliveryStatus)
155
+ // — it must stay in the always-kept inflight set during pruning, or a
156
+ // concurrent retry of the same id can bypass dedup while the original
157
+ // delivery is still pending. Bounded by DELIVERY_PENDING_TTL_MS so a
158
+ // stuck/abandoned pending row (crash, never resolved) doesn't pin
159
+ // itself in the always-kept set forever and grow the index unbounded;
160
+ // once stale it falls through to the capped `other` pool below.
161
+ if (e.status === "received" || e.status === "processing" || (e.status === "pending" && _isFreshPending(e))) {
162
+ inflight.push(e);
163
+ }
143
164
  else if (e.status === "done") done.push(e);
144
165
  else other.push(e);
145
166
  }
@@ -250,6 +250,39 @@ let _bootTimestamp = null
250
250
  // flush pass (the old boolean guard silently SKIPPED bursts, leaving rows
251
251
  // embedding-less until the ~60s tick — a recall (no results) window).
252
252
  let _rawEmbedFlushChain = Promise.resolve()
253
+ // Per-session ingest serialization. Concurrent ingest_session calls for the
254
+ // SAME session raced on MAX(source_turn) → duplicate turn allocation. Chain
255
+ // same-session ingests so the MAX read + insert loop for one session never
256
+ // overlaps another for the same session. Different sessions stay parallel.
257
+ const _ingestSessionChains = new Map()
258
+ // Cache of (role, cleaned content) per session message OBJECT, keyed by
259
+ // identity (WeakMap — entries vanish once the message is GC'd, e.g. after
260
+ // compaction drops the array). The subset-reingest ordinal fix below must
261
+ // replay cleanMemoryText/shouldExcludeIngestMessage over messages[0, start)
262
+ // on every call; without caching that turned an O(limit)-bounded ingest into
263
+ // an O(full transcript) regex pass on every hydrate. Message objects are
264
+ // immutable transcript entries reused by reference across calls (recall-
265
+ // fasttrack re-ingests the same in-memory array as the session grows), so
266
+ // once a message's identity fields are computed they never change — caching
267
+ // makes every call after the first pay only for genuinely NEW messages,
268
+ // restoring the limit-bounded cost in steady state.
269
+ const _ingestIdentityFieldsCache = new WeakMap()
270
+ // Return { role, content } for a session message's dedup identity, or null if
271
+ // the message would be skipped by ingest (no role / excluded / empty content
272
+ // after cleaning). Cached per message object so repeated calls over the same
273
+ // (unchanged) transcript prefix never re-run the expensive clean/normalize
274
+ // regex pipeline.
275
+ function _ingestIdentityFields(m) {
276
+ if (_ingestIdentityFieldsCache.has(m)) return _ingestIdentityFieldsCache.get(m)
277
+ let fields = null
278
+ const role = normalizeIngestRole(m.role)
279
+ if (role && !shouldExcludeIngestMessage(m)) {
280
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
281
+ if (content && content.trim()) fields = { role, content }
282
+ }
283
+ _ingestIdentityFieldsCache.set(m, fields)
284
+ return fields
285
+ }
253
286
  // Ingest now WAITS for its embedding flush (bounded) so freshly ingested rows
254
287
  // are dense-searchable the moment ingest_session returns. On a cold ONNX
255
288
  // model the flush can stall on warmup; the race below caps the wait and lets
@@ -441,6 +474,23 @@ async function setCycleLastRun(kind, ts) {
441
474
 
442
475
  async function ingestSessionMessages(args = {}) {
443
476
  const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
477
+ // Serialize per-session so the MAX(source_turn) read + insert loop for one
478
+ // session never overlaps a concurrent ingest for the SAME session (they
479
+ // otherwise race the turn allocator and double-allocate). Distinct sessions
480
+ // run in parallel.
481
+ const prev = _ingestSessionChains.get(sessionId) ?? Promise.resolve()
482
+ const run = prev.catch(() => {}).then(() => _ingestSessionMessagesImpl(sessionId, args))
483
+ _ingestSessionChains.set(sessionId, run.catch(() => {}))
484
+ const tail = _ingestSessionChains.get(sessionId)
485
+ try {
486
+ return await run
487
+ } finally {
488
+ // Best-effort GC: drop the map entry only if no later call chained after us.
489
+ if (_ingestSessionChains.get(sessionId) === tail) _ingestSessionChains.delete(sessionId)
490
+ }
491
+ }
492
+
493
+ async function _ingestSessionMessagesImpl(sessionId, args = {}) {
444
494
  const messages = Array.isArray(args.messages) ? args.messages : []
445
495
  // Recall fast-track hydrates the current session before compaction; allow
446
496
  // callers to ingest the full in-memory transcript instead of silently
@@ -468,6 +518,30 @@ async function ingestSessionMessages(args = {}) {
468
518
  prevMaxTurn = Number(maxRow.rows?.[0]?.max_turn) || 0
469
519
  } catch { prevMaxTurn = 0 }
470
520
  const turnAllocator = createIngestTurnAllocator(prevMaxTurn)
521
+ const _identityOccurrence = new Map()
522
+ // Per-session-stable ordinal for untimestamped, textually-identical repeats.
523
+ // Must NOT use the global next source_turn (differs every run → re-ingest
524
+ // duplicates). The Nth identical (role,content) untimestamped turn gets
525
+ // ordinal N based on its POSITION IN THE FULL messages ARRAY (0..i), not
526
+ // merely within this call's [start, len) window. Seed the counter by
527
+ // replaying the SAME identity/exclude logic over messages[0, start) below
528
+ // (no DB writes, no query — the full array is already in memory) so a
529
+ // SUBSET re-ingest (e.g. only a session's later half) reproduces the exact
530
+ // ordinal a full re-ingest would assign at that array position. Appended
531
+ // genuine repeats therefore never collide with earlier persisted rows of
532
+ // the same identity, while a full identical re-ingest still reproduces the
533
+ // same ordinals/source_refs and ON CONFLICT dedupes exactly.
534
+ for (let i = 0; i < start; i += 1) {
535
+ const m = messages[i]
536
+ if (!m || typeof m !== 'object') continue
537
+ // Cached (WeakMap-keyed) so a repeated hydrate over the SAME prefix
538
+ // objects only pays the clean/normalize cost once per message, keeping
539
+ // this seed pass effectively free after the first ingest call.
540
+ const fields = _ingestIdentityFields(m)
541
+ if (!fields) continue
542
+ const occKey = `${fields.role}\u0000${fields.content}`
543
+ _identityOccurrence.set(occKey, (_identityOccurrence.get(occKey) ?? 0) + 1)
544
+ }
471
545
  for (let i = start; i < messages.length; i += 1) {
472
546
  const m = messages[i]
473
547
  if (!m || typeof m !== 'object') continue
@@ -490,16 +564,25 @@ async function ingestSessionMessages(args = {}) {
490
564
  if (!content || !content.trim()) continue
491
565
  considered += 1
492
566
  const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
567
+ // Assign the next monotonic turn BEFORE building the source_ref so identical
568
+ // untimestamped repeats get distinct identities (peekNext is stable until a
569
+ // row is actually inserted → next()).
570
+ const assignedTurn = turnAllocator.peekNext()
571
+ // Stable occurrence index for this identity (pre-hash; role+content is a
572
+ // sufficient discriminator for the duplicate case the ordinal disambiguates),
573
+ // seeded above from messages[0, start) so it reflects full-array position
574
+ // regardless of where this call's window starts.
575
+ const occKey = `${role}\u0000${content}`
576
+ const occurrence = (_identityOccurrence.get(occKey) ?? 0)
577
+ _identityOccurrence.set(occKey, occurrence + 1)
493
578
  // Stable per-message identity. The previous `session:${id}#${i+1}` key was
494
579
  // positional, so after compaction shrinks/reindexes session.messages a
495
580
  // later turn could reuse an old index and be silently skipped by
496
581
  // ON CONFLICT DO NOTHING. stableSessionSourceRef hashes only durable
497
582
  // fields (role, original ts if present, tool ids, content) — never the
498
- // synthesized tsMs fallback or the loop index.
499
- const sourceRef = stableSessionSourceRef(sessionId, m, role, content)
500
- // Assign the next monotonic turn; only consume it when the row is actually
501
- // inserted (a conflicting re-ingest keeps its original source_turn).
502
- const assignedTurn = turnAllocator.peekNext()
583
+ // synthesized tsMs fallback or the loop index. For untimestamped turns the
584
+ // monotonic ordinal is folded in so genuine repeats persist (not collapsed).
585
+ const sourceRef = stableSessionSourceRef(sessionId, m, role, content, occurrence)
503
586
  const result = await db.query(`
504
587
  INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
505
588
  VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -1310,7 +1393,7 @@ async function handleMemoryAction(args, signal) {
1310
1393
  const RETRO_BATCH = 50
1311
1394
  const cycle2Config = config?.cycle2 || {}
1312
1395
  const allActive = (await db.query(
1313
- `SELECT id, element, category, summary, score, last_seen_at, project_id, status
1396
+ `SELECT id, element, category, summary, score, last_seen_at, project_id, status, reviewed_at
1314
1397
  FROM entries WHERE is_root = 1 AND status = 'active'
1315
1398
  ORDER BY reviewed_at ASC, id ASC`
1316
1399
  )).rows
@@ -1370,19 +1453,23 @@ async function handleMemoryAction(args, signal) {
1370
1453
  }
1371
1454
  if (!primaryActions.length) { kept += batch.filter(r => successIds.has(Number(r.id))).length; continue }
1372
1455
  const acted = new Set()
1456
+ const rowById = new Map(batch.map(r => [Number(r.id), r]))
1373
1457
  for (const act of primaryActions) {
1374
1458
  try {
1375
1459
  const eid = Number(act?.entry_id)
1376
1460
  if (!Number.isFinite(eid) || !allowed.has(eid)) continue
1377
1461
  acted.add(eid)
1462
+ // Optimistic guard: skip if the row moved since this batch was read.
1463
+ const snap = rowById.get(eid)
1464
+ const expected = snap ? { status: snap.status, reviewedAt: snap.reviewed_at } : null
1378
1465
  if (act.action === 'archived') {
1379
- if (await applySimpleStatus(db, eid, 'archived')) archived += 1
1466
+ if (await applySimpleStatus(db, eid, 'archived', expected)) archived += 1
1380
1467
  } else if (act.action === 'active') {
1381
1468
  // active → active is a keep verdict from the gate.
1382
1469
  kept += 1
1383
1470
  await setCoreSummary(eid, coreSummaryById.get(eid))
1384
1471
  } else if (act.action === 'update') {
1385
- if (await applyUpdate(db, eid, act.element, act.summary)) updated += 1
1472
+ if (await applyUpdate(db, eid, act.element, act.summary, { expected })) updated += 1
1386
1473
  await setCoreSummary(eid, coreSummaryById.get(eid))
1387
1474
  } else if (act.action === 'merge') {
1388
1475
  const targetId = Number(act?.target_id)
@@ -27,11 +27,19 @@ import fs from 'node:fs'
27
27
 
28
28
  const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
29
29
  const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
30
+ const CYCLE1_OMITTED_COOLDOWN_MS = 60 * 60 * 1000
30
31
  const BACKLOG_WARN_COOLDOWN_MS = 10 * 60_000
31
32
  const BACKLOG_WARN_PENDING = 500
32
33
  const BACKLOG_WARN_FAILURES = 5
33
34
  // Max back-to-back cycle2 passes per scheduled slot (config: cycle2.catchup_passes).
34
35
  const CYCLE2_CATCHUP_PASSES = 4
36
+ const CYCLE2_CATCHUP_PASSES_MAX = 10
37
+
38
+ function resolveCycle2CatchupPasses(config) {
39
+ const raw = Number(config?.catchup_passes ?? CYCLE2_CATCHUP_PASSES)
40
+ if (!Number.isFinite(raw)) return CYCLE2_CATCHUP_PASSES
41
+ return Math.min(CYCLE2_CATCHUP_PASSES_MAX, Math.max(1, Math.floor(raw)))
42
+ }
35
43
 
36
44
  export function createCycleScheduler(deps) {
37
45
  const {
@@ -260,10 +268,11 @@ export function createCycleScheduler(deps) {
260
268
  // Catch-up drain: one scheduled slot may run several back-to-back
261
269
  // passes while pending roots remain, so a backlog above the batch
262
270
  // size drains in one interval instead of one batch per hour.
263
- const drainPasses = Math.max(1, Number(config?.catchup_passes ?? CYCLE2_CATCHUP_PASSES))
271
+ const drainPasses = resolveCycle2CatchupPasses(config)
264
272
  for (let pass = 0; pass < drainPasses; pass++) {
265
273
  let c2Options = {
266
274
  coalescedRetry: true,
275
+ catchUpDrainPass: pass > 0,
267
276
  onCoalescedSuccess: _finalizeCycle2Run,
268
277
  }
269
278
  if (typeof c2Options?.callLlm !== 'function') {
@@ -285,13 +294,16 @@ export function createCycleScheduler(deps) {
285
294
  // A gate parse/coverage failure marks the run failed even with
286
295
  // ok=true; never chain passes on a failing gate.
287
296
  if (result?.gate_failed === true) break
288
- if (pass + 1 >= drainPasses) break
289
297
  const pendingRes = await getDb().query(
290
298
  `SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`,
291
299
  )
292
300
  const pendingLeft = Number(pendingRes?.rows?.[0]?.c ?? 0)
301
+ log(
302
+ `[cycle2] catch-up pass ${pass + 1}/${drainPasses}: `
303
+ + `promoted=${Number(result?.promoted ?? 0)} pending=${pendingLeft}\n`,
304
+ )
293
305
  if (pendingLeft <= 0) break
294
- log(`[cycle2] catch-up pass ${pass + 2}/${drainPasses}: pending=${pendingLeft}\n`)
306
+ if (pass + 1 >= drainPasses) break
295
307
  }
296
308
  } catch (err) {
297
309
  log(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
@@ -418,13 +430,20 @@ export function createCycleScheduler(deps) {
418
430
  const unchunked = Number((await db.query(
419
431
  `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND NULLIF(btrim(session_id), '') IS NOT NULL`,
420
432
  )).rows[0]?.c ?? 0)
433
+ const unchunkedEligible = Number((await db.query(
434
+ `SELECT COUNT(*) c FROM entries
435
+ WHERE chunk_root IS NULL
436
+ AND NULLIF(btrim(session_id), '') IS NOT NULL
437
+ AND (reviewed_at IS NULL OR reviewed_at < $1)`,
438
+ [now - CYCLE1_OMITTED_COOLDOWN_MS],
439
+ )).rows[0]?.c ?? 0)
421
440
  const cycle2Pending = Number((await db.query(
422
441
  `SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`,
423
442
  )).rows[0]?.c ?? 0)
424
- _cycleBacklogSnapshot = { unchunked, cycle2_pending: cycle2Pending, at: now }
443
+ _cycleBacklogSnapshot = { unchunked, unchunked_eligible: unchunkedEligible, cycle2_pending: cycle2Pending, at: now }
425
444
  _writeCycleStateFile()
426
445
  if (unchunked > BACKLOG_WARN_PENDING || cycle2Pending > BACKLOG_WARN_PENDING) {
427
- _warnCycleHealth(`backlog unchunked=${unchunked} cycle2_pending=${cycle2Pending}`)
446
+ _warnCycleHealth(`backlog unchunked=${unchunked} eligible=${unchunkedEligible} cycle2_pending=${cycle2Pending}`)
428
447
  }
429
448
  if (unchunked > 0 && !_rawEmbedFlushInFlight) {
430
449
  _rawEmbedFlushInFlight = true
@@ -17,6 +17,11 @@ const VALID_CATEGORIES = new Set([
17
17
  ])
18
18
  const CYCLE1_OMITTED_RETRY_LIMIT = 2
19
19
  const CYCLE1_OMITTED_COOLDOWN_MS = 60 * 60 * 1000
20
+ // LLM/transport outages are transient and must NOT archive healthy raw rows at
21
+ // the tight parse-failure limit. Cooldown reviewed_at (so they sort to the back
22
+ // and stop hot-looping) but bump error_count under a much higher ceiling so a
23
+ // sustained provider outage doesn't shed the backlog.
24
+ const CYCLE1_TRANSIENT_RETRY_LIMIT = 50
20
25
 
21
26
  // Structural validation only. Conceptual omission belongs to the LLM; code
22
27
  // should not encode language-specific phrases such as acknowledgements.
@@ -59,7 +64,7 @@ async function markTerminalRows(db, rowIds, label = 'terminal') {
59
64
  }
60
65
  }
61
66
 
62
- async function markOmittedRows(db, rowIds) {
67
+ async function markOmittedRows(db, rowIds, retryLimit = CYCLE1_OMITTED_RETRY_LIMIT) {
63
68
  const ids = [...new Set((Array.isArray(rowIds) ? rowIds : [])
64
69
  .map(id => Number(id))
65
70
  .filter(id => Number.isFinite(id) && id > 0))]
@@ -81,7 +86,7 @@ async function markOmittedRows(db, rowIds) {
81
86
  FROM candidates c
82
87
  WHERE e.id = c.id
83
88
  RETURNING e.id, (c.next_error_count >= $3) AS marked_terminal`,
84
- [ids, Date.now(), CYCLE1_OMITTED_RETRY_LIMIT],
89
+ [ids, Date.now(), retryLimit],
85
90
  )
86
91
  const rows = Array.isArray(result?.rows) ? result.rows : []
87
92
  const marked = rows.filter(r => r.marked_terminal === true).length
@@ -309,6 +314,20 @@ async function countPendingRows(db) {
309
314
  }
310
315
  }
311
316
 
317
+ async function countRawUnchunkedRows(db) {
318
+ try {
319
+ const result = await db.query(
320
+ `SELECT COUNT(*) AS c
321
+ FROM entries
322
+ WHERE chunk_root IS NULL
323
+ AND NULLIF(btrim(session_id), '') IS NOT NULL`,
324
+ )
325
+ return Number(result.rows[0]?.c ?? 0)
326
+ } catch {
327
+ return null
328
+ }
329
+ }
330
+
312
331
  function uniqueNumbers(values) {
313
332
  return [...new Set((Array.isArray(values) ? values : [])
314
333
  .map(v => Number(v))
@@ -464,6 +483,7 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
464
483
  const signal = options?.signal
465
484
  throwIfAborted(signal)
466
485
  const pendingRowsAtStart = await countPendingRows(db)
486
+ const rawUnchunkedAtStart = await countRawUnchunkedRows(db)
467
487
  throwIfAborted(signal)
468
488
  const batchSize = Math.max(1, Number(config.batch_size ?? 100))
469
489
  const windowSize = Math.max(1, Number(config.window_size ?? config.windowSize ?? batchSize))
@@ -520,7 +540,14 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
520
540
  throwIfAborted(signal)
521
541
  const rowsDesc = fetchResult.rows
522
542
 
523
- if (Number.isFinite(pendingRowsAtStart) && pendingRowsAtStart < minBatch) {
543
+ const bypassMinBatchForCooldown = Number.isFinite(rawUnchunkedAtStart)
544
+ && rawUnchunkedAtStart >= minBatch
545
+ && Number.isFinite(pendingRowsAtStart)
546
+ && pendingRowsAtStart < minBatch
547
+ if (Number.isFinite(pendingRowsAtStart) && pendingRowsAtStart < minBatch && !bypassMinBatchForCooldown) {
548
+ const pendingLog = Number.isFinite(rawUnchunkedAtStart) ? rawUnchunkedAtStart : 'na'
549
+ const eligibleLog = Number.isFinite(pendingRowsAtStart) ? pendingRowsAtStart : 'na'
550
+ __mixdogMemoryLog(`[cycle1] quick-exit pending=${pendingLog} eligible=${eligibleLog} min_batch=${minBatch}\n`)
524
551
  throwIfAborted(signal)
525
552
  flushEmbeddingDirty(db, { signal }).catch((err) =>
526
553
  __mixdogMemoryLog(`[cycle1] quick-exit embedding flush failed: ${err.message}\n`)
@@ -629,6 +656,12 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
629
656
  const first = await callCycle1Grouping(buildCycle1ChunkPrompt(rows))
630
657
  if (!first.chunks) {
631
658
  __mixdogMemoryLog(`[cycle1] unparseable response (window=${windowIdx}) (${String(first.raw).slice(0, 200)})\n`)
659
+ // Cooldown the rows via the omitted-retry counter so a permanently
660
+ // unparseable/LLM-erroring window stops hot-looping. markOmittedRows
661
+ // bumps error_count and archives once CYCLE1_OMITTED_RETRY_LIMIT is hit
662
+ // (bounded backoff), instead of returning them failed to be re-picked
663
+ // on the very next tick forever.
664
+ const cooldown = await markOmittedRows(db, rows.map(r => Number(r.id)))
632
665
  return {
633
666
  committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
634
667
  invalidChunks: [{ reason: 'unparseable_response', member_ids: rows.map(r => Number(r.id)) }],
@@ -637,6 +670,8 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
637
670
  prefilteredRowIds,
638
671
  prefilterMarked,
639
672
  prefilterMarkFailed,
673
+ cooldownMarked: cooldown.marked,
674
+ cooldownDeferred: cooldown.deferred,
640
675
  }
641
676
  }
642
677
  firstChunkList = first.chunks
@@ -671,6 +706,11 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
671
706
  } catch (err) {
672
707
  if (signal?.aborted) throw err.reason ?? err
673
708
  __mixdogMemoryLog(`[cycle1] LLM error (window=${windowIdx}): ${err.message}\n`)
709
+ // Transient LLM/transport outage — NOT a data-quality parse failure.
710
+ // Cooldown reviewed_at so the window stops hot-looping, but under a much
711
+ // higher retry ceiling so a sustained provider outage does not archive
712
+ // healthy raw rows at the tight parse-failure limit.
713
+ const cooldown = await markOmittedRows(db, rows.map(r => Number(r.id)), CYCLE1_TRANSIENT_RETRY_LIMIT)
674
714
  return {
675
715
  committedChunks: 0, committedMembers: 0, skippedChunks: rows.length, rowsConsidered: originalRows.length,
676
716
  invalidChunks: [{ reason: 'llm_error', member_ids: rows.map(r => Number(r.id)) }],
@@ -679,6 +719,8 @@ async function _runCycle1Impl(db, config = {}, options = {}, _dataDir = null) {
679
719
  prefilteredRowIds,
680
720
  prefilterMarked,
681
721
  prefilterMarkFailed,
722
+ cooldownMarked: cooldown.marked,
723
+ cooldownDeferred: cooldown.deferred,
682
724
  }
683
725
  }
684
726
 
@@ -353,11 +353,15 @@ export async function runUnifiedGate(db, rows, activeContext, config = {}, optio
353
353
  // numbered Entries block uses). The gate may echo either the real batch id
354
354
  // or the row ordinal 1..N; the parser resolves both when no integer token is
355
355
  // ambiguous (id K present in batch but row K points at a different entry).
356
- const ordinalToId = new Map(rows.map((r, i) => [i + 1, Number(r.id)]))
356
+ // On collision, DISABLE ordinal fallback (exact ids only) instead of
357
+ // skipping: a skipped batch is re-selected with the same rows next run and
358
+ // collides again — a permanent stall (observed: same "batch id 3 collides"
359
+ // skip every cycle while cycle2_pending climbed past 500).
360
+ let ordinalToId = new Map(rows.map((r, i) => [i + 1, Number(r.id)]))
357
361
  const collideToken = batchOrdinalIdCollides(statusById, ordinalToId, rows.length)
358
362
  if (collideToken != null) {
359
- __mixdogMemoryLog(`[cycle2] batch id ${collideToken} collides with row ordinal ${collideToken} (id ${ordinalToId.get(collideToken)}) — skipping batch (no safe id resolution)\n`)
360
- return { actions: null, rejected: new Set(), parseOk: false }
363
+ __mixdogMemoryLog(`[cycle2] batch id ${collideToken} collides with row ordinal ${collideToken} (id ${ordinalToId.get(collideToken)}) — ordinal fallback disabled, exact ids only\n`)
364
+ ordinalToId = null
361
365
  }
362
366
 
363
367
  __mixdogMemoryLog(`[cycle2-diag] unified prompt=${prompt.length} bytes; rows=${rows.length}\n`)
@@ -49,18 +49,25 @@ export function clampPendingPromotions(statusBatch, rowsById, activeCount, activ
49
49
  // Trigger handles score recompute automatically — no app-side score writes.
50
50
  export async function applyBatchStatusVerdicts(db, batch, nowMs) {
51
51
  if (!batch || batch.length === 0) return { promoted: 0, archived: 0 }
52
+ // Optimistic guard: each item may carry expected {status, reviewedAt} — the
53
+ // snapshot the verdict was computed against. NULL expected_* means "no guard"
54
+ // (legacy blind update). The UPDATE only fires when the row still matches, so
55
+ // a concurrent write skips the stale verdict.
52
56
  const valueRows = batch.map((item, i) => {
53
- const base = i * 3
54
- return `($${base + 1}::bigint, $${base + 2}::text, $${base + 3}::boolean)`
57
+ const base = i * 5
58
+ return `($${base + 1}::bigint, $${base + 2}::text, $${base + 3}::boolean, $${base + 4}::entry_status, $${base + 5}::bigint)`
55
59
  })
56
60
  const params = []
57
61
  for (const item of batch) {
58
- params.push(item.entry_id, item.new_status, item.was_pending)
62
+ const exp = item.expected ?? null
63
+ const expStatus = exp && typeof exp.status === 'string' ? exp.status : null
64
+ const expReviewed = exp && Number.isFinite(Number(exp.reviewedAt)) ? Number(exp.reviewedAt) : null
65
+ params.push(item.entry_id, item.new_status, item.was_pending, expStatus, expReviewed)
59
66
  }
60
67
  params.push(nowMs)
61
68
  const lastParam = `$${params.length}`
62
69
  const res = await db.query(
63
- `WITH actions(entry_id, new_status, was_pending) AS (
70
+ `WITH actions(entry_id, new_status, was_pending, exp_status, exp_reviewed) AS (
64
71
  VALUES ${valueRows.join(', ')}
65
72
  )
66
73
  UPDATE entries
@@ -72,6 +79,8 @@ export async function applyBatchStatusVerdicts(db, batch, nowMs) {
72
79
  END
73
80
  FROM actions a
74
81
  WHERE entries.id = a.entry_id AND entries.is_root = 1
82
+ AND (a.exp_status IS NULL OR entries.status IS NOT DISTINCT FROM a.exp_status)
83
+ AND (a.exp_reviewed IS NULL OR entries.reviewed_at IS NOT DISTINCT FROM a.exp_reviewed)
75
84
  RETURNING entries.id, entries.status, a.was_pending, a.new_status`,
76
85
  params,
77
86
  )
@@ -85,10 +94,27 @@ export async function applyBatchStatusVerdicts(db, batch, nowMs) {
85
94
  }
86
95
 
87
96
  // Generic status update for archived/active terminal transitions.
88
- export async function applySimpleStatus(db, entryId, nextStatus) {
97
+ // Optimistic concurrency: when the caller passes `expected` (the status and/or
98
+ // reviewed_at it observed when it made the verdict), the UPDATE only fires if
99
+ // the row still matches. A concurrent cycle/recall write that changed status or
100
+ // bumped reviewed_at makes the guard fail (0 rows) so a stale LLM verdict does
101
+ // not overwrite the newer state. Omitting `expected` preserves legacy blind
102
+ // update-by-id behavior for callers that don't track a baseline.
103
+ export async function applySimpleStatus(db, entryId, nextStatus, expected = null) {
104
+ const params = [nextStatus, entryId]
105
+ const guards = []
106
+ if (expected && typeof expected.status === 'string') {
107
+ guards.push(`status IS NOT DISTINCT FROM $${params.length + 1}::entry_status`)
108
+ params.push(expected.status)
109
+ }
110
+ if (expected && Number.isFinite(Number(expected.reviewedAt))) {
111
+ guards.push(`reviewed_at IS NOT DISTINCT FROM $${params.length + 1}::bigint`)
112
+ params.push(Number(expected.reviewedAt))
113
+ }
114
+ const guardSql = guards.length ? ` AND ${guards.join(' AND ')}` : ''
89
115
  const res = await db.query(
90
- `UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1`,
91
- [nextStatus, entryId],
116
+ `UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1${guardSql}`,
117
+ params,
92
118
  )
93
119
  return Number(res.rowCount ?? res.affectedRows ?? 0) > 0
94
120
  }
@@ -108,8 +134,22 @@ export async function applyUpdate(db, entryId, element, summary, options = {}) {
108
134
  }
109
135
  if (setClauses.length === 0) return false
110
136
  params.push(entryId)
137
+ const idParam = paramIdx++
138
+ // Optimistic guard (see applySimpleStatus): skip the write if the row moved
139
+ // on since the verdict was computed. options.expected = { status, reviewedAt }.
140
+ const guards = []
141
+ const expected = options?.expected
142
+ if (expected && typeof expected.status === 'string') {
143
+ guards.push(`status IS NOT DISTINCT FROM $${paramIdx++}::entry_status`)
144
+ params.push(expected.status)
145
+ }
146
+ if (expected && Number.isFinite(Number(expected.reviewedAt))) {
147
+ guards.push(`reviewed_at IS NOT DISTINCT FROM $${paramIdx++}::bigint`)
148
+ params.push(Number(expected.reviewedAt))
149
+ }
150
+ const guardSql = guards.length ? ` AND ${guards.join(' AND ')}` : ''
111
151
  const res = await db.query(
112
- `UPDATE entries SET ${setClauses.join(', ')} WHERE id = $${paramIdx} AND is_root = 1`,
152
+ `UPDATE entries SET ${setClauses.join(', ')} WHERE id = $${idParam} AND is_root = 1${guardSql}`,
113
153
  params,
114
154
  )
115
155
  if (Number(res.rowCount ?? res.affectedRows ?? 0) === 0) return false
@@ -278,7 +318,11 @@ export async function runPhaseMerge(db, options = {}) {
278
318
  String(pair.a.summary ?? ''),
279
319
  String(pair.b.summary ?? ''),
280
320
  tier2Pairs,
281
- { signal },
321
+ // Thread the in-process LLM adapter through — omitting it falls back to
322
+ // callAgentDispatch (IPC), which is unavailable in the standalone
323
+ // memory service and failed every judge call with
324
+ // `agent-ipc: IPC channel unavailable`.
325
+ { signal, callLlm: options?.callLlm },
282
326
  )
283
327
  throwIfAborted(signal)
284
328
  if (shouldMerge) await doMerge(pair.a, pair.b, pair.sim)
@@ -324,7 +368,7 @@ export async function runPhaseMerge(db, options = {}) {
324
368
  String(row.entry_summary ?? ''),
325
369
  String(row.core_summary ?? ''),
326
370
  [],
327
- { signal },
371
+ { signal, callLlm: options?.callLlm },
328
372
  )
329
373
  throwIfAborted(signal)
330
374
  if (!verdictMerge) continue