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.
- package/package.json +1 -1
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +11 -7
- package/src/defaults/cycle3-review-prompt.md +14 -0
- package/src/defaults/memory-promote-prompt.md +9 -9
- package/src/lib/rules-builder.cjs +15 -11
- package/src/mixdog-session-runtime.mjs +10 -0
- package/src/rules/agent/00-common.md +5 -17
- package/src/rules/agent/00-core.md +21 -0
- package/src/rules/lead/lead-brief.md +7 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
- package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
- package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
- package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
- package/src/runtime/channels/index.mjs +28 -1
- package/src/runtime/channels/lib/memory-client.mjs +200 -15
- package/src/runtime/memory/index.mjs +18 -13
- package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
- package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
- package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/runtime/memory/lib/memory.mjs +7 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/tui/dist/index.mjs +203 -10
- package/src/tui/engine/tui-steering-persist.mjs +175 -0
- package/src/tui/engine.mjs +46 -2
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +2 -0
- package/src/workflows/sequential/WORKFLOW.md +2 -0
- 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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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
|