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