mixdog 0.9.35 → 0.9.37
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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +15 -7
- package/src/rules/lead/01-general.md +4 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +473 -155
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/context-state.mjs +13 -4
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -255,7 +255,11 @@ export function _formatCalleeRow(row) {
|
|
|
255
255
|
}
|
|
256
256
|
export async function _prewarmReferenceSourceText(graph, symbol, language) {
|
|
257
257
|
const candidateNodes = _lookupCandidateNodes(graph, symbol, language);
|
|
258
|
-
|
|
258
|
+
// Return the resolved candidate set so the immediately-following
|
|
259
|
+
// _cheapReferenceSearch (references/callers dispatch) can reuse it instead
|
|
260
|
+
// of recomputing _lookupCandidateNodes for the same (symbol, language) —
|
|
261
|
+
// which on a token-index miss is a full-graph scan run twice per symbol.
|
|
262
|
+
if (!candidateNodes.length) return candidateNodes;
|
|
259
263
|
const uncached = [];
|
|
260
264
|
for (const node of candidateNodes) {
|
|
261
265
|
const cached = graph._sourceTextCache?.get(node.rel);
|
|
@@ -263,7 +267,7 @@ export async function _prewarmReferenceSourceText(graph, symbol, language) {
|
|
|
263
267
|
uncached.push(node);
|
|
264
268
|
}
|
|
265
269
|
}
|
|
266
|
-
if (uncached.length === 0) return;
|
|
270
|
+
if (uncached.length === 0) return candidateNodes;
|
|
267
271
|
const { readFile } = await import('fs/promises');
|
|
268
272
|
const concurrency = 64;
|
|
269
273
|
let next = 0;
|
|
@@ -280,9 +284,10 @@ export async function _prewarmReferenceSourceText(graph, symbol, language) {
|
|
|
280
284
|
}
|
|
281
285
|
const workerCount = Math.min(Math.max(1, concurrency), uncached.length);
|
|
282
286
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
287
|
+
return candidateNodes;
|
|
283
288
|
}
|
|
284
289
|
|
|
285
|
-
export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, limit = null, fileRel = null, scopeRelPrefix = null } = {}) {
|
|
290
|
+
export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, limit = null, fileRel = null, scopeRelPrefix = null, nodes = null } = {}) {
|
|
286
291
|
const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
287
292
|
if (!escaped) return '(no references)';
|
|
288
293
|
const cacheKey = `${language || '*'}|${symbol}|${Number.isFinite(limit) && limit > 0 ? String(Math.floor(limit)) : 'd'}|${fileRel || '*'}|${scopeRelPrefix || '*'}`;
|
|
@@ -291,7 +296,11 @@ export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, lim
|
|
|
291
296
|
return cached;
|
|
292
297
|
}
|
|
293
298
|
const lines = [];
|
|
294
|
-
|
|
299
|
+
// Reuse the caller's precomputed candidate set (from
|
|
300
|
+
// _prewarmReferenceSourceText) when provided — same (symbol, language) so
|
|
301
|
+
// the node set is identical; the fileRel/scopeRelPrefix filters below still
|
|
302
|
+
// apply, keeping the result byte-for-byte unchanged.
|
|
303
|
+
let candidateNodes = Array.isArray(nodes) ? nodes : _lookupCandidateNodes(graph, symbol, language);
|
|
295
304
|
if (fileRel) candidateNodes = candidateNodes.filter((node) => node.rel === fileRel);
|
|
296
305
|
if (scopeRelPrefix) candidateNodes = candidateNodes.filter((node) => node.rel === scopeRelPrefix.slice(0, -1) || node.rel.startsWith(scopeRelPrefix));
|
|
297
306
|
const ENV_CAP = Math.max(1, Number(process.env.REFERENCE_HIT_CAP) || 200);
|
|
@@ -629,16 +638,57 @@ function _formatSearchSymbolRow(name, hit) {
|
|
|
629
638
|
return `${name}\t${loc}\t${next}`;
|
|
630
639
|
}
|
|
631
640
|
|
|
641
|
+
const KEYWORD_SEARCH_CACHE_MAX_ENTRIES = Math.max(
|
|
642
|
+
16,
|
|
643
|
+
Math.floor(Number(process.env.CODE_GRAPH_KEYWORD_SEARCH_CACHE_MAX_ENTRIES) || 128),
|
|
644
|
+
);
|
|
645
|
+
const KEYWORD_SEARCH_CACHE_MAX_BYTES = Math.max(
|
|
646
|
+
64 * 1024,
|
|
647
|
+
Math.floor(Number(process.env.CODE_GRAPH_KEYWORD_SEARCH_CACHE_MAX_BYTES) || (1024 * 1024)),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
function _keywordSearchLanguageCacheKey(language) {
|
|
651
|
+
return language == null ? '<none>' : `lang:${String(language)}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function _setKeywordSearchCache(graph, cacheKey, value) {
|
|
655
|
+
const cache = graph?._keywordSearchCache;
|
|
656
|
+
if (!(cache instanceof Map)) return value;
|
|
657
|
+
const valueBytes = Buffer.byteLength(String(value || ''), 'utf8');
|
|
658
|
+
if (valueBytes > KEYWORD_SEARCH_CACHE_MAX_BYTES) return value;
|
|
659
|
+
if (cache.has(cacheKey)) cache.delete(cacheKey);
|
|
660
|
+
cache.set(cacheKey, value);
|
|
661
|
+
let totalBytes = 0;
|
|
662
|
+
for (const memo of cache.values()) totalBytes += Buffer.byteLength(String(memo || ''), 'utf8');
|
|
663
|
+
while (cache.size > KEYWORD_SEARCH_CACHE_MAX_ENTRIES || totalBytes > KEYWORD_SEARCH_CACHE_MAX_BYTES) {
|
|
664
|
+
const oldest = cache.keys().next().value;
|
|
665
|
+
if (oldest === undefined) break;
|
|
666
|
+
const oldValue = cache.get(oldest);
|
|
667
|
+
totalBytes -= Buffer.byteLength(String(oldValue || ''), 'utf8');
|
|
668
|
+
cache.delete(oldest);
|
|
669
|
+
}
|
|
670
|
+
return value;
|
|
671
|
+
}
|
|
672
|
+
|
|
632
673
|
export function _searchSymbolsByKeyword(graph, keyword, cwd, { language = null, limit = 30 } = {}) {
|
|
633
674
|
const clean = String(keyword || '').trim();
|
|
634
675
|
if (!clean) return '(no keyword)';
|
|
635
676
|
const cap = Math.max(1, Math.min(100, Math.floor(Number(limit) || 30)));
|
|
677
|
+
// Memoize the full formatted output per (language, keyword, cap). Repeated
|
|
678
|
+
// symbol_search scans (e.g. batched keywords) otherwise re-walk every graph
|
|
679
|
+
// node — native + cheap symbol collection — for each keyword. The cached
|
|
680
|
+
// string already embeds the truncated WARN line, so truncated/incomplete
|
|
681
|
+
// semantics are preserved byte-for-byte on a cache hit.
|
|
682
|
+
const cacheKey = JSON.stringify([_keywordSearchLanguageCacheKey(language), clean, cap]);
|
|
683
|
+
const cached = graph?._keywordSearchCache?.get(cacheKey);
|
|
684
|
+
if (typeof cached === 'string') return cached;
|
|
685
|
+
const _memo = (s) => _setKeywordSearchCache(graph, cacheKey, s);
|
|
636
686
|
const nativeEntries = _collectNativeKeywordSymbolEntries(graph, clean, { language });
|
|
637
687
|
const cheapEntries = _collectCheapKeywordSymbolEntries(graph, clean, { language });
|
|
638
688
|
const entries = [...nativeEntries, ...cheapEntries];
|
|
639
689
|
if (!entries.length) {
|
|
640
690
|
const nodeCount = graph?.nodes?.size ?? 0;
|
|
641
|
-
return `(no symbol keyword matches in cwd=${cwd})\ngraph: nodes=${nodeCount}${language ? `, language=${language}` : ''}
|
|
691
|
+
return _memo(`(no symbol keyword matches in cwd=${cwd})\ngraph: nodes=${nodeCount}${language ? `, language=${language}` : ''}`);
|
|
642
692
|
}
|
|
643
693
|
entries.sort((a, b) => {
|
|
644
694
|
const rank = Number(b.resolved) - Number(a.resolved);
|
|
@@ -669,7 +719,7 @@ export function _searchSymbolsByKeyword(graph, keyword, cwd, { language = null,
|
|
|
669
719
|
if (graph?.truncated) {
|
|
670
720
|
lines.push(`WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — matches may be incomplete. Re-run with a narrower cwd.`);
|
|
671
721
|
}
|
|
672
|
-
return lines.join('\n');
|
|
722
|
+
return _memo(lines.join('\n'));
|
|
673
723
|
}
|
|
674
724
|
|
|
675
725
|
function _parseReferenceEntries(referenceText) {
|
|
@@ -413,6 +413,11 @@ export class ExecResult {
|
|
|
413
413
|
this.taskId = opts.taskId;
|
|
414
414
|
this.partialOutput = opts.partialOutput === true;
|
|
415
415
|
this.outputCaptureError = opts.outputCaptureError || null;
|
|
416
|
+
// Distinguish a shell tool/control-plane failure (spawn/preflight/capture)
|
|
417
|
+
// from a command process failure (non-zero exit/signal/timeout). The
|
|
418
|
+
// renderer in builtin/bash-tool.mjs turns this into a model-visible marker.
|
|
419
|
+
this.failurePhase = opts.failurePhase || null;
|
|
420
|
+
this.failureReason = opts.failureReason || null;
|
|
416
421
|
// Auto-background transition (CC startBackgrounding analogue). When a
|
|
417
422
|
// foreground command outlives autoBackgroundMs the call settles with
|
|
418
423
|
// backgrounded:true + the jobId for manual task control. The
|
|
@@ -558,6 +563,8 @@ export function execShellCommand({
|
|
|
558
563
|
timedOut: false,
|
|
559
564
|
killed: false,
|
|
560
565
|
taskId,
|
|
566
|
+
failurePhase: 'tool',
|
|
567
|
+
failureReason: 'preflight failed',
|
|
561
568
|
}),
|
|
562
569
|
);
|
|
563
570
|
return;
|
|
@@ -605,6 +612,8 @@ export function execShellCommand({
|
|
|
605
612
|
timedOut: false,
|
|
606
613
|
killed: false,
|
|
607
614
|
taskId,
|
|
615
|
+
failurePhase: 'tool',
|
|
616
|
+
failureReason: 'spawn failed',
|
|
608
617
|
}),
|
|
609
618
|
);
|
|
610
619
|
return;
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
RUNTIME_ROOT
|
|
53
53
|
} from "./runtime-paths.mjs";
|
|
54
54
|
import { getDiscordToken } from "./config.mjs";
|
|
55
|
+
import { invalidateConfigReadCache } from "../../shared/config.mjs";
|
|
55
56
|
import { bootProfile, localTimestamp } from "./boot-profile.mjs";
|
|
56
57
|
import {
|
|
57
58
|
isChannelsDegraded,
|
|
@@ -720,6 +721,10 @@ async function start() {
|
|
|
720
721
|
if (!_configWatcher) {
|
|
721
722
|
try {
|
|
722
723
|
_configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
|
|
724
|
+
// Cross-process edit landed on disk; drop this process's short-TTL raw
|
|
725
|
+
// config cache synchronously so the debounced reload (and any readAll
|
|
726
|
+
// in between) sees the fresh file immediately, not up to TTL stale.
|
|
727
|
+
invalidateConfigReadCache();
|
|
723
728
|
if (_reloadDebounce) clearTimeout(_reloadDebounce);
|
|
724
729
|
_reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
|
|
725
730
|
});
|
|
@@ -442,6 +442,110 @@ export async function archiveCore(dataDir, id, expect = null) {
|
|
|
442
442
|
}
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
// Non-destructive project reclassification: move a mis-scoped core entry to the
|
|
446
|
+
// correct pool (a project slug, or COMMON when newProjectId is null). Only the
|
|
447
|
+
// project_id + updated_at change — no delete, and no re-embed (the embedding is
|
|
448
|
+
// derived from element/summary text, which is pool-independent). Takes BOTH the
|
|
449
|
+
// source and target pool advisory locks in a deterministic (sorted) order so a
|
|
450
|
+
// move can't deadlock with a concurrent add/edit/archive on either pool, then
|
|
451
|
+
// FOR UPDATEs the row and re-validates under the lock. `expect` carries the
|
|
452
|
+
// element/summary cycle3 reviewed; if the live row drifted, the move is skipped
|
|
453
|
+
// ({ skipped, reason }). A live (project_id, element) collision in the target
|
|
454
|
+
// pool means the fact already exists there → skipped, so the caller holds it for
|
|
455
|
+
// manual resolution instead of clobbering the existing row.
|
|
456
|
+
export async function reclassifyCore(dataDir, id, newProjectId, expect = null) {
|
|
457
|
+
const numId = Number(id)
|
|
458
|
+
if (!Number.isInteger(numId) || numId <= 0) throw new Error('integer id > 0 required')
|
|
459
|
+
const targetPid = newProjectId == null ? null : (String(newProjectId).trim() || null)
|
|
460
|
+
const db = _getDb(dataDir)
|
|
461
|
+
const now = Date.now()
|
|
462
|
+
const client = await checkedConnect(db._pool, 'memory')
|
|
463
|
+
try {
|
|
464
|
+
await client.query('BEGIN')
|
|
465
|
+
await client.query(`SET LOCAL lock_timeout = '5s'`)
|
|
466
|
+
// Read the current pool WITHOUT a row lock first (no FOR UPDATE → acquires
|
|
467
|
+
// no row lock, can't invert ordering) so the advisory locks are taken first.
|
|
468
|
+
const pre = (await client.query(`SELECT project_id FROM core_entries WHERE id = $1`, [numId])).rows[0]
|
|
469
|
+
if (!pre) throw new Error(`no entry with id=${numId}`)
|
|
470
|
+
const curPid = pre.project_id ?? null
|
|
471
|
+
if ((curPid ?? null) === (targetPid ?? null)) {
|
|
472
|
+
await client.query('ROLLBACK')
|
|
473
|
+
return { id: numId, skipped: true, reason: 'already in target pool' }
|
|
474
|
+
}
|
|
475
|
+
// Lock BOTH pools, sorted, so a move in the opposite direction can't deadlock.
|
|
476
|
+
const keys = [...new Set([
|
|
477
|
+
`core:${curPid == null ? 'COMMON' : curPid}`,
|
|
478
|
+
`core:${targetPid == null ? 'COMMON' : targetPid}`,
|
|
479
|
+
])].sort()
|
|
480
|
+
for (const k of keys) await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [k])
|
|
481
|
+
const locked = (await client.query(
|
|
482
|
+
`SELECT id, element, summary, project_id, status FROM core_entries WHERE id = $1 FOR UPDATE`,
|
|
483
|
+
[numId],
|
|
484
|
+
)).rows[0]
|
|
485
|
+
if (!locked || !(locked.status == null || locked.status === 'active')) {
|
|
486
|
+
await client.query('ROLLBACK')
|
|
487
|
+
return { id: numId, skipped: true, reason: 'concurrently archived/removed' }
|
|
488
|
+
}
|
|
489
|
+
if ((locked.project_id ?? null) !== curPid) {
|
|
490
|
+
await client.query('ROLLBACK')
|
|
491
|
+
return { id: numId, skipped: true, reason: 'pool changed under lock' }
|
|
492
|
+
}
|
|
493
|
+
// Reviewed-source guard: cycle3 decided this move against the pool the row
|
|
494
|
+
// was in AT REVIEW time. If the row was reclassified into a THIRD pool
|
|
495
|
+
// between review and now, the in-tx pre-read above sees that new pool (so
|
|
496
|
+
// the pool-changed-under-lock check passes) but the move is stale → skip.
|
|
497
|
+
// `expect.sourceProjectId` (null = COMMON) carries the reviewed pool.
|
|
498
|
+
if (expect && 'sourceProjectId' in expect) {
|
|
499
|
+
const reviewedPid = expect.sourceProjectId == null ? null : (String(expect.sourceProjectId).trim() || null)
|
|
500
|
+
if ((locked.project_id ?? null) !== reviewedPid) {
|
|
501
|
+
await client.query('ROLLBACK')
|
|
502
|
+
return { id: numId, skipped: true, reason: 'source pool drift' }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (expect && (String(expect.element ?? '') !== String(locked.element ?? '') ||
|
|
506
|
+
String(expect.summary ?? '') !== String(locked.summary ?? ''))) {
|
|
507
|
+
await client.query('ROLLBACK')
|
|
508
|
+
return { id: numId, skipped: true, reason: 'content drift' }
|
|
509
|
+
}
|
|
510
|
+
// Unique (project_id, element) guard: a live row with this element already in
|
|
511
|
+
// the target pool means the fact is present there → don't clobber it.
|
|
512
|
+
const dup = (await client.query(
|
|
513
|
+
`SELECT id FROM core_entries
|
|
514
|
+
WHERE project_id IS NOT DISTINCT FROM $1 AND element = $2
|
|
515
|
+
AND (status IS NULL OR status = 'active') AND id != $3
|
|
516
|
+
LIMIT 1`,
|
|
517
|
+
[targetPid, locked.element, numId],
|
|
518
|
+
)).rows[0]
|
|
519
|
+
if (dup) {
|
|
520
|
+
await client.query('ROLLBACK')
|
|
521
|
+
return { id: numId, skipped: true, reason: `target pool already has element (id=${dup.id})` }
|
|
522
|
+
}
|
|
523
|
+
let r
|
|
524
|
+
try {
|
|
525
|
+
await client.query('SAVEPOINT mv')
|
|
526
|
+
r = await client.query(
|
|
527
|
+
`UPDATE core_entries SET project_id = $1, updated_at = $2 WHERE id = $3
|
|
528
|
+
RETURNING id, element, summary, category, project_id, created_at, updated_at`,
|
|
529
|
+
[targetPid, now, numId],
|
|
530
|
+
)
|
|
531
|
+
await client.query('RELEASE SAVEPOINT mv')
|
|
532
|
+
} catch (err) {
|
|
533
|
+
if (err.code === '23505') {
|
|
534
|
+
await client.query('ROLLBACK')
|
|
535
|
+
return { id: numId, skipped: true, reason: 'unique collision in target pool' }
|
|
536
|
+
}
|
|
537
|
+
throw err
|
|
538
|
+
}
|
|
539
|
+
await client.query('COMMIT')
|
|
540
|
+
return { ...r.rows[0], from_project_id: curPid, to_project_id: targetPid }
|
|
541
|
+
} catch (err) {
|
|
542
|
+
try { await client.query('ROLLBACK') } catch {}
|
|
543
|
+
throw err
|
|
544
|
+
} finally {
|
|
545
|
+
client.release()
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
445
549
|
// ─── Core-candidate promotion pipeline (proposal mode) ───────────────────────
|
|
446
550
|
//
|
|
447
551
|
// nominateCoreCandidates flags strong active entries as core-memory
|
|
@@ -175,7 +175,7 @@ export function analyze(text) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
// Content-morpheme stem forms for a Korean phrase. null when not ready.
|
|
178
|
-
//
|
|
178
|
+
// Example: an inflected Korean noun/verb phrase is reduced to content stems.
|
|
179
179
|
export function stems(text) {
|
|
180
180
|
const tokens = analyze(text)
|
|
181
181
|
if (!tokens) return null
|
|
@@ -22,6 +22,7 @@ function __mixdogMemoryLog(...args) {
|
|
|
22
22
|
// <id>|update|<element>|<summary>
|
|
23
23
|
// <id>|merge|<target_id>|<source_ids_csv>
|
|
24
24
|
// <id>|delete|<reason>
|
|
25
|
+
// <id>|reclassify|<project_slug|common>
|
|
25
26
|
|
|
26
27
|
import { existsSync, readFileSync } from 'fs'
|
|
27
28
|
import { join } from 'path'
|
|
@@ -29,6 +30,7 @@ import { fileURLToPath } from 'url'
|
|
|
29
30
|
import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
|
|
30
31
|
import { callAgentDispatch } from './agent-ipc.mjs'
|
|
31
32
|
import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
|
|
33
|
+
import { reclassifyCore } from './core-memory-store.mjs'
|
|
32
34
|
import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
|
|
33
35
|
import { embedText } from './embedding-provider.mjs'
|
|
34
36
|
import { searchRelevantHybrid } from './memory-recall-store.mjs'
|
|
@@ -264,6 +266,17 @@ function parseVerdicts(raw, idSet) {
|
|
|
264
266
|
} else if (verb === 'delete') {
|
|
265
267
|
const reason = (parts[2] ?? '').trim()
|
|
266
268
|
actions.push({ id, verb: 'delete', reason: reason || null })
|
|
269
|
+
} else if (verb === 'reclassify' || verb === 'reproject' || verb === 'rescope') {
|
|
270
|
+
// Project classification correction: move a mis-scoped entry to the right
|
|
271
|
+
// pool. `<id>|reclassify|<project_slug|common>`. A missing target scope
|
|
272
|
+
// has no destination → drop. 'common' (case-insensitive) → COMMON pool.
|
|
273
|
+
const rawTarget = (parts[2] ?? '').trim()
|
|
274
|
+
if (!rawTarget) {
|
|
275
|
+
__mixdogMemoryLog(`[cycle3] reclassify rejected: id=${id} missing target scope\n`)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
const projectId = /^common$/i.test(rawTarget) ? null : rawTarget
|
|
279
|
+
actions.push({ id, verb: 'reclassify', projectId })
|
|
267
280
|
} else if (verb === 'superseded' || verb === 'supersede') {
|
|
268
281
|
// Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
|
|
269
282
|
// newer active core id the supersession has no evidence → drop to keep.
|
|
@@ -288,6 +301,7 @@ function mergeCycle3Counts(a = {}, b = {}) {
|
|
|
288
301
|
updated: Number(a.updated || 0) + Number(b.updated || 0),
|
|
289
302
|
merged: Number(a.merged || 0) + Number(b.merged || 0),
|
|
290
303
|
deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
|
|
304
|
+
reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
|
|
291
305
|
}
|
|
292
306
|
}
|
|
293
307
|
|
|
@@ -302,11 +316,13 @@ function mergeCycle3Results(a, b) {
|
|
|
302
316
|
updated: Number(a.updated || 0) + Number(b.updated || 0),
|
|
303
317
|
merged: Number(a.merged || 0) + Number(b.merged || 0),
|
|
304
318
|
deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
|
|
319
|
+
reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
|
|
305
320
|
proposed: mergeCycle3Counts(a.proposed, b.proposed),
|
|
306
321
|
held: {
|
|
307
322
|
updated: Number(a?.held?.updated || 0) + Number(b?.held?.updated || 0),
|
|
308
323
|
merged: Number(a?.held?.merged || 0) + Number(b?.held?.merged || 0),
|
|
309
324
|
deleted: Number(a?.held?.deleted || 0) + Number(b?.held?.deleted || 0),
|
|
325
|
+
reclassified: Number(a?.held?.reclassified || 0) + Number(b?.held?.reclassified || 0),
|
|
310
326
|
},
|
|
311
327
|
details: [...(a.details || []), ...(b.details || [])],
|
|
312
328
|
skippedInFlight: false,
|
|
@@ -517,6 +533,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
517
533
|
|
|
518
534
|
const idSet = new Set(cores.map(c => Number(c.id)))
|
|
519
535
|
const coreById = new Map(cores.map(c => [Number(c.id), c]))
|
|
536
|
+
// Real project pools present in this review — reclassify may only move an
|
|
537
|
+
// entry INTO a pool we already know is real (COMMON is always valid), never
|
|
538
|
+
// invent a typo pool from an LLM-supplied slug.
|
|
539
|
+
const knownPools = new Set(cores.map(c => c.project_id).filter(p => p != null).map(String))
|
|
520
540
|
const parsed = parseVerdicts(raw, idSet)
|
|
521
541
|
if (!parsed) {
|
|
522
542
|
__mixdogMemoryLog(
|
|
@@ -605,9 +625,9 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
605
625
|
}
|
|
606
626
|
}
|
|
607
627
|
|
|
608
|
-
let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
|
|
609
|
-
const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
610
|
-
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
628
|
+
let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0, reclassified = 0
|
|
629
|
+
const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
|
|
630
|
+
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
|
|
611
631
|
const details = []
|
|
612
632
|
const touched = new Set() // ids already acted on this cycle — avoid double action
|
|
613
633
|
// Safety margin: even when every junk verdict is legitimate, a single
|
|
@@ -687,6 +707,44 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
687
707
|
}
|
|
688
708
|
continue
|
|
689
709
|
}
|
|
710
|
+
if (a.verb === 'reclassify') {
|
|
711
|
+
proposed.reclassified++
|
|
712
|
+
// Project classification correction: move a mis-scoped entry to the right
|
|
713
|
+
// pool. Non-destructive (project_id only, no delete, no re-embed) and
|
|
714
|
+
// reversible, so it applies in DEFAULT (conservative) mode. Guards: the
|
|
715
|
+
// move must actually change pools and the target must be a real pool from
|
|
716
|
+
// this review (or COMMON) — otherwise hold. Unique-collision / content
|
|
717
|
+
// drift are resolved inside reclassifyCore (returns skipped).
|
|
718
|
+
const core = coreById.get(a.id)
|
|
719
|
+
const curPid = core?.project_id ?? null
|
|
720
|
+
const targetPid = a.projectId ?? null
|
|
721
|
+
let holdReason = null
|
|
722
|
+
if ((curPid ?? null) === (targetPid ?? null)) holdReason = 'already in target pool'
|
|
723
|
+
else if (targetPid != null && !knownPools.has(String(targetPid))) holdReason = `unknown target pool "${targetPid}"`
|
|
724
|
+
if (!mutate) holdReason = holdReason || 'proposal mode'
|
|
725
|
+
if (holdReason) {
|
|
726
|
+
held.reclassified++
|
|
727
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: holdReason })
|
|
728
|
+
touched.add(a.id)
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const res = await reclassifyCore(dataDir, a.id, targetPid, core ? { element: core.element, summary: core.summary, sourceProjectId: curPid } : null)
|
|
733
|
+
if (res?.skipped) {
|
|
734
|
+
held.reclassified++
|
|
735
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: res.reason })
|
|
736
|
+
} else {
|
|
737
|
+
reclassified++
|
|
738
|
+
details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: true })
|
|
739
|
+
}
|
|
740
|
+
touched.add(a.id)
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (signal?.aborted) throw signal.reason ?? err
|
|
743
|
+
__mixdogMemoryLog(`[cycle3] reclassify failed id=${a.id}: ${err.message}\n`)
|
|
744
|
+
details.push({ id: a.id, verb: 'reclassify', error: err.message })
|
|
745
|
+
}
|
|
746
|
+
continue
|
|
747
|
+
}
|
|
690
748
|
if (a.verb === 'superseded') {
|
|
691
749
|
proposed.superseded++
|
|
692
750
|
// Supersession archives (status flip), never physical DELETE, and applies
|
|
@@ -808,9 +866,11 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
808
866
|
` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
|
|
809
867
|
` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
|
|
810
868
|
` applied_superseded=${superseded}` +
|
|
869
|
+
` applied_reclassify=${reclassified}` +
|
|
811
870
|
` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
|
|
871
|
+
` held_reclassify=${held.reclassified}` +
|
|
812
872
|
` mode=${applyMode}\n`,
|
|
813
873
|
)
|
|
814
874
|
|
|
815
|
-
return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
|
|
875
|
+
return { reviewed: cores.length, kept, updated, merged, deleted, superseded, reclassified, proposed, held, applied: mutate, applyMode, details }
|
|
816
876
|
}
|
|
@@ -10,7 +10,7 @@ const MEMORY_TOKEN_STOPWORDS = new Set([
|
|
|
10
10
|
'your', 'unless', 'with',
|
|
11
11
|
'user', 'assistant', 'requested', 'request', 'asked', 'ask', 'stated', 'state', 'reported', 'report',
|
|
12
12
|
'mentioned', 'mention', 'clarified', 'clarify', 'explicitly', 'currently',
|
|
13
|
-
'
|
|
13
|
+
'\uC0AC\uC6A9\uC790', '\uC720\uC800', '\uC694\uCCAD', '\uC9C8\uBB38', '\uB2F5\uBCC0', '\uC5B8\uAE09', '\uB9D0\uC500', '\uC124\uBA85', '\uBCF4\uACE0', '\uBB34\uC2A8', '\uBB50\uC57C', '\uD588\uC9C0', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
|
|
14
14
|
])
|
|
15
15
|
|
|
16
16
|
export function normalizeMemoryToken(token) {
|
|
@@ -20,9 +20,9 @@ export function normalizeMemoryToken(token) {
|
|
|
20
20
|
// Korean suffix stripping: basic particles + compound endings
|
|
21
21
|
if (/[\uAC00-\uD7AF]/.test(normalized) && normalized.length > 2) {
|
|
22
22
|
const stripped = normalized
|
|
23
|
-
.replace(/(
|
|
24
|
-
.replace(/(
|
|
25
|
-
.replace(/(
|
|
23
|
+
.replace(/(\uD588\uC5C8\uC9C0|\uD588\uB354\uB77C|\uB410\uC5C8\uB098|\uB410\uB358\uAC00|\uD588\uB294\uC9C0|\uC600\uB294\uC9C0|\uC778\uAC74\uAC00|\uD558\uB824\uBA74|\uC5D0\uC11C\uB294|\uC774\uB77C\uC11C|\uC600\uB354\uB77C|\uC5D0\uC11C\uB3C4|\uC774\uC5C8\uC9C0|\uC73C\uB85C\uB3C4|\uAC70\uC600\uC9C0|\uD55C\uAC74\uC9C0|\uC774\uC5C8\uB098)$/u, '')
|
|
24
|
+
.replace(/(\uD588\uB358|\uD588\uC9C0|\uB410\uB358|\uB410\uC9C0|\uD558\uAC8C|\uB418\uB358|\uC774\uB77C|\uC5D0\uC11C|\uC73C\uB85C|\uD558\uB294|\uC5C6\uB294|\uC788\uB294|\uC5C8\uB358|\uD558\uC790|\uC54A\uAC8C|\uD560\uB54C|\uC778\uC9C0|\uC778\uB370|\uC778\uAC74|\uC774\uACE0|\uBCF4\uB2E4|\uCC98\uB7FC|\uAE4C\uC9C0|\uBD80\uD130|\uB9C8\uB2E4|\uBC16\uC5D0|\uC5C6\uC774)$/u, '')
|
|
25
|
+
.replace(/(\uC740|\uB294|\uC774|\uAC00|\uC744|\uB97C|\uB791|\uACFC|\uC640|\uB3C4|\uC5D0|\uC758|\uB85C|\uB9CC|\uBA70|\uB098|\uACE0|\uC11C|\uC790|\uC694)$/u, '')
|
|
26
26
|
if (stripped.length >= 2) normalized = stripped
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -314,14 +314,14 @@ function collectGroupTs(groupRows) {
|
|
|
314
314
|
return all
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
// Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n
|
|
317
|
+
// Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n entries)`; the end keeps the
|
|
318
318
|
// MM-DD prefix only when it falls on a different calendar day than the start.
|
|
319
319
|
function spanHeaderSuffix(minTs, maxTs, n) {
|
|
320
320
|
const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
|
|
321
321
|
const max = formatTs(maxTs)
|
|
322
322
|
const startPart = min.slice(5) // "MM-DD HH:mm"
|
|
323
323
|
const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
|
|
324
|
-
return `(${startPart} ~ ${endPart}, ${n}
|
|
324
|
+
return `(${startPart} ~ ${endPart}, ${n} entries)`
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
// Session-grouped rendering for the GLOBAL query-less browse ("what did we
|
|
@@ -359,7 +359,7 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
|
|
|
359
359
|
const tsAll = collectGroupTs(groupRows)
|
|
360
360
|
const suffix = tsAll.length
|
|
361
361
|
? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
|
|
362
|
-
: ` (${groupRows.length}
|
|
362
|
+
: ` (${groupRows.length} entries)`
|
|
363
363
|
parts.push(`## ${label}${mark}${suffix}`)
|
|
364
364
|
const bodyStr = renderEntryLines(groupRows, { recencyOrder })
|
|
365
365
|
const bodyLines = bodyStr === '(no results)'
|
|
@@ -120,6 +120,21 @@ function _abortError() {
|
|
|
120
120
|
return e;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort capacity probe for NON-COMPETING prewarm/warmup work. Returns
|
|
125
|
+
* true only when a slot could be taken right now without queuing — i.e. below
|
|
126
|
+
* the cap AND with no waiter already queued. Speculative warmers (explore's
|
|
127
|
+
* code_graph / find prewarm) consult this to skip/defer when the daemon is
|
|
128
|
+
* busy, so a fire-and-forget warm never pushes a real tool query into the
|
|
129
|
+
* queue (the "non-competing under fanout" guarantee). This is a probe, NOT a
|
|
130
|
+
* reservation: the answer can go stale under a race, which is acceptable for
|
|
131
|
+
* best-effort work. Real queries must use acquire()/withGate() and never gate
|
|
132
|
+
* themselves on this.
|
|
133
|
+
*/
|
|
134
|
+
export function hasSpareCapacity() {
|
|
135
|
+
return _inflight < MAX_INFLIGHT && _queue.length === 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
/**
|
|
124
139
|
* Run `fn` while holding one child-spawn slot. Release is guaranteed in a
|
|
125
140
|
* finally so a throw/return from `fn` cannot leak a slot or deadlock the gate.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Unified config reader/writer.
|
|
3
3
|
* Single file: mixdog-config.json with sections such as channels, agent, and memory.
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, mkdirSync, existsSync } from 'fs'
|
|
5
|
+
import { readFileSync, statSync, mkdirSync, existsSync } from 'fs'
|
|
6
6
|
import { join, dirname } from 'path'
|
|
7
7
|
import { createRequire } from 'module'
|
|
8
8
|
import { resolvePluginData } from './plugin-paths.mjs'
|
|
@@ -24,6 +24,78 @@ const CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json')
|
|
|
24
24
|
|
|
25
25
|
const GENERATED_KEY = '_generated'
|
|
26
26
|
|
|
27
|
+
// Process-wide short-TTL cache of the RAW utf8 string of CONFIG_PATH (never
|
|
28
|
+
// the parsed object). Parallel agent spawns each hit the non-RMW read path
|
|
29
|
+
// (readAll → readSection/readConfig/readCapabilities); without this every
|
|
30
|
+
// spawn pays a synchronous readFileSync(CONFIG_PATH) on the event loop,
|
|
31
|
+
// serializing the whole fanout behind redundant disk I/O. Caching the raw
|
|
32
|
+
// string — not the parsed object — lets each caller re-JSON.parse for an
|
|
33
|
+
// isolated, freely-mutable object (no shared-reference poisoning). Only the
|
|
34
|
+
// non-RMW readAll() consults this; the RMW path (readAllForRmW) always reads
|
|
35
|
+
// disk under the lock. Read errors and malformed JSON are never cached.
|
|
36
|
+
let _configReadCache = null // { raw, mtimeMs, size, atMs } | null
|
|
37
|
+
// Optional coarse fast-path window (ms). Default 0 => the cached raw string is
|
|
38
|
+
// validated against the file's mtime+size on EVERY read via a cheap statSync
|
|
39
|
+
// (no content read, no whole-file JSON.parse), so a cross-process edit is
|
|
40
|
+
// observed immediately with ZERO time-based staleness while the expensive
|
|
41
|
+
// full read + parse are still skipped on an unchanged file. Set >0 to also
|
|
42
|
+
// skip the statSync within the window, trading a bounded staleness for fewer
|
|
43
|
+
// syscalls under extreme spawn fanout.
|
|
44
|
+
const CONFIG_READ_TTL_MS = (() => {
|
|
45
|
+
const n = Number(process.env.MIXDOG_CONFIG_READ_TTL_MS)
|
|
46
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
export function invalidateConfigReadCache() {
|
|
50
|
+
_configReadCache = null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readConfigRawCached() {
|
|
54
|
+
const now = Date.now()
|
|
55
|
+
if (_configReadCache) {
|
|
56
|
+
// Fast path (only when a TTL window is configured): serve without any
|
|
57
|
+
// syscall until the window lapses.
|
|
58
|
+
if (CONFIG_READ_TTL_MS > 0 && now - _configReadCache.atMs < CONFIG_READ_TTL_MS) {
|
|
59
|
+
return _configReadCache.raw
|
|
60
|
+
}
|
|
61
|
+
// Freshness gate: metadata-only stat. Unchanged mtime+size => the cached
|
|
62
|
+
// raw is still current, cross-process writes included — atomic writers land
|
|
63
|
+
// a new inode with a fresh mtime, so this reliably detects them.
|
|
64
|
+
try {
|
|
65
|
+
const st = statSync(CONFIG_PATH)
|
|
66
|
+
if (st.mtimeMs === _configReadCache.mtimeMs && st.size === _configReadCache.size) {
|
|
67
|
+
_configReadCache.atMs = now
|
|
68
|
+
return _configReadCache.raw
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// stat failed (e.g. transient ENOENT mid-rename); fall through to a fresh
|
|
72
|
+
// read which re-applies the ENOENT/backup-restore semantics below.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Stat BEFORE read: if a write lands between the two we cache newer content
|
|
76
|
+
// against the older mtime, so the very next call re-reads instead of getting
|
|
77
|
+
// stuck on stale bytes — the cache always converges toward fresh.
|
|
78
|
+
let st = null
|
|
79
|
+
try {
|
|
80
|
+
st = statSync(CONFIG_PATH)
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err.code === 'ENOENT') { _configReadCache = null; return null }
|
|
83
|
+
process.stderr.write(`[config] readConfigRawCached: unexpected stat error for ${CONFIG_PATH}: ${err.message}\n`)
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
let raw
|
|
87
|
+
try {
|
|
88
|
+
raw = readFileSync(CONFIG_PATH, 'utf8')
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === 'ENOENT') { _configReadCache = null; return null }
|
|
91
|
+
// Fail closed on unknown read errors (EACCES, EIO, …); do NOT cache.
|
|
92
|
+
process.stderr.write(`[config] readJsonFile: unexpected read error for ${CONFIG_PATH}: ${err.message}\n`)
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
_configReadCache = { raw, mtimeMs: st.mtimeMs, size: st.size, atMs: now }
|
|
96
|
+
return raw
|
|
97
|
+
}
|
|
98
|
+
|
|
27
99
|
function isPlainObject(value) {
|
|
28
100
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
29
101
|
}
|
|
@@ -36,24 +108,32 @@ export function stripGeneratedMarker(data) {
|
|
|
36
108
|
|
|
37
109
|
function readJsonFile(path) {
|
|
38
110
|
let raw
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
111
|
+
if (path === CONFIG_PATH) {
|
|
112
|
+
// Non-RMW read path: served from the short-TTL raw-string cache. Returning
|
|
113
|
+
// {} on a read error here would let a subsequent writeSection() serialize
|
|
114
|
+
// an empty object over an existing-but-temporarily-unreadable config and
|
|
115
|
+
// erase every other section, so read errors surface (throw) uncached.
|
|
116
|
+
raw = readConfigRawCached()
|
|
117
|
+
} else {
|
|
118
|
+
try {
|
|
119
|
+
raw = readFileSync(path, 'utf8')
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.code === 'ENOENT') return null
|
|
122
|
+
process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
|
|
123
|
+
throw err
|
|
124
|
+
}
|
|
50
125
|
}
|
|
126
|
+
if (raw == null) return null
|
|
51
127
|
try {
|
|
52
128
|
return JSON.parse(raw)
|
|
53
129
|
} catch (err) {
|
|
54
130
|
// Quarantine a malformed mixdog-config.json so the next boot starts fresh
|
|
55
131
|
// instead of looping on a broken file.
|
|
56
132
|
if (path === CONFIG_PATH) {
|
|
133
|
+
// A cached raw string that fails to parse must not be re-served; drop it
|
|
134
|
+
// so the post-quarantine/restore read hits disk fresh (malformed = never
|
|
135
|
+
// cached).
|
|
136
|
+
invalidateConfigReadCache()
|
|
57
137
|
const corrupt = `${path}.corrupt-${Date.now()}`
|
|
58
138
|
try { renameWithRetrySync(path, corrupt) } catch {}
|
|
59
139
|
process.stderr.write(`[config] mixdog-config.json is malformed (${err.message}). Renamed to ${corrupt}. Restore it or delete to start fresh.\n`)
|
|
@@ -81,6 +161,9 @@ function writeJsonFile(path, data) {
|
|
|
81
161
|
}
|
|
82
162
|
writeJsonAtomicSync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
|
|
83
163
|
if (path === CONFIG_PATH) {
|
|
164
|
+
// Our own write just changed CONFIG_PATH on disk; drop the stale raw cache
|
|
165
|
+
// synchronously so the next in-process readAll() reflects it immediately.
|
|
166
|
+
invalidateConfigReadCache()
|
|
84
167
|
try { markUserDataInitialized(DATA_DIR) } catch {}
|
|
85
168
|
try { backupUserData(DATA_DIR, 'post-config-write') } catch {}
|
|
86
169
|
}
|
|
@@ -226,6 +309,9 @@ async function writeJsonFileAsync(path, data) {
|
|
|
226
309
|
}
|
|
227
310
|
await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
|
|
228
311
|
if (path === CONFIG_PATH) {
|
|
312
|
+
// Parity with writeJsonFile: invalidate synchronously after the async
|
|
313
|
+
// write resolves so this process never serves a stale cached config.
|
|
314
|
+
invalidateConfigReadCache()
|
|
229
315
|
try { markUserDataInitialized(DATA_DIR) } catch {}
|
|
230
316
|
try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
|
|
231
317
|
}
|