mixdog 0.9.24 → 0.9.26

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 (84) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/explore-bench-tmp.mjs +1 -1
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/steering-fold-provenance-test.mjs +71 -0
  7. package/scripts/tool-efficiency-diag.mjs +1 -1
  8. package/scripts/webhook-smoke.mjs +46 -53
  9. package/src/defaults/cycle3-review-prompt.md +11 -4
  10. package/src/defaults/memory-promote-prompt.md +9 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/rules/lead/02-channels.md +3 -3
  13. package/src/rules/shared/01-tool.md +8 -4
  14. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  15. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  17. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  18. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  26. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  27. package/src/runtime/channels/backends/discord.mjs +10 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  30. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  31. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  32. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  33. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  34. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  35. package/src/runtime/channels/lib/webhook.mjs +98 -150
  36. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  37. package/src/runtime/memory/index.mjs +50 -25
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
  39. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  40. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  41. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  42. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  43. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  44. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  45. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  46. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  47. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  48. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  49. package/src/runtime/memory/tool-defs.mjs +1 -1
  50. package/src/runtime/shared/child-guardian.mjs +2 -2
  51. package/src/runtime/shared/open-url.mjs +2 -1
  52. package/src/runtime/shared/schedules-db.mjs +350 -0
  53. package/src/runtime/shared/service-discovery.mjs +169 -0
  54. package/src/runtime/shared/spawn-flags.mjs +27 -0
  55. package/src/runtime/shared/tool-primitives.mjs +3 -1
  56. package/src/runtime/shared/tool-surface.mjs +19 -3
  57. package/src/runtime/shared/update-checker.mjs +54 -10
  58. package/src/runtime/shared/webhooks-db.mjs +405 -0
  59. package/src/session-runtime/channel-config-api.mjs +13 -13
  60. package/src/session-runtime/lifecycle-api.mjs +14 -0
  61. package/src/session-runtime/runtime-core.mjs +39 -20
  62. package/src/standalone/agent-tool.mjs +42 -11
  63. package/src/standalone/channel-admin.mjs +173 -121
  64. package/src/standalone/channel-worker.mjs +2 -2
  65. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  66. package/src/tui/App.jsx +32 -10
  67. package/src/tui/app/channel-pickers.mjs +9 -9
  68. package/src/tui/app/clipboard.mjs +14 -0
  69. package/src/tui/app/doctor.mjs +1 -1
  70. package/src/tui/app/maintenance-pickers.mjs +17 -7
  71. package/src/tui/app/settings-picker.mjs +2 -2
  72. package/src/tui/app/transcript-window.mjs +37 -1
  73. package/src/tui/app/use-mouse-input.mjs +19 -6
  74. package/src/tui/components/ToolExecution.jsx +12 -4
  75. package/src/tui/display-width.mjs +10 -4
  76. package/src/tui/dist/index.mjs +166 -58
  77. package/src/tui/engine/agent-job-feed.mjs +42 -3
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/index.jsx +12 -2
  80. package/src/ui/statusline-segments.mjs +12 -1
  81. package/src/workflows/default/WORKFLOW.md +29 -38
  82. package/src/workflows/solo/WORKFLOW.md +15 -20
  83. package/vendor/ink/build/display-width.js +5 -4
  84. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
11
11
 
12
12
  const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
13
13
 
14
+ // Category grades the pipeline trusts outright: user/cycle1 tagged durable
15
+ // knowledge. These are NEVER content-scanned — a curated constraint like
16
+ // "95% pass threshold" or a rule that cites a metric must still promote.
17
+ const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
18
+
19
+ // Deterministic (non-LLM) signature for transient work-state: status/review
20
+ // churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
21
+ // requires snapshot phrasing (a measurement verb or an explicit result-metric
22
+ // noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
23
+ // Applied only to non-durable categories (e.g. 'fact'), catching benchmark
24
+ // snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
25
+ // these; this is the structural enforcement.
26
+ const TRANSIENT_SNAPSHOT_RE = new RegExp(
27
+ [
28
+ 'terminal-bench',
29
+ '\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
30
+ 'pass@\\d',
31
+ '\\btokens?\\s?\\/\\s?s(ec)?\\b',
32
+ 'status\\s+snapshot',
33
+ 'review(er)?\\s+(validation\\s+)?cycles?',
34
+ 'in[-\\s]progress',
35
+ '\\bWIP\\b',
36
+ '\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
37
+ '\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
38
+ ].join('|'),
39
+ 'i',
40
+ )
41
+
42
+ // Structural promotion gate: is this row transient content (task/issue chatter
43
+ // or a status/benchmark snapshot) that must not be promoted pending→active?
44
+ // task/issue block by category. Durable knowledge categories are trusted and
45
+ // skip the content scan. Everything else (e.g. 'fact') is content-scanned so
46
+ // snapshots mis-categorized as durable are still blocked.
47
+ export function isTransientPromotion(row) {
48
+ if (!row) return false
49
+ const cat = String(row.category ?? '').toLowerCase()
50
+ if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
51
+ if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
52
+ return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
53
+ }
54
+
55
+ // Strip pending→active promotions for transient content out of the status
56
+ // batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
57
+ // so they stay pending (held for re-confirmation on a later cycle) — never
58
+ // archived here, and reviewed_at is still bumped via the caller's reviewedIds.
59
+ export function blockTransientPromotions(statusBatch, rowsById) {
60
+ if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
61
+ let blocked = 0
62
+ const batch = statusBatch.filter((item) => {
63
+ if (item.was_pending && item.new_status === 'active'
64
+ && isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
65
+ blocked += 1
66
+ return false
67
+ }
68
+ return true
69
+ })
70
+ return { batch, blocked }
71
+ }
72
+
14
73
  // After the gate, cap how many pending→active promotions may land in one batch.
15
74
  // Overflow stays pending (not archived). Tiebreak: durable category before
16
75
  // task/issue, then score DESC, then older last_seen_at, then id ASC.
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
11
11
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
12
12
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
13
13
  import {
14
- applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
+ applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
15
15
  } from './memory-cycle2-mutations.mjs'
16
16
  import {
17
17
  CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
54
54
  rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
55
55
  merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
56
56
  missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
57
+ promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
57
58
  core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
58
59
  core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
59
60
  rescore: mergeNestedNumeric(a.rescore, b.rescore),
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
211
212
  updated: 0, kept: 0, rejected_verb: 0,
212
213
  merge_rejected: 0,
213
214
  missing_core_summary: 0,
215
+ promotion_blocked: 0,
214
216
  core_embedding_backfill: 0,
215
217
  core_candidates_nominated: 0,
216
218
  rescore: { updated: 0 },
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
523
525
  }
524
526
  guardedBatch.push(item)
525
527
  }
528
+ // Structural transient block runs BEFORE the cap clamp: task/issue chatter
529
+ // and status/benchmark snapshots can never promote pending→active, so they
530
+ // are stripped here (held pending) regardless of remaining cap slots.
531
+ const blockRes = blockTransientPromotions(guardedBatch, rowsById)
532
+ stats.promotion_blocked = blockRes.blocked
526
533
  const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
527
- const clampRes = clampPendingPromotions(guardedBatch, rowsById, activeCountForClamp, activeTargetCap)
534
+ const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
528
535
  stats.promotion_clamped = clampRes.clamped
529
536
  const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
530
537
  stats.promoted += batchRes.promoted
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
702
709
  ` core_backfill=${stats.core_embedding_backfill}` +
703
710
  ` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
704
711
  ` | gate promoted=${stats.promoted} archived=${stats.archived}` +
705
- ` promotion_clamped=${stats.promotion_clamped}` +
712
+ ` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
706
713
  ` updated=${stats.updated} kept=${stats.kept}` +
707
714
  ` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
708
715
  ` missing_core=${stats.missing_core_summary}` +
@@ -11,13 +11,17 @@ function __mixdogMemoryLog(...args) {
11
11
  // {{CORE_REVIEW}} block for defaults/cycle3-review-prompt.md, then asks the
12
12
  // maintenance-preset LLM for one verdict per id. By default Cycle3 performs
13
13
  // conservative cleanup: safe compression updates and strict duplicate merges
14
- // are applied, while deletes stay proposals unless explicitly confirmed.
14
+ // are applied. Deletes now also apply in conservative mode when the model
15
+ // tags the entry as clear junk (a whitelisted junk reason) and — for
16
+ // redundant-with-default reasons — the text actually echoes a current rule,
17
+ // bounded by a per-run delete cap so a run keeps a safety margin instead of
18
+ // nuking the whole set. Unreasoned / non-junk deletes still require APPLY.
15
19
  //
16
20
  // Verdict line grammar (mirrors parseUnifiedFormat in memory-cycle2.mjs):
17
21
  // <id>|keep
18
22
  // <id>|update|<element>|<summary>
19
23
  // <id>|merge|<target_id>|<source_ids_csv>
20
- // <id>|delete
24
+ // <id>|delete|<reason>
21
25
 
22
26
  import { existsSync, readFileSync } from 'fs'
23
27
  import { join } from 'path'
@@ -136,6 +140,58 @@ function isStrictDuplicate(a, b) {
136
140
  return sim >= 0.78
137
141
  }
138
142
 
143
+ // Whitelisted delete reasons that conservative mode may auto-apply. These are
144
+ // the "clear junk" classes: a copy of a built-in/default rule, a bare
145
+ // restatement, an obsolete/already-implemented decision, or a past-event log.
146
+ // Anything outside this set (or a bare `delete` with no reason) stays held for
147
+ // APPLY CYCLE3 so genuinely durable rules are never removed unattended.
148
+ const SAFE_DELETE_REASONS = new Set([
149
+ 'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
150
+ 'restatement', 'restate', 'restates_default',
151
+ 'obsolete', 'implemented', 'done', 'completed', 'resolved',
152
+ 'superseded_decision', 'stale', 'past_event', 'event_log', 'log',
153
+ ])
154
+ // Reasons that claim redundancy with a built-in/default rule — these demand
155
+ // corroboration (the core text must actually echo the current rules digest)
156
+ // before a conservative auto-delete, so a mislabelled durable rule survives.
157
+ const DEFAULT_ECHO_REASONS = new Set([
158
+ 'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
159
+ 'restatement', 'restate', 'restates_default',
160
+ ])
161
+
162
+ function normalizeDeleteReason(reason) {
163
+ return String(reason ?? '')
164
+ .toLowerCase()
165
+ .trim()
166
+ .replace(/[^a-z0-9]+/g, '_')
167
+ .replace(/^_+|_+$/g, '')
168
+ }
169
+
170
+ // Max trigram similarity of `text` against any substantive line of the current
171
+ // rules digest — how strongly a core entry echoes a built-in rule.
172
+ function digestRedundancy(text, rulesDigest) {
173
+ if (!text || !rulesDigest) return 0
174
+ const lines = String(rulesDigest).split('\n').map(l => l.trim()).filter(l => l.length >= 12)
175
+ let best = 0
176
+ for (const line of lines) {
177
+ const d = charDice(text, line)
178
+ if (d > best) best = d
179
+ if (best >= 0.9) break
180
+ }
181
+ return best
182
+ }
183
+
184
+ function isSafeConservativeDelete(core, action, rulesDigest) {
185
+ const reason = normalizeDeleteReason(action?.reason)
186
+ if (!reason) return { ok: false, reason: 'delete needs a junk reason → APPLY CYCLE3' }
187
+ if (!SAFE_DELETE_REASONS.has(reason)) return { ok: false, reason: `delete reason "${reason}" not in safe set → APPLY CYCLE3` }
188
+ if (DEFAULT_ECHO_REASONS.has(reason)) {
189
+ const red = digestRedundancy(coreText(core), rulesDigest)
190
+ if (red < 0.5) return { ok: false, reason: `not redundant with defaults (sim=${red.toFixed(2)})` }
191
+ }
192
+ return { ok: true, reason }
193
+ }
194
+
139
195
  function formatRelatedRow(r) {
140
196
  const tag = r.project_id ? r.project_id : 'COMMON'
141
197
  const stat = r.status ? `[${r.status}]` : '[?]'
@@ -206,7 +262,8 @@ function parseVerdicts(raw, idSet) {
206
262
  }
207
263
  actions.push({ id, verb: 'merge', targetId, sourceIds })
208
264
  } else if (verb === 'delete') {
209
- actions.push({ id, verb: 'delete' })
265
+ const reason = (parts[2] ?? '').trim()
266
+ actions.push({ id, verb: 'delete', reason: reason || null })
210
267
  } else if (verb === 'superseded' || verb === 'supersede') {
211
268
  // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
212
269
  // newer active core id the supersession has no evidence → drop to keep.
@@ -553,6 +610,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
553
610
  const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
554
611
  const details = []
555
612
  const touched = new Set() // ids already acted on this cycle — avoid double action
613
+ // Safety margin: even when every junk verdict is legitimate, a single
614
+ // conservative run applies at most this many outright deletes and holds the
615
+ // rest for the next pass. Prevents an over-eager model from clearing the set.
616
+ const conservativeDeleteCap = Math.max(1, Math.ceil(cores.length * 0.35))
556
617
 
557
618
  // Core-store edit/delete calls are the mutation unit; checkpoints sit before
558
619
  // each action/source and after each awaited unit, not inside one file-store write.
@@ -596,19 +657,28 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
596
657
  }
597
658
  if (a.verb === 'delete') {
598
659
  proposed.deleted++
599
- if (!confirmed) {
660
+ // confirmed → always delete. conservative → delete only clear junk (safe
661
+ // reason, corroborated for redundant-with-default), capped per run.
662
+ // proposal → always hold.
663
+ let applyDelete = confirmed
664
+ let holdReason = 'proposal mode'
665
+ let safeReason = null
666
+ if (!confirmed && conservative) {
667
+ const safeDel = isSafeConservativeDelete(coreById.get(a.id), a, rulesDigest)
668
+ if (!safeDel.ok) holdReason = safeDel.reason
669
+ else if (deleted >= conservativeDeleteCap) holdReason = `conservative delete cap ${conservativeDeleteCap} reached`
670
+ else { applyDelete = true; safeReason = safeDel.reason }
671
+ }
672
+ if (!applyDelete) {
600
673
  held.deleted++
601
- details.push({
602
- id: a.id, verb: 'delete', applied: false, held: true,
603
- reason: conservative ? 'delete requires APPLY CYCLE3' : 'proposal mode',
604
- })
674
+ details.push({ id: a.id, verb: 'delete', applied: false, held: true, reason: holdReason })
605
675
  touched.add(a.id)
606
676
  continue
607
677
  }
608
678
  try {
609
679
  await deleteCore(dataDir, a.id)
610
680
  deleted++
611
- details.push({ id: a.id, verb: 'delete', applied: true })
681
+ details.push({ id: a.id, verb: 'delete', applied: true, reason: safeReason || 'confirmed' })
612
682
  touched.add(a.id)
613
683
  } catch (err) {
614
684
  if (signal?.aborted) throw signal.reason ?? err
@@ -75,7 +75,11 @@ async function _initClient(client, schema) {
75
75
  // memory.entries from a trace-schema connection, or recall × trace JOIN
76
76
  // analytics) MUST use fully-qualified names (memory.entries / trace.trace_events).
77
77
  // Relying on search_path silently in cross-schema code = bug magnet.
78
- const sp = schema === 'trace' ? 'trace, public' : 'memory, public'
78
+ const sp = schema === 'trace'
79
+ ? 'trace, public'
80
+ : schema === 'scheduler'
81
+ ? 'scheduler, public'
82
+ : 'memory, public'
79
83
  await client.query(`SET search_path = ${sp}`)
80
84
  // pg_trgm similarity threshold: session-local, must be set per connection.
81
85
  await client.query(`SELECT set_limit(0.10)`)
@@ -331,6 +335,7 @@ async function bootstrapInstance(pgPool, dataDirKey) {
331
335
  await client.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public`)
332
336
  await client.query(`CREATE SCHEMA IF NOT EXISTS memory`)
333
337
  await client.query(`CREATE SCHEMA IF NOT EXISTS trace`)
338
+ await client.query(`CREATE SCHEMA IF NOT EXISTS scheduler`)
334
339
  } finally {
335
340
  client.release()
336
341
  }
@@ -290,7 +290,12 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
290
290
  // so we return the instant pg_isready succeeds rather than waiting on pg_ctl.
291
291
  // Only the long-lived child handle is unref'd; poll timers stay ref'd.
292
292
  async function startAndWaitReady() {
293
- const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
293
+ // detached on win32: give pg_ctl (and the postmaster it spawns) its own
294
+ // process group / console so an ancestor `taskkill /F /T` cannot enumerate
295
+ // it via the Node process tree. The orphaned postmaster survives and is
296
+ // re-adopted next boot via supervisor tryReusePgInstance (postmaster.pid).
297
+ const detached = process.platform === 'win32'
298
+ const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached })
294
299
  child.unref?.()
295
300
  let stdout = '', stderr = '', closed = false, exitCode = null
296
301
  child.stdout?.on('data', d => { stdout += d.toString() })
@@ -375,6 +380,19 @@ export async function stopPg({ runtimeDir, pgdataDir }) {
375
380
  }
376
381
  }
377
382
 
383
+ // Synchronous best-effort variant for a process 'exit' hook (only sync work is
384
+ // possible there). One bounded `pg_ctl stop -m fast`; never throws. On Windows
385
+ // a force-kill of the daemon can orphan the postmaster mid-write, so this is
386
+ // the last-ditch graceful attempt before the process goes away.
387
+ export function stopPgSync({ runtimeDir, pgdataDir }) {
388
+ try {
389
+ spawnSync(pgBin(runtimeDir, 'pg_ctl'), ['stop', '-m', 'fast', '-D', pgdataDir], {
390
+ env: libEnv(runtimeDir), stdio: 'ignore', timeout: 8_000, windowsHide: true,
391
+ })
392
+ __mixdogMemoryLog('[pg-process] stopPgSync: pg_ctl stop -m fast issued on exit\n')
393
+ } catch {}
394
+ }
395
+
378
396
  // ---------------------------------------------------------------------------
379
397
  // healthcheckPg
380
398
  // ---------------------------------------------------------------------------
@@ -28,7 +28,12 @@ import {
28
28
  } from 'node:fs';
29
29
  import { join, resolve } from 'node:path';
30
30
  import { tmpdir } from 'node:os';
31
- import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
31
+ import {
32
+ discoveryPath as _pgDiscoveryPath,
33
+ readServiceAdvert as _readPgServiceAdvert,
34
+ writeServiceAdvert as _writePgServiceAdvert,
35
+ } from '../../../shared/service-discovery.mjs';
36
+ import { withFileLockSync } from '../../../shared/atomic-file.mjs';
32
37
 
33
38
  // ── pg-process interface (Track A) ───────────────────────────────────────────
34
39
  // Dynamic import so this module loads even before Track A's file exists.
@@ -42,6 +47,7 @@ async function _getPgProc() {
42
47
  stopPg: mod.stopPg,
43
48
  healthcheckPg: mod.healthcheckPg,
44
49
  reconcileConfV2: mod.reconcileConfV2,
50
+ stopPgSync: mod.stopPgSync,
45
51
  };
46
52
  return _pgProc;
47
53
  }
@@ -181,23 +187,95 @@ const _RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
181
187
  ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
182
188
  : join(tmpdir(), 'mixdog');
183
189
  const _ACTIVE_FILE = join(_RUNTIME_ROOT, 'active-instance.json');
190
+ // Single-writer PG advert. Port discovery (pg_port et al.) moved OUT of the
191
+ // shared, lock-guarded active-instance.json into discovery/pg.json, written by
192
+ // this supervisor via a plain atomic rename (NO .lock) so it can never be
193
+ // starved by active-instance lock contention.
194
+ const _PG_DISCOVERY = 'pg';
195
+
196
+ // Read the PG advert, preferring the single-writer discovery file
197
+ // (discovery/pg.json) and falling back to the legacy active-instance.json pg_*
198
+ // fields for cross-version compat. Field names are identical in both sources.
199
+ function _readPgAdvert() {
200
+ const advert = _readPgServiceAdvert(_PG_DISCOVERY);
201
+ if (advert && Number(advert.pg_port) > 0) return advert;
202
+ try {
203
+ const ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
204
+ if (ai && Number(ai.pg_port) > 0) return ai;
205
+ } catch {}
206
+ return advert ?? null;
207
+ }
184
208
 
185
- // Lock contention codes thrown by withFileLockSync when the active-instance
186
- // lock cannot be acquired within timeoutMs (or try-once contention).
187
- const _LOCK_CONTENTION_CODES = new Set(['ELOCKTIMEOUT', 'ELOCKCONTENDED']);
209
+ // Transient write-contention codes. ELOCK* survive from the old locked path;
210
+ // EPERM/EBUSY/EACCES cover Windows atomic-rename hiccups on the plain-rename
211
+ // discovery write so it backs off and retries rather than dropping the advert.
212
+ const _LOCK_CONTENTION_CODES = new Set(['ELOCKTIMEOUT', 'ELOCKCONTENDED', 'EPERM', 'EBUSY', 'EACCES']);
213
+
214
+ // Is the current on-disk advert owned by a DIFFERENT, still-live supervisor?
215
+ // A clear (pg_port → null) must never unlink a NEWER supervisor's fresh advert:
216
+ // re-read under the lock and, if pg_owner_pid names another live process, leave
217
+ // it. Our own pid, a dead owner, or a missing/legacy owner → safe to unlink.
218
+ function _pgAdvertIsForeignLive(adv) {
219
+ const owner = Number(adv?.pg_owner_pid);
220
+ if (!Number.isInteger(owner) || owner <= 0) return false;
221
+ if (owner === process.pid) return false;
222
+ return isPidAlive(owner);
223
+ }
188
224
 
189
225
  function _applyActiveInstancePatch(fields, timeoutMs) {
190
- updateJsonAtomicSync(_ACTIVE_FILE, (curRaw) => {
191
- // Drop stale fields (pid/startedAt) written by older server versions.
192
- const { pid: _legacyPid, startedAt: _legacyStartedAt, ...cur } = curRaw ?? {};
193
- // Omit null-valued fields (clean removal when pg is stopped).
194
- const merged = { ...cur, updatedAt: Date.now() };
226
+ // pg.json is NOT single-writer: start/attach/stop paths write it across
227
+ // separate supervisor processes, so the unlocked read-merge-write raced and
228
+ // lost cross-process updates. Guard it with pg.json's OWN private lock
229
+ // supervisor-only contention, so it never recreates the shared
230
+ // active-instance.json bottleneck (memory.json stays lock-free).
231
+ const file = _pgDiscoveryPath(_PG_DISCOVERY);
232
+ withFileLockSync(`${file}.lock`, () => {
233
+ const cur = _readPgServiceAdvert(_PG_DISCOVERY) ?? {};
234
+ // Drop stale fields (pid/startedAt/updatedAt) written by older versions.
235
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
195
236
  for (const [k, v] of Object.entries(fields)) {
196
237
  if (v == null) delete merged[k];
197
238
  else merged[k] = v;
198
239
  }
199
- return merged;
200
- }, { compact: true, fsyncDir: true, renameFallback: 'truncate', timeoutMs });
240
+ const port = Number(merged.pg_port);
241
+ if (!Number.isInteger(port) || port <= 0) {
242
+ // No live port left → drop the advert (clean pg shutdown). But a late
243
+ // clear from an old shutdown/recovery path must NOT delete a fresh advert
244
+ // a newer supervisor just published — unlink only when it is ours or a
245
+ // dead corpse, never another live owner's.
246
+ if (_pgAdvertIsForeignLive(cur)) return;
247
+ try { unlinkSync(file); } catch {}
248
+ return;
249
+ }
250
+ // Stamp our supervisor pid so a later clear can prove advert ownership.
251
+ merged.pg_owner_pid = process.pid;
252
+ _writePgServiceAdvert(_PG_DISCOVERY, merged);
253
+ }, { timeoutMs });
254
+ }
255
+
256
+ // Re-stamp advert ownership on REUSE without clobbering a concurrently-updated
257
+ // advert. Unlike patchActiveInstance (which blindly overwrites pg_port/pg_pgdata
258
+ // /pg_runtime_dir from the reuse-time snapshot — already stale by the time the
259
+ // lock is acquired, so a restart or a different live owner that landed meanwhile
260
+ // gets clobbered), this RE-READS under the lock and only stamps our pid when the
261
+ // advert STILL describes the same instance we reused (same pgdata + port) and no
262
+ // DIFFERENT live owner has since taken it. It touches only pg_owner_pid — never
263
+ // rewriting other fields from the stale snapshot.
264
+ function _restampReuseOwner({ pgdata, port }) {
265
+ const file = _pgDiscoveryPath(_PG_DISCOVERY);
266
+ try {
267
+ withFileLockSync(`${file}.lock`, () => {
268
+ const cur = _readPgServiceAdvert(_PG_DISCOVERY);
269
+ if (!cur) return; // advert gone — nothing to re-stamp
270
+ if (Number(cur.pg_port) !== Number(port)) return; // restarted on a new port
271
+ if (!cur.pg_pgdata || resolve(cur.pg_pgdata) !== resolve(pgdata)) return; // different instance
272
+ if (_pgAdvertIsForeignLive(cur)) return; // a different live owner holds it
273
+ if (Number(cur.pg_owner_pid) === process.pid) return; // already ours
274
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, updatedAt: _prevUpdatedAt, ...merged } = cur;
275
+ merged.pg_owner_pid = process.pid; // minimal: owner pid only (updatedAt re-stamped on write)
276
+ _writePgServiceAdvert(_PG_DISCOVERY, merged);
277
+ }, { timeoutMs: 1000 });
278
+ } catch { /* best-effort: lock contention → skip re-stamp (guard reverts to prior owner) */ }
201
279
  }
202
280
 
203
281
  // Single module-level background-retry slot: a delayed retry must NEVER replay
@@ -347,7 +425,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
347
425
  let existingPort = null;
348
426
  let existingRtDir = runtimeDir;
349
427
  try {
350
- ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
428
+ ai = _readPgAdvert();
351
429
  if (ai?.pg_port && ai?.pg_pgdata && resolve(ai.pg_pgdata) === resolve(pgdata)) {
352
430
  existingPort = ai.pg_port;
353
431
  existingRtDir = ai?.pg_runtime_dir ?? runtimeDir;
@@ -359,6 +437,14 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
359
437
  if (await healthcheckPg({ port: existingPort })) {
360
438
  __mixdogMemoryLog(`[supervisor-pg] reusing PG on port ${existingPort} (${source}:active-instance)\n`);
361
439
  _live = { port: existingPort, pgdata, runtimeDir: existingRtDir, proc: null };
440
+ // Re-stamp advert ownership: this process is now a live owner of the
441
+ // reused postmaster. Without it the advert keeps the previous (possibly
442
+ // dead) owner pid, and a later sync exit hook's _pgAdvertIsForeignLive
443
+ // guard would judge it "not foreign-live" and wrongly sync-stop a
444
+ // postmaster this reuser still uses. Use the revalidating re-stamp (not
445
+ // patchActiveInstance) so a concurrent restart/owner change is not
446
+ // clobbered by this reuse-time snapshot.
447
+ _restampReuseOwner({ pgdata, port: existingPort });
362
448
  return { host: '127.0.0.1', port: existingPort, runtimeDir: existingRtDir, pgdataDir: pgdata };
363
449
  }
364
450
  } catch {}
@@ -452,7 +538,7 @@ async function _doEnsure(dataDir) {
452
538
  let existingPort = null;
453
539
  let ai = null;
454
540
  try {
455
- ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8'));
541
+ ai = _readPgAdvert();
456
542
  // Only reuse a recorded instance when it was started for THIS pgdata.
457
543
  // active-instance.json is process-global; without matching pg_pgdata a
458
544
  // healthy PG serving a different data directory would be reused, binding
@@ -536,7 +622,7 @@ export async function stopPgForShutdown() {
536
622
  // _live may be null if PG was started by another process or adapter call.
537
623
  // Attempt graceful stop via active-instance.json.
538
624
  let ai = null;
539
- try { ai = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8')); } catch {}
625
+ try { ai = _readPgAdvert(); } catch {}
540
626
  if (!ai?.pg_port || !ai?.pg_pgdata) return;
541
627
  const pgdataDir2 = ai.pg_pgdata;
542
628
  const runtimeDir2 = ai.pg_runtime_dir;
@@ -570,3 +656,28 @@ export async function stopPgForShutdown() {
570
656
  // tolerated — readers healthcheck-verify the port before reuse.
571
657
  patchActiveInstance({ pg_port: null, pg_started_at: null, pg_pgdata: null, pg_runtime_dir: null }, { timeoutMs: 2000, syncRetries: 1 });
572
658
  }
659
+
660
+ /**
661
+ * Synchronous last-ditch PG stop for a process 'exit' hook. Covers the paths
662
+ * where the async stopPgForShutdown() above never runs/completes — a Windows
663
+ * force-kill, or stop() hitting its exit timeout — which otherwise kill the
664
+ * postmaster mid-write and force WAL crash recovery on next boot. No-op once
665
+ * the async path has cleared _live + active-instance (its record is gone), so
666
+ * a graceful shutdown is never double-stopped.
667
+ */
668
+ export function stopPgForShutdownSync() {
669
+ // Mirror the async clear's ownership guard (_pgAdvertIsForeignLive) and apply
670
+ // it UNCONDITIONALLY — even when _live is set. Async shutdown or a
671
+ // cross-process reuse may have handed this postmaster to a different, still-
672
+ // live owner that re-stamped pg_owner_pid; that owner will stop it itself, so
673
+ // the exit hook must never sync-stop it out from under them.
674
+ let ai = null;
675
+ try { ai = _readPgAdvert(); } catch {}
676
+ if (_pgAdvertIsForeignLive(ai)) return;
677
+ let runtimeDir = _live?.runtimeDir, pgdataDir = _live?.pgdata;
678
+ if (!runtimeDir || !pgdataDir) {
679
+ runtimeDir = ai?.pg_runtime_dir; pgdataDir = ai?.pg_pgdata;
680
+ }
681
+ if (!runtimeDir || !pgdataDir || !_pgProc?.stopPgSync) return;
682
+ try { _pgProc.stopPgSync({ runtimeDir, pgdataDir }); } catch {}
683
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
24
24
  import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
25
25
  import { retrieveEntries } from './memory-retrievers.mjs'
26
+ import { buildPromotedExclusionClauses } from './memory-recall-scope-filter.mjs'
26
27
  import { cleanMemoryText } from './memory.mjs'
27
28
  import { insertTraceEvents } from './trace-store.mjs'
28
29
  import {
@@ -661,6 +662,107 @@ export function createQueryHandlers({
661
662
  return out
662
663
  }
663
664
 
665
+ // period='last': session-grouped browse. Pick the N most-recently-active
666
+ // sessions (limit = session count, default 5; offset = session-level
667
+ // paging) ranked by MAX(ts) DESC, then fill each with its newest rows
668
+ // under a per-session row cap. Session selection and per-session fetch
669
+ // reuse the same projectScope / excludeStatuses filters as the generic
670
+ // browse below; the grouped renderer adds activity-span headers. Output
671
+ // size is bounded by session count x per-session row cap plus the
672
+ // orchestrator-level tool-output KB cap — no recall-local line budget.
673
+ if (temporal?.mode === 'last') {
674
+ const sessionCount = Number.isFinite(requestedLimit) ? limit : 5
675
+ const PER_SESSION_ROW_CAP = 10
676
+ const excludeStatuses = includeArchived ? [] : ['archived']
677
+ const VALID_LAST_CATS = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
678
+ const catList = category == null
679
+ ? []
680
+ : (Array.isArray(category) ? category : [category])
681
+ .map((c) => String(c).trim().toLowerCase())
682
+ .filter((c) => VALID_LAST_CATS.has(c))
683
+ // 1) rank sessions by most-recent activity (MAX(ts)) over the SAME pool
684
+ // the fill draws from — roots (status-filtered) + unchunked raw
685
+ // leaves, member rows excluded — so a session ranked here can never
686
+ // fill empty (e.g. an archived-root session whose only surviving rows
687
+ // are members). projectScope + excludeStatuses + category + promoted
688
+ // exclusion all match the fill filters below.
689
+ const selWhere = [
690
+ 'session_id IS NOT NULL',
691
+ "btrim(session_id) <> ''",
692
+ '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)',
693
+ ]
694
+ const selParams = []
695
+ if (projectScope === 'common') {
696
+ selWhere.push('project_id IS NULL')
697
+ } else if (typeof projectScope === 'string' && projectScope && projectScope !== 'all') {
698
+ selParams.push(projectScope)
699
+ selWhere.push(`(project_id IS NULL OR project_id = $${selParams.length})`)
700
+ }
701
+ if (excludeStatuses.length > 0) {
702
+ const ph = excludeStatuses.map((s) => { selParams.push(s); return `$${selParams.length}` }).join(',')
703
+ selWhere.push(`(status IS NULL OR status NOT IN (${ph}))`)
704
+ }
705
+ if (catList.length > 0) {
706
+ const ph = catList.map((c) => { selParams.push(c); return `$${selParams.length}` }).join(',')
707
+ selWhere.push(`category IN (${ph})`)
708
+ }
709
+ for (const c of buildPromotedExclusionClauses()) selWhere.push(c)
710
+ selParams.push(sessionCount, offset)
711
+ // Deterministic tie-breaker: equal MAX(ts) across sessions would let the
712
+ // LIMIT/OFFSET window skip or duplicate a session between offset pages
713
+ // without a stable secondary sort.
714
+ const sessSql = `SELECT session_id, MAX(ts) AS last_ts
715
+ FROM entries
716
+ WHERE ${selWhere.join(' AND ')}
717
+ GROUP BY session_id
718
+ ORDER BY last_ts DESC, session_id DESC
719
+ LIMIT $${selParams.length - 1} OFFSET $${selParams.length}`
720
+ const sessRows = (await db.query(sessSql, selParams)).rows
721
+ // 2) per selected session, fetch its newest rows (roots+members, sort by
722
+ // date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
723
+ const allRows = []
724
+ for (const s of sessRows) {
725
+ const sid = String(s?.session_id || '').trim()
726
+ if (!sid) continue
727
+ const sf = { limit: PER_SESSION_ROW_CAP, session_id: sid, projectScope, sort: 'date' }
728
+ if (includeMembers) sf.includeMembers = true
729
+ if (excludeStatuses.length > 0) sf.excludeStatuses = excludeStatuses
730
+ if (catList.length > 0) sf.category = catList
731
+ const sRows = await retrieveEntries(db, sf)
732
+ let merged = sRows
733
+ if (includeRaw) {
734
+ const rawRows = await readRawRowsInWindow(db, null, Date.now(), PER_SESSION_ROW_CAP, { projectScope, sessionId: sid })
735
+ const seenIds = new Set(sRows.map((r) => Number(r.id)))
736
+ for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
737
+ // readRawRowsInWindow carries no category filter, so a category-
738
+ // scoped last must gate raw rows here to match the ranking/fill
739
+ // category predicate (unclassified raw rows have no category and
740
+ // are correctly dropped when a category is requested).
741
+ let newRaw = rawRows.filter((r) => !seenIds.has(Number(r.id)))
742
+ if (catList.length > 0) {
743
+ newRaw = newRaw.filter((r) => catList.includes(String(r.category || '').trim().toLowerCase()))
744
+ }
745
+ // readRawRowsInWindow carries no status filter either, so an
746
+ // includeArchived:false browse must drop archived raw rows here to
747
+ // match the ranking/root-fill excludeStatuses predicate.
748
+ if (excludeStatuses.length > 0) {
749
+ newRaw = newRaw.filter((r) => {
750
+ const st = String(r.status || '').trim().toLowerCase()
751
+ return !st || !excludeStatuses.includes(st)
752
+ })
753
+ }
754
+ if (newRaw.length > 0) {
755
+ merged = [...sRows, ...newRaw]
756
+ merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
757
+ merged = merged.slice(0, PER_SESSION_ROW_CAP)
758
+ }
759
+ }
760
+ for (const r of merged) allRows.push(r)
761
+ }
762
+ const _currentSessionHint = String(args?.currentSessionId || '').trim()
763
+ return { text: recallCapPrefix + renderSessionGroupedLines(allRows, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
764
+ }
765
+
664
766
  const filters = { limit: limit + offset }
665
767
  if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
666
768
  filters.projectScope = projectScope