mixdog 0.9.12 → 0.9.14

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 (58) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/defaults/cycle3-review-prompt.md +14 -0
  5. package/src/defaults/memory-promote-prompt.md +9 -9
  6. package/src/lib/rules-builder.cjs +15 -11
  7. package/src/mixdog-session-runtime.mjs +10 -0
  8. package/src/rules/agent/00-common.md +5 -17
  9. package/src/rules/agent/00-core.md +21 -0
  10. package/src/rules/lead/lead-brief.md +7 -0
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  14. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  15. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  17. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  18. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  22. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  24. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  25. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  26. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  28. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  29. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  30. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  31. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  32. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  33. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  34. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
  35. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  36. package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
  37. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  38. package/src/runtime/channels/index.mjs +28 -1
  39. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  40. package/src/runtime/memory/index.mjs +18 -13
  41. package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
  42. package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
  43. package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
  44. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
  45. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
  46. package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
  47. package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
  48. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  49. package/src/runtime/memory/lib/memory.mjs +7 -1
  50. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  51. package/src/standalone/agent-tool.mjs +89 -7
  52. package/src/tui/dist/index.mjs +203 -10
  53. package/src/tui/engine/tui-steering-persist.mjs +175 -0
  54. package/src/tui/engine.mjs +46 -2
  55. package/src/ui/statusline.mjs +2 -4
  56. package/src/workflows/default/WORKFLOW.md +2 -0
  57. package/src/workflows/sequential/WORKFLOW.md +2 -0
  58. package/src/workflows/solo/WORKFLOW.md +2 -0
@@ -193,6 +193,22 @@ export const PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS = resolveTimeoutMs(
193
193
  { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: STALL_ABORT_MS },
194
194
  );
195
195
 
196
+ // First-MEANINGFUL-frame watchdog (WS). The inter-chunk timer resets on EVERY
197
+ // received frame (keepalive/metadata/rate_limits keep the socket "alive"), so a
198
+ // server that ACKs response.create with only keepalive/metadata frames — never
199
+ // a response.created or a content/tool delta — resets provider idle forever and
200
+ // the pre-response first-byte window never wins. This distinct timer is cleared
201
+ // ONLY by a MEANINGFUL response event (response.created or the first content /
202
+ // tool-arg delta); keepalive/metadata frames do NOT touch it. On expiry the
203
+ // stream is treated as stalled (streamStalledError → existing retry/fallback).
204
+ // Kept comfortably below the agent stall first-byte abort (300s default) so the
205
+ // provider layer catches the wedge before the agent watchdog does. Env-tunable.
206
+ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
207
+ 'MIXDOG_PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS',
208
+ 120_000,
209
+ { minMs: 10_000, maxMs: STALL_WARN_MS },
210
+ );
211
+
196
212
  // First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
197
213
  // a transient 5xx burst lets parallel workers thundering-herd the backend in
198
214
  // lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
@@ -70,6 +70,7 @@ const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.ur
70
70
  const {
71
71
  appendEntry: memoryAppendEntry,
72
72
  ingestTranscript: memoryIngestTranscript,
73
+ drainBuffer: memoryDrainBuffer,
73
74
  } = await import(memoryClientModulePath);
74
75
  // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
75
76
  // after an unhandled rejection whose async state was already corrupted
@@ -188,7 +189,19 @@ if (!_isWorkerMode) {
188
189
  // owner_lead_alive() sees a live owner and uses the full connect budget
189
190
  // instead of the 5s no-owner grace (fixes missing recap/core on restart).
190
191
  // backendReady intentionally omitted — readiness stays gated until connect.
191
- refreshActiveInstance(INSTANCE_ID);
192
+ try {
193
+ refreshActiveInstance(INSTANCE_ID);
194
+ } catch (e) {
195
+ const code = e?.code;
196
+ const transient =
197
+ code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT";
198
+ if (!transient) throw e;
199
+ try {
200
+ process.stderr.write(
201
+ `mixdog channels: refreshActiveInstance at startup failed (non-fatal, ${code}): ${e instanceof Error ? e.message : String(e)}\n`,
202
+ );
203
+ } catch {}
204
+ }
192
205
  startCliWorker();
193
206
  }
194
207
  const INSTRUCTIONS = "";
@@ -314,6 +327,10 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
314
327
  const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
315
328
  forwarder.startWatch();
316
329
  void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
330
+ // Opportunistic drain: an ingest that had to buffer (memory port not yet
331
+ // published) leaves entry-/ingest- files behind; kick the drainer so they
332
+ // replay as soon as the port appears, without waiting for the periodic tick.
333
+ void memoryDrainBuffer().catch(() => {});
317
334
  // onlyIfOwned: binds happen on the owned path, but discovery/poll loops
318
335
  // above can outlast an ownership handoff — never overwrite a newer owner.
319
336
  refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
@@ -549,6 +566,7 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
549
566
  let bindingReadyStatus = "pending";
550
567
  let _bindingReadyResolve;
551
568
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
569
+ let _memoryDrainTimer = null;
552
570
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
553
571
  // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
554
572
  // Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
@@ -723,6 +741,13 @@ async function startOwnedRuntime(options = {}) {
723
741
  return;
724
742
  }
725
743
  startOwnerHeartbeat();
744
+ // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
745
+ // memory service publishes its port. Idempotent + reentrancy-guarded inside
746
+ // drainBuffer(); unref'd so it never holds the worker open.
747
+ if (!_memoryDrainTimer) {
748
+ _memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
749
+ _memoryDrainTimer.unref?.();
750
+ }
726
751
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
727
752
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
728
753
  // webhook/binding launches below would revive owner state after stop).
@@ -808,6 +833,7 @@ async function startOwnedRuntime(options = {}) {
808
833
  try { stopOwnerHeartbeat(); } catch {}
809
834
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
810
835
  try { clearActiveInstance(INSTANCE_ID); } catch {}
836
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
811
837
  } finally {
812
838
  bridgeRuntimeStarting = false;
813
839
  }
@@ -837,6 +863,7 @@ async function stopOwnedRuntime(reason) {
837
863
  // file handle + timers for the rest of the process lifetime.
838
864
  try { forwarder.stopWatch(); } catch {}
839
865
  stopOwnerHeartbeat();
866
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
840
867
  scheduler.stop();
841
868
  stopSnapshotWriter();
842
869
  await stopWebhookAndEventRuntime();
@@ -31,7 +31,7 @@ async function getMemoryPort() {
31
31
  }
32
32
  }
33
33
 
34
- async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
34
+ async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000, { throwOnError = false } = {}) {
35
35
  const port = await getMemoryPort()
36
36
  return new Promise((resolve, reject) => {
37
37
  if (!port) { reject(new Error('active-instance.json missing memory_port')); return }
@@ -49,8 +49,20 @@ async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
49
49
  let data = ''
50
50
  res.on('data', chunk => { data += chunk })
51
51
  res.on('end', () => {
52
- try { resolve(JSON.parse(data)) }
53
- catch { resolve({ raw: data }) }
52
+ const status = res.statusCode || 0
53
+ let parsed
54
+ try { parsed = JSON.parse(data) }
55
+ catch { parsed = { raw: data } }
56
+ // Live callers keep lenient semantics (resolve on any response).
57
+ // Drain replay passes throwOnError so a non-2xx status or an
58
+ // {error} body is treated as a FAILED replay — the buffer file is
59
+ // then kept for retry instead of being unlinked (data-loss guard).
60
+ if (throwOnError && (status < 200 || status >= 300 || (parsed && parsed.error != null))) {
61
+ const detail = parsed && parsed.error != null ? String(parsed.error) : `HTTP ${status}`
62
+ reject(new Error(`memory replay rejected: ${detail}`))
63
+ return
64
+ }
65
+ resolve(parsed)
54
66
  })
55
67
  })
56
68
  req.on('error', reject)
@@ -61,6 +73,10 @@ async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
61
73
  }
62
74
 
63
75
  const BUFFER_DIR = path.join(RUNTIME_ROOT, 'memory-buffer')
76
+ const DEAD_DIR = path.join(BUFFER_DIR, 'dead')
77
+ const MAX_DRAIN_ATTEMPTS = 5
78
+ const MAX_BUFFER_FILES = 500
79
+ let _draining = false
64
80
 
65
81
  function normalizeTs(ts) {
66
82
  if (typeof ts === 'number' && Number.isFinite(ts)) {
@@ -87,16 +103,8 @@ export async function appendEntry(data) {
87
103
  return await memoryFetch('POST', '/entry', payload, 3_000)
88
104
  } catch (e) {
89
105
  process.stderr.write(`[memory-client] appendEntry failed (${e.message}) — buffering\n`)
90
- try {
91
- fs.mkdirSync(BUFFER_DIR, { recursive: true })
92
- const random = Math.random().toString(36).slice(2, 10)
93
- const bufferPath = path.join(BUFFER_DIR, `entry-${Date.now()}-${random}.json`)
94
- fs.writeFileSync(bufferPath, JSON.stringify(payload, null, 2))
95
- return { ok: false, buffered: true, path: bufferPath }
96
- } catch (bufErr) {
97
- process.stderr.write(`[memory-client] Failed to buffer entry: ${bufErr.message}\n`)
98
- return { ok: false }
99
- }
106
+ const bufferPath = bufferToDisk('entry', payload)
107
+ return bufferPath ? { ok: false, buffered: true, path: bufferPath } : { ok: false }
100
108
  }
101
109
  }
102
110
 
@@ -104,8 +112,185 @@ export async function ingestTranscript(filePath, { cwd } = {}) {
104
112
  try {
105
113
  return await memoryFetch('POST', '/ingest-transcript', { filePath, ...(cwd ? { cwd } : {}) })
106
114
  } catch (e) {
107
- process.stderr.write(`[memory-client] ingestTranscript failed: ${e.message}\n`)
108
- return { ok: false }
115
+ process.stderr.write(`[memory-client] ingestTranscript failed (${e.message}) — buffering\n`)
116
+ // Dedupe by transcriptPath: replace any already-buffered ingest for the
117
+ // same file so a re-ingest storm cannot fan out to N buffer files.
118
+ const bufferPath = bufferToDisk('ingest', { filePath, ...(cwd ? { cwd } : {}) }, { dedupeKey: filePath })
119
+ return bufferPath ? { ok: false, buffered: true, path: bufferPath } : { ok: false }
120
+ }
121
+ }
122
+
123
+ // Persist a failed request so the drainer can replay it once the memory
124
+ // service publishes its port. `kind` selects the replay endpoint on drain.
125
+ // dedupeKey (ingest): if an existing kind-* file already carries the same
126
+ // key, overwrite it in place instead of writing a new file — one buffered
127
+ // ingest per transcriptPath. Enforces MAX_BUFFER_FILES (drop-oldest+warn).
128
+ // Atomic write: stage to a unique tmp file in the SAME dir, then rename over
129
+ // the target. rename() is atomic on a single filesystem, so a concurrent
130
+ // reader/drainer never observes a half-written buffer file.
131
+ function atomicWrite(targetPath, contents) {
132
+ const tmp = `${targetPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
133
+ fs.writeFileSync(tmp, contents)
134
+ try { fs.renameSync(tmp, targetPath) }
135
+ catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
136
+ }
137
+
138
+ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
139
+ try {
140
+ fs.mkdirSync(BUFFER_DIR, { recursive: true })
141
+ if (dedupeKey != null) {
142
+ let existing = []
143
+ try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
144
+ for (const name of existing) {
145
+ if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
146
+ const full = path.join(BUFFER_DIR, name)
147
+ try {
148
+ const prev = JSON.parse(fs.readFileSync(full, 'utf8'))
149
+ if (prev && prev.filePath === dedupeKey) {
150
+ // Atomic overwrite (tmp+rename); preserves the original (oldest)
151
+ // ordering slot without exposing a half-written file to a drainer.
152
+ atomicWrite(full, JSON.stringify(payload, null, 2))
153
+ return full
154
+ }
155
+ } catch {}
156
+ }
157
+ }
158
+ enforceBufferCap()
159
+ const random = Math.random().toString(36).slice(2, 10)
160
+ // Prefix carries the replay kind; timestamp prefix keeps oldest-first
161
+ // ordering under a lexicographic sort.
162
+ const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
163
+ atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
164
+ return bufferPath
165
+ } catch (bufErr) {
166
+ process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
167
+ return null
168
+ }
169
+ }
170
+
171
+ // Move a buffer file to memory-buffer/dead/ (quarantine, never silent-drop).
172
+ // Returns true on success. On failure NEVER unlinks (no silent payload loss):
173
+ // leaves the file in place and returns false so the caller skips it this pass.
174
+ function moveToDead(name, reason) {
175
+ process.stderr.write(`[memory-client] quarantining ${name} to dead/ (${reason})\n`)
176
+ try {
177
+ fs.mkdirSync(DEAD_DIR, { recursive: true })
178
+ fs.renameSync(path.join(BUFFER_DIR, name), path.join(DEAD_DIR, name))
179
+ return true
180
+ } catch (e) {
181
+ process.stderr.write(`[memory-client] quarantine of ${name} failed (${e.message}) — leaving in place\n`)
182
+ return false
183
+ }
184
+ }
185
+
186
+ // Cap the buffer directory: when at/over MAX_BUFFER_FILES, quarantine oldest
187
+ // files (lexicographic = ts-prefixed = oldest-first) to dead/ — never silently
188
+ // destroy data (MED: cap must preserve for triage, same as poison path).
189
+ function enforceBufferCap() {
190
+ let files
191
+ try {
192
+ files = fs.readdirSync(BUFFER_DIR)
193
+ .filter(f => (f.startsWith('entry-') || f.startsWith('ingest-')) && f.endsWith('.json'))
194
+ .sort()
195
+ } catch { return }
196
+ let over = files.length - (MAX_BUFFER_FILES - 1)
197
+ for (let i = 0; i < files.length && over > 0; i++, over--) {
198
+ moveToDead(files[i], `buffer cap ${MAX_BUFFER_FILES} exceeded — oldest`)
199
+ }
200
+ }
201
+
202
+ // Replay buffered entry-*/ingest-* files once the memory port is live.
203
+ // Oldest-first (filename carries a ms timestamp), dedupe-safe (each file is
204
+ // deleted only after a 2xx replay — memoryFetch(throwOnError) rejects on
205
+ // non-2xx/{error}, so a rejected replay keeps the file for retry, no data
206
+ // loss). Retry count is PERSISTED in the filename suffix (`.rN`) so process
207
+ // restarts don't reset the poison cap; after MAX_DRAIN_ATTEMPTS the file is
208
+ // MOVED to memory-buffer/dead/ (not deleted, not left blocking the queue).
209
+ // Reentrancy-guarded.
210
+ //
211
+ // Attempt count lives in the name: `<kind>-<ts>-<rnd>.json` (attempt 0) or
212
+ // `<kind>-<ts>-<rnd>.rN.json` (N prior failures). Parsed/rewritten via rename.
213
+ function parseRetry(name) {
214
+ const m = name.match(/\.r(\d+)\.json$/)
215
+ return m ? Number(m[1]) : 0
216
+ }
217
+ function retryName(name, n) {
218
+ const base = name.replace(/\.r\d+\.json$/, '.json').replace(/\.json$/, '')
219
+ return `${base}.r${n}.json`
220
+ }
221
+ export async function drainBuffer() {
222
+ if (_draining) return { ok: true, skipped: 'in-progress' }
223
+ const port = await getMemoryPort()
224
+ if (!port) return { ok: false, reason: 'no-port' }
225
+ _draining = true
226
+ let drained = 0
227
+ let failed = 0
228
+ // Files that could not be advanced this pass (rename lock/EPERM). Skipped so
229
+ // an un-rewritable file can't wedge the oldest-first queue forever; retried
230
+ // on the next drain (their on-disk .rN is unchanged, so the cap still holds).
231
+ const skipThisPass = new Set()
232
+ try {
233
+ let files
234
+ try {
235
+ files = fs.readdirSync(BUFFER_DIR)
236
+ } catch { return { ok: true, drained: 0 } }
237
+ files = files
238
+ .filter(f => (f.startsWith('entry-') || f.startsWith('ingest-')) && f.endsWith('.json'))
239
+ .sort() // ts-prefixed name => oldest-first
240
+ for (const name of files) {
241
+ if (skipThisPass.has(name)) continue
242
+ const bufferPath = path.join(BUFFER_DIR, name)
243
+ let payload
244
+ try {
245
+ payload = JSON.parse(fs.readFileSync(bufferPath, 'utf8'))
246
+ } catch {
247
+ // Unparseable/corrupt buffer file — a partial write may be in flight,
248
+ // or it may be genuinely corrupt. Do NOT unlink (silent data loss):
249
+ // quarantine to dead/ for triage and move on. If the quarantine move
250
+ // itself fails, skip it for this pass so it can't block oldest-first.
251
+ if (!moveToDead(name, 'unparseable buffer file')) skipThisPass.add(name)
252
+ continue
253
+ }
254
+ const endpoint = name.startsWith('ingest-') ? '/ingest-transcript' : '/entry'
255
+ try {
256
+ // throwOnError: non-2xx status or an {error} body REJECTS, so the
257
+ // file is kept/aged for retry instead of unlinked (HIGH: data loss).
258
+ await memoryFetch('POST', endpoint, payload, 10_000, { throwOnError: true })
259
+ try { fs.unlinkSync(bufferPath) } catch {}
260
+ drained++
261
+ } catch (e) {
262
+ const attempts = parseRetry(name) + 1
263
+ failed++
264
+ if (attempts >= MAX_DRAIN_ATTEMPTS) {
265
+ // Quarantine, don't drop: move to dead/ so a poison record neither
266
+ // wedges the queue nor silently vanishes (recoverable for triage).
267
+ moveToDead(name, `${attempts} failed replays: ${e.message}`)
268
+ } else {
269
+ // Persist the incremented attempt count in the filename so a
270
+ // restart resumes the poison cap instead of resetting it. If the
271
+ // rename fails (EPERM/lock), leave the file as-is but skip it for
272
+ // THIS pass so it can't block the oldest-first queue forever — the
273
+ // next drain re-reads its (unchanged) .rN and retries.
274
+ try {
275
+ fs.renameSync(bufferPath, path.join(BUFFER_DIR, retryName(name, attempts)))
276
+ } catch {
277
+ // Rename lock/EPERM: don't break the whole pass on a file we
278
+ // couldn't even age — skip it and CONTINUE so later buffered
279
+ // files still drain. (Strict oldest-first yields to progress
280
+ // ONLY in this rename-failure case; a normal replay failure
281
+ // below still breaks, since the service is likely down.)
282
+ skipThisPass.add(name)
283
+ continue
284
+ }
285
+ }
286
+ // Stop the pass on first failure: the service is likely still down,
287
+ // so hammering the rest wastes timeouts. Next drain retries in order.
288
+ break
289
+ }
290
+ }
291
+ } finally {
292
+ _draining = false
109
293
  }
294
+ return { ok: failed === 0, drained, failed }
110
295
  }
111
296
 
@@ -152,6 +152,17 @@ function touchDaemonIdleTimer(reason = 'activity') {
152
152
  function advertiseMemoryPort(boundPort, attempt = 0) {
153
153
  if (!Number.isFinite(boundPort) || boundPort <= 0) return
154
154
  _currentAdvertisedPort = boundPort
155
+ if (!_periodicAdvertiseInstalled) {
156
+ _periodicAdvertiseInstalled = true
157
+ _periodicAdvertiseTimer = setInterval(() => {
158
+ try {
159
+ if (_currentAdvertisedPort != null) {
160
+ advertiseMemoryPort(_currentAdvertisedPort)
161
+ }
162
+ } catch {}
163
+ }, 30_000)
164
+ _periodicAdvertiseTimer.unref?.()
165
+ }
155
166
  const dir = RUNTIME_ROOT
156
167
  const file = path.join(dir, 'active-instance.json')
157
168
  try {
@@ -177,17 +188,6 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
177
188
  }
178
189
  return next
179
190
  }, { compact: true, fsyncDir: true, renameFallback: 'truncate' })
180
- if (!_periodicAdvertiseInstalled) {
181
- _periodicAdvertiseInstalled = true
182
- _periodicAdvertiseTimer = setInterval(() => {
183
- try {
184
- if (_currentAdvertisedPort != null) {
185
- advertiseMemoryPort(_currentAdvertisedPort)
186
- }
187
- } catch {}
188
- }, 30_000)
189
- _periodicAdvertiseTimer.unref?.()
190
- }
191
191
  } catch (e) {
192
192
  const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
193
193
  if (transient && attempt < 3) {
@@ -580,6 +580,7 @@ const _cycleScheduler = createCycleScheduler({
580
580
  const _startCycle1Run = _cycleScheduler.startCycle1Run
581
581
  const _awaitCycle1Run = _cycleScheduler.awaitCycle1Run
582
582
  const _finalizeCycle2Run = _cycleScheduler.finalizeCycle2Run
583
+ const _finalizeCycle3Run = _cycleScheduler.finalizeCycle3Run
583
584
 
584
585
  // Transcript watcher lifecycle stays in the facade (owns _transcriptIngest);
585
586
  // the cycle tick loop start/stop is delegated to the scheduler.
@@ -769,6 +770,10 @@ async function _handleMemCycle3(args, config, signal) {
769
770
  }
770
771
  const result = await runCycle3(db, config || {}, DATA_DIR, c3Options)
771
772
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
773
+ // Stamp cycle3 success: the MCP path bypasses the scheduler's coalesced
774
+ // onCoalescedSuccess, so without this last_success_at stayed 0 despite
775
+ // successful runs.
776
+ await _finalizeCycle3Run(result)
772
777
  const parts = ['reviewed', 'kept', 'updated', 'merged', 'deleted']
773
778
  .map(k => `${k}=${result?.[k] || 0}`)
774
779
  if (result?.proposed) {
@@ -1523,11 +1528,11 @@ async function buildSessionCoreMemoryPayload(cwd) {
1523
1528
  ORDER BY score DESC, last_seen_at DESC
1524
1529
  `, projectId !== null ? [projectId] : [])).rows
1525
1530
  const commonRows = (await db.query(
1526
- `SELECT summary FROM core_entries WHERE project_id IS NULL ORDER BY id ASC`
1531
+ `SELECT summary FROM core_entries WHERE project_id IS NULL AND (status IS NULL OR status = 'active') ORDER BY id ASC`
1527
1532
  )).rows
1528
1533
  const scopedRows = projectId !== null
1529
1534
  ? (await db.query(
1530
- `SELECT summary FROM core_entries WHERE project_id = $1 ORDER BY id ASC`,
1535
+ `SELECT summary FROM core_entries WHERE project_id = $1 AND (status IS NULL OR status = 'active') ORDER BY id ASC`,
1531
1536
  [projectId]
1532
1537
  )).rows
1533
1538
  : []
@@ -63,7 +63,9 @@ function throwIfAborted(signal) {
63
63
  async function _backfillNullEmbeddings(db, options = {}) {
64
64
  const signal = options?.signal
65
65
  throwIfAborted(signal)
66
- const r = await db.query(`SELECT id, element, summary FROM core_entries WHERE embedding IS NULL`)
66
+ // Only refill live cores archiveCore intentionally nulls the embedding to
67
+ // drop archived rows from recall; refilling them would resurrect them.
68
+ const r = await db.query(`SELECT id, element, summary FROM core_entries WHERE embedding IS NULL AND (status IS NULL OR status = 'active')`)
67
69
  if (r.rows.length === 0) return 0
68
70
  let filled = 0
69
71
  for (const row of r.rows) {
@@ -144,6 +146,7 @@ async function _findTopKCore(db, projectId, embedding, excludeId, { forUpdate =
144
146
  FROM core_entries
145
147
  WHERE embedding IS NOT NULL
146
148
  AND project_id IS NOT DISTINCT FROM $2
149
+ AND (status IS NULL OR status = 'active')
147
150
  ${exclusion}
148
151
  ORDER BY embedding <=> $1::halfvec
149
152
  LIMIT ${CORE_DEDUP_TOP_K}${forUpdate ? ' FOR UPDATE' : ''}`
@@ -188,15 +191,18 @@ async function _llmJudgeMerge(existing, incoming) {
188
191
  export async function listCore(dataDir, projectId = null) {
189
192
  const db = _getDb(dataDir)
190
193
  const cols = `id, element, summary, category, project_id, created_at, updated_at`
194
+ // Only live cores enter recall/review — archived (superseded) rows are
195
+ // retired but retained for audit. Legacy NULL status = active.
196
+ const live = `(status IS NULL OR status = 'active')`
191
197
  if (projectId === '*') {
192
- const r = await db.query(`SELECT ${cols} FROM core_entries ORDER BY project_id NULLS FIRST, id ASC`)
198
+ const r = await db.query(`SELECT ${cols} FROM core_entries WHERE ${live} ORDER BY project_id NULLS FIRST, id ASC`)
193
199
  return r.rows
194
200
  }
195
201
  if (projectId === null) {
196
- const r = await db.query(`SELECT ${cols} FROM core_entries WHERE project_id IS NULL ORDER BY id ASC`)
202
+ const r = await db.query(`SELECT ${cols} FROM core_entries WHERE project_id IS NULL AND ${live} ORDER BY id ASC`)
197
203
  return r.rows
198
204
  }
199
- const r = await db.query(`SELECT ${cols} FROM core_entries WHERE project_id = $1 ORDER BY id ASC`, [projectId])
205
+ const r = await db.query(`SELECT ${cols} FROM core_entries WHERE project_id = $1 AND ${live} ORDER BY id ASC`, [projectId])
200
206
  return r.rows
201
207
  }
202
208
 
@@ -242,14 +248,37 @@ export async function addCore(dataDir, { element, summary, category }, projectId
242
248
  }
243
249
  let r
244
250
  try {
251
+ // Savepoint so a unique-collision doesn't abort the whole tx — we recover
252
+ // by reviving an archived row on the same connection below.
253
+ await client.query('SAVEPOINT ins')
245
254
  r = await client.query(
246
255
  `INSERT INTO core_entries(element, summary, category, project_id, embedding, created_at, updated_at)
247
256
  VALUES ($1, $2, $3, $4, $5::halfvec, $6, $7)
248
257
  RETURNING id, element, summary, category, project_id, created_at, updated_at`,
249
258
  [el, sm, cat, projectId, embedding ? embeddingToSql(embedding) : null, now, now],
250
259
  )
260
+ await client.query('RELEASE SAVEPOINT ins')
251
261
  } catch (err) {
252
262
  if (err.code === '23505') {
263
+ await client.query('ROLLBACK TO SAVEPOINT ins')
264
+ // Unique (project_id, element) collision. If the colliding row is an
265
+ // archived (superseded) row, the fact is being re-asserted → revive it
266
+ // in place: flip back to active, clear archived_at, overwrite content.
267
+ // Avoids a partial-index migration. An active collision is a genuine
268
+ // duplicate → surface the error.
269
+ const revived = await client.query(
270
+ `UPDATE core_entries
271
+ SET summary = $1, category = $2, embedding = $3::halfvec,
272
+ status = 'active', archived_at = NULL, updated_at = $4
273
+ WHERE project_id IS NOT DISTINCT FROM $5 AND element = $6
274
+ AND status = 'archived'
275
+ RETURNING id, element, summary, category, project_id, created_at, updated_at`,
276
+ [sm, cat, embedding ? embeddingToSql(embedding) : null, now, projectId, el],
277
+ )
278
+ if (revived.rows.length > 0) {
279
+ await client.query('COMMIT')
280
+ return { ...revived.rows[0], revived_from_archived: true }
281
+ }
253
282
  throw new Error(`core entry already exists: project=${projectId ?? 'COMMON'} element=${JSON.stringify(el.slice(0, 200))}`)
254
283
  }
255
284
  throw err
@@ -339,6 +368,80 @@ export async function deleteCore(dataDir, id) {
339
368
  return r.rows[0]
340
369
  }
341
370
 
371
+ // Archive (retire without physical removal) a core entry whose fact was
372
+ // superseded by a newer active fact. Non-destructive: flips status to
373
+ // 'archived' + stamps archived_at so the row can be recovered/audited, and
374
+ // drops it from the recall/review pool. Safe-by-default: unlike deleteCore this
375
+ // is reversible and runs in conservative mode. Relies on the nullable status/
376
+ // archived_at columns added in ensureCurrentSchemaExtensions (no migration
377
+ // beyond those additive columns).
378
+ //
379
+ // Takes the same core:${project} advisory lock as addCore/editCore and
380
+ // re-checks the row inside it, so a concurrent addCore merge/overwrite that
381
+ // changed the fact after cycle3 read it is NOT clobbered. `expect` carries the
382
+ // element/summary cycle3 reviewed; if the live row drifted from it the archive
383
+ // is skipped (returns { skipped:true, reason:'content drift' }).
384
+ export async function archiveCore(dataDir, id, expect = null) {
385
+ const numId = Number(id)
386
+ if (!Number.isInteger(numId) || numId <= 0) throw new Error('integer id > 0 required')
387
+ const db = _getDb(dataDir)
388
+ const now = Date.now()
389
+ const client = await checkedConnect(db._pool, 'memory')
390
+ try {
391
+ await client.query('BEGIN')
392
+ await client.query(`SET LOCAL lock_timeout = '5s'`)
393
+ // Lock ORDER must match addCore/editCore: advisory (pool) FIRST, then the
394
+ // row FOR UPDATE — otherwise same-row concurrency deadlocks. The advisory
395
+ // key needs project_id, so read it first WITHOUT a row lock (plain SELECT,
396
+ // no FOR UPDATE → acquires no row lock, can't invert ordering), take the
397
+ // pool advisory lock, THEN FOR UPDATE the row and re-validate under it.
398
+ const pre = (await client.query(
399
+ `SELECT project_id FROM core_entries WHERE id = $1`,
400
+ [numId],
401
+ )).rows[0]
402
+ if (!pre) throw new Error(`no entry with id=${numId}`)
403
+ const poolKey = `core:${pre.project_id == null ? 'COMMON' : pre.project_id}`
404
+ await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [poolKey])
405
+ // Now take the row lock under the pool lock and re-read — a concurrent
406
+ // addCore holding the same advisory lock may have merged/overwritten or
407
+ // moved the row's project_id between our pre-read and the lock.
408
+ const locked = (await client.query(
409
+ `SELECT id, element, summary, project_id, status FROM core_entries WHERE id = $1 FOR UPDATE`,
410
+ [numId],
411
+ )).rows[0]
412
+ if (!locked || !(locked.status == null || locked.status === 'active')) {
413
+ await client.query('ROLLBACK')
414
+ return { id: numId, skipped: true, reason: 'concurrently archived/removed' }
415
+ }
416
+ // If project_id moved pools between pre-read and lock, our advisory lock is
417
+ // on the wrong pool → bail rather than archive under a mismatched lock.
418
+ if ((locked.project_id ?? null) !== (pre.project_id ?? null)) {
419
+ await client.query('ROLLBACK')
420
+ return { id: numId, skipped: true, reason: 'pool changed under lock' }
421
+ }
422
+ if (expect && (String(expect.element ?? '') !== String(locked.element ?? '') ||
423
+ String(expect.summary ?? '') !== String(locked.summary ?? ''))) {
424
+ await client.query('ROLLBACK')
425
+ return { id: numId, skipped: true, reason: 'content drift' }
426
+ }
427
+ const r = await client.query(
428
+ `UPDATE core_entries
429
+ SET status = 'archived', archived_at = $1, updated_at = $2, embedding = NULL
430
+ WHERE id = $3 AND (status IS NULL OR status = 'active')
431
+ RETURNING *`,
432
+ [now, now, numId],
433
+ )
434
+ await client.query('COMMIT')
435
+ if (r.rows.length === 0) return { id: numId, skipped: true, reason: 'concurrently archived/removed' }
436
+ return r.rows[0]
437
+ } catch (err) {
438
+ try { await client.query('ROLLBACK') } catch {}
439
+ throw err
440
+ } finally {
441
+ client.release()
442
+ }
443
+ }
444
+
342
445
  // ─── Core-candidate promotion pipeline (proposal mode) ───────────────────────
343
446
  //
344
447
  // nominateCoreCandidates flags strong active entries as core-memory
@@ -446,6 +549,7 @@ export async function nominateCoreCandidates(dataDir, options = {}) {
446
549
  SELECT inner_c.embedding
447
550
  FROM core_entries inner_c
448
551
  WHERE inner_c.embedding IS NOT NULL
552
+ AND (inner_c.status IS NULL OR inner_c.status = 'active')
449
553
  AND (inner_c.project_id IS NULL OR inner_c.project_id IS NOT DISTINCT FROM e.project_id)
450
554
  ORDER BY inner_c.embedding <=> e.embedding
451
555
  LIMIT 1