mixdog 0.9.87 → 0.9.89
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 +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { statSync,
|
|
1
|
+
import { statSync, readFileSync } from 'fs';
|
|
2
2
|
import { getPluginData } from '../../config.mjs';
|
|
3
|
+
import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
|
|
3
4
|
|
|
4
5
|
// Recent full-session reads are much hotter than writes while a user hops
|
|
5
6
|
// between conversations. Verify the file identity on every access, but reuse
|
|
@@ -8,42 +9,224 @@ const SESSION_LOAD_CACHE_LIMIT = 8;
|
|
|
8
9
|
const _sessionLoadCache = new Map(); // path → { signature, session }
|
|
9
10
|
let _sessionLoadCacheDataDir = null;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// A session file is only ever replaced atomically (write tmp → rename over
|
|
13
|
+
// the canonical path). A reader that stats one inode and then reads "the
|
|
14
|
+
// path" can therefore straddle a replacement: the stat describes the old
|
|
15
|
+
// file while the read returns the new bytes, or the old inode is already
|
|
16
|
+
// gone (ENOENT) even though a perfectly valid replacement exists — which the
|
|
17
|
+
// naive version reported as {exists:true, session:null} / {exists:false},
|
|
18
|
+
// i.e. loadSession()/getSession() returning null for a live session. Every
|
|
19
|
+
// observation below is therefore a STABLE SNAPSHOT: stat → read → stat, and
|
|
20
|
+
// the result is only trusted (and only cached) when the identity signature is
|
|
21
|
+
// unchanged around the read. An unstable observation is retried a bounded
|
|
22
|
+
// number of times, so the replacement window closes and we still fail closed
|
|
23
|
+
// on genuine corruption instead of spinning.
|
|
24
|
+
const SESSION_LOAD_STABLE_ATTEMPTS = 5;
|
|
25
|
+
|
|
26
|
+
// Deterministic fault injection point (tests only): invoked immediately
|
|
27
|
+
// before every stat and every read with { phase, path, attempt }. The hook
|
|
28
|
+
// may mutate the file system (simulating an atomic rename landing between
|
|
29
|
+
// the two syscalls) or return an Error to make that syscall fail.
|
|
30
|
+
//
|
|
31
|
+
// STRUCTURAL GATE: the seam is inert unless the process was started in
|
|
32
|
+
// explicit fault-injection test mode. Installing a hook without the gate is
|
|
33
|
+
// a no-op (returns false), and an already-installed hook is dropped the
|
|
34
|
+
// moment the gate stops being set, so a production build can never activate
|
|
35
|
+
// it accidentally — not via a stray import, not via a leaked reference.
|
|
36
|
+
const SESSION_LOAD_FAULT_ENV = 'MIXDOG_SESSION_LOAD_FAULT_HOOKS';
|
|
37
|
+
let _sessionLoadFaultHook = null;
|
|
38
|
+
|
|
39
|
+
function _faultHooksEnabled() {
|
|
40
|
+
return process.env[SESSION_LOAD_FAULT_ENV] === '1';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function _setSessionLoadFaultHook(hook) {
|
|
44
|
+
if (!_faultHooksEnabled()) {
|
|
45
|
+
_sessionLoadFaultHook = null;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
_sessionLoadFaultHook = typeof hook === 'function' ? hook : null;
|
|
49
|
+
return _sessionLoadFaultHook !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Test/diagnostic view of the cache: proves a signature is never paired with
|
|
53
|
+
// content that came from a different inode.
|
|
54
|
+
export function _inspectSessionLoadCache() {
|
|
55
|
+
return [..._sessionLoadCache.entries()].map(([path, entry]) => ({
|
|
56
|
+
path,
|
|
57
|
+
signature: entry.signature,
|
|
58
|
+
session: entry.session,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _fault(phase, path, attempt) {
|
|
63
|
+
if (!_sessionLoadFaultHook) return;
|
|
64
|
+
if (!_faultHooksEnabled()) {
|
|
65
|
+
_sessionLoadFaultHook = null; // gate revoked after install: stay inert
|
|
66
|
+
return;
|
|
16
67
|
}
|
|
17
|
-
|
|
68
|
+
const injected = _sessionLoadFaultHook({ phase, path, attempt });
|
|
69
|
+
if (injected instanceof Error) throw injected;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only these mean "nothing is at this path right now". Everything else
|
|
73
|
+
// (EACCES, EPERM, EIO, EBUSY, EMFILE, ELOOP, unknown) means a file is very
|
|
74
|
+
// likely THERE and we simply cannot look at it: that must never be reported
|
|
75
|
+
// as absence, because absence lets loadSession() fall back to in-memory
|
|
76
|
+
// state and thereby mask an unreadable file that owns the identity.
|
|
77
|
+
const ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
78
|
+
|
|
79
|
+
// Observation states: 'present' (signature valid), 'absent' (ENOENT-class),
|
|
80
|
+
// 'unreadable' (persistent/transient failure — fail closed).
|
|
81
|
+
function _observe(path, attempt) {
|
|
18
82
|
try {
|
|
83
|
+
_fault('stat', path, attempt);
|
|
19
84
|
const info = statSync(path, { bigint: true });
|
|
20
|
-
signature
|
|
21
|
-
} catch {
|
|
22
|
-
|
|
23
|
-
return {
|
|
85
|
+
return { state: 'present', signature: `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`, code: null };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const code = err?.code || 'EUNKNOWN';
|
|
88
|
+
if (ABSENT_CODES.has(code)) return { state: 'absent', signature: null, code };
|
|
89
|
+
return { state: 'unreadable', signature: null, code };
|
|
24
90
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A decision is always derived from ONE observation that was actually made —
|
|
94
|
+
// never from a second, independent existence probe (the old
|
|
95
|
+
// `stat → null → existsSync(path)` pair was a TOCTOU window that could turn a
|
|
96
|
+
// live file into {exists:false}).
|
|
97
|
+
function _decide(observation) {
|
|
98
|
+
if (observation.state === 'absent') return { exists: false, session: null };
|
|
99
|
+
return { exists: true, session: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Content is only this session's when it parses UNAMBIGUOUSLY and claims this
|
|
103
|
+
// id. readTopLevelLifecycleRecord is the strict parse (no separate JSON.parse
|
|
104
|
+
// authority to diverge from): a duplicate top-level `id` is last-wins under
|
|
105
|
+
// JSON.parse, which would hand the runtime a foreign/ambiguous transcript to
|
|
106
|
+
// serve, re-save and tombstone from. Ambiguity reads as corruption here —
|
|
107
|
+
// `null` keeps the file owning the identity and fails closed.
|
|
108
|
+
function _ownedSession(id, text) {
|
|
109
|
+
const record = readTopLevelLifecycleRecord(text);
|
|
110
|
+
if (isLifecycleUnreadable(record)) return null;
|
|
111
|
+
return record.id === id ? record.doc : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _cacheStable(path, signature, session) {
|
|
115
|
+
_sessionLoadCache.delete(path);
|
|
116
|
+
_sessionLoadCache.set(path, { signature, session });
|
|
117
|
+
while (_sessionLoadCache.size > SESSION_LOAD_CACHE_LIMIT) {
|
|
118
|
+
const oldest = _sessionLoadCache.keys().next().value;
|
|
119
|
+
if (oldest === undefined) break;
|
|
120
|
+
_sessionLoadCache.delete(oldest);
|
|
30
121
|
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The inode we stat'ed vanished under the read on the LAST attempt: no retry
|
|
125
|
+
// is left, but a replacement may already own the path. Take ONE decisive
|
|
126
|
+
// snapshot (observe, then read what is actually there) instead of classifying
|
|
127
|
+
// from the observation alone — reporting {exists:true, session:null} for a
|
|
128
|
+
// perfectly readable replacement is the same false corruption the retry loop
|
|
129
|
+
// exists to avoid. Uncached: no identity was verified around this read.
|
|
130
|
+
function _decisiveReplacementSnapshot(id, path, attempt) {
|
|
131
|
+
const observed = _observe(path, attempt);
|
|
132
|
+
if (observed.state === 'absent') return { exists: false, session: null };
|
|
133
|
+
if (observed.state !== 'present') return { exists: true, session: null };
|
|
134
|
+
let text = null;
|
|
31
135
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
136
|
+
_fault('read', path, attempt);
|
|
137
|
+
text = readFileSync(path, 'utf-8');
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const code = err?.code || 'EUNKNOWN';
|
|
140
|
+
return ABSENT_CODES.has(code) ? { exists: false, session: null } : { exists: true, session: null };
|
|
141
|
+
}
|
|
142
|
+
return { exists: true, session: _ownedSession(id, text) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function _readStoredSessionCached(id, path) {
|
|
146
|
+
const dataDir = getPluginData();
|
|
147
|
+
if (_sessionLoadCacheDataDir !== dataDir) {
|
|
148
|
+
_sessionLoadCacheDataDir = dataDir;
|
|
149
|
+
_sessionLoadCache.clear();
|
|
150
|
+
}
|
|
151
|
+
let last = { state: 'absent', signature: null, code: 'ENOENT' };
|
|
152
|
+
for (let attempt = 0; attempt < SESSION_LOAD_STABLE_ATTEMPTS; attempt++) {
|
|
153
|
+
// The final attempt must DECIDE — it may not defer to yet another
|
|
154
|
+
// observation, so a replacement landing on that last boundary is
|
|
155
|
+
// parsed (if its bytes are readable and ours) or reported as
|
|
156
|
+
// present-but-invalid, never as absence.
|
|
157
|
+
const isFinal = attempt === SESSION_LOAD_STABLE_ATTEMPTS - 1;
|
|
158
|
+
const before = _observe(path, attempt);
|
|
159
|
+
last = before;
|
|
160
|
+
if (before.state !== 'present') {
|
|
161
|
+
// Absent: possibly the unlink → rename hole, retry.
|
|
162
|
+
// Unreadable: EACCES/EIO/EBUSY — retry in case it is transient,
|
|
163
|
+
// but this observation already means "present, fail closed".
|
|
34
164
|
_sessionLoadCache.delete(path);
|
|
35
|
-
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const cached = _sessionLoadCache.get(path);
|
|
168
|
+
if (cached?.signature === before.signature) {
|
|
169
|
+
// The cached object was stored under a verified-stable signature,
|
|
170
|
+
// and dev/ino/size/mtimeNs still describe that same file.
|
|
171
|
+
_sessionLoadCache.delete(path);
|
|
172
|
+
_sessionLoadCache.set(path, cached);
|
|
173
|
+
return { exists: true, session: cached.session };
|
|
174
|
+
}
|
|
175
|
+
let text = null;
|
|
176
|
+
let readCode = null;
|
|
177
|
+
try {
|
|
178
|
+
_fault('read', path, attempt);
|
|
179
|
+
text = readFileSync(path, 'utf-8');
|
|
180
|
+
} catch (err) {
|
|
181
|
+
readCode = err?.code || 'EUNKNOWN';
|
|
36
182
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
183
|
+
if (readCode !== null) {
|
|
184
|
+
_sessionLoadCache.delete(path);
|
|
185
|
+
if (!ABSENT_CODES.has(readCode)) {
|
|
186
|
+
// EACCES/EIO/EBUSY on the read: the file is there and we
|
|
187
|
+
// cannot have it. Fail closed, now or after the retries.
|
|
188
|
+
last = { state: 'unreadable', signature: null, code: readCode };
|
|
189
|
+
if (isFinal) return { exists: true, session: null };
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// The inode we stat'ed vanished under us: replacement window, not
|
|
193
|
+
// corruption. Retry and let the new file answer.
|
|
194
|
+
last = { state: 'absent', signature: null, code: readCode };
|
|
195
|
+
// On the last boundary there is no retry left: read the
|
|
196
|
+
// replacement rather than classify it from a bare stat.
|
|
197
|
+
if (isFinal) return _decisiveReplacementSnapshot(id, path, attempt);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const after = _observe(path, attempt);
|
|
201
|
+
if (after.state !== 'present' || after.signature !== before.signature) {
|
|
202
|
+
// A rename landed around the read — these bytes may belong to a
|
|
203
|
+
// different inode than `before`, so they must never be cached
|
|
204
|
+
// under it.
|
|
205
|
+
_sessionLoadCache.delete(path);
|
|
206
|
+
last = after;
|
|
207
|
+
if (!isFinal) continue;
|
|
208
|
+
// Last boundary: the bytes still came from a real file at this
|
|
209
|
+
// path. If they are a valid session for this id, answer with them
|
|
210
|
+
// (uncached, since no identity was verified) rather than invent an
|
|
211
|
+
// absence/null for a perfectly readable replacement; otherwise
|
|
212
|
+
// stay conservatively present-but-invalid.
|
|
213
|
+
return { exists: true, session: _ownedSession(id, text) };
|
|
214
|
+
}
|
|
215
|
+
// Stable snapshot: `text` provably belongs to the file identified by
|
|
216
|
+
// `before`. Whatever it says is now authoritative, including "this is
|
|
217
|
+
// not valid session JSON" / "this is a foreign id" — fail closed.
|
|
218
|
+
const stored = _ownedSession(id, text);
|
|
219
|
+
if (stored === null) {
|
|
220
|
+
_sessionLoadCache.delete(path);
|
|
221
|
+
return { exists: true, session: null };
|
|
43
222
|
}
|
|
223
|
+
_cacheStable(path, before.signature, stored);
|
|
44
224
|
return { exists: true, session: stored };
|
|
45
|
-
} catch {
|
|
46
|
-
_sessionLoadCache.delete(path);
|
|
47
|
-
return { exists: true, session: null };
|
|
48
225
|
}
|
|
226
|
+
// Bounded retries exhausted. `last` is the most recent observation we
|
|
227
|
+
// actually made, so the answer needs no further syscall: ENOENT-class ⇒
|
|
228
|
+
// genuinely absent, anything else ⇒ present-but-unreadable, which keeps
|
|
229
|
+
// owning the identity and fails closed like real external corruption.
|
|
230
|
+
_sessionLoadCache.delete(path);
|
|
231
|
+
return _decide(last);
|
|
49
232
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-save commit faults and scratch-file hygiene.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, both about the *commit edge* of the store's atomic
|
|
5
|
+
* save (write `<id>.json.<hex>.tmp`, then rename it onto `<id>.json`):
|
|
6
|
+
*
|
|
7
|
+
* 1. A deterministic commit fault, STRUCTURALLY GATED to explicit test mode
|
|
8
|
+
* (MIXDOG_SESSION_SAVE_FAULT_HOOKS=1). The synthetic error is raised
|
|
9
|
+
* BEFORE renameSync runs, so the destination is never unlinked, truncated
|
|
10
|
+
* or replaced — the last-good canonical JSON survives the fault byte for
|
|
11
|
+
* byte. This replaces destructive "make the path a directory" fault rigs,
|
|
12
|
+
* which prove nothing about canonical integrity because they destroy the
|
|
13
|
+
* canonical file first. There is deliberately NO env-driven fault
|
|
14
|
+
* configuration: nothing in a production environment can arm this.
|
|
15
|
+
* 2. Conservative reclamation of the store's OWN abandoned scratch files.
|
|
16
|
+
* A failed commit can leave `<id>.json.<hex>.tmp` bytes behind; the store
|
|
17
|
+
* used to swallow the unlink failure and leak them forever. Only paths
|
|
18
|
+
* THIS realm minted and then failed to unlink are retried, and only when
|
|
19
|
+
* they match the store's exact scratch naming. The sessions directory is
|
|
20
|
+
* never scanned: an unregistered scratch file may be the save worker's or
|
|
21
|
+
* another process's ACTIVE commit, which no registry here can see.
|
|
22
|
+
*/
|
|
23
|
+
import { unlinkSync } from 'fs';
|
|
24
|
+
import { basename } from 'path';
|
|
25
|
+
import { _renameWithRetrySync } from './serialize.mjs';
|
|
26
|
+
|
|
27
|
+
// `<id>.json.<12 hex>.tmp` — exactly what the store mints
|
|
28
|
+
// (`sessionPath(id) + '.' + randomBytes(6).toString('hex') + '.tmp'`).
|
|
29
|
+
// Anything else in sessions/ is somebody else's file and is left alone.
|
|
30
|
+
const SAVE_TMP_RE = /^[A-Za-z0-9_-]+\.json\.[0-9a-f]{12}\.tmp$/;
|
|
31
|
+
const ORPHAN_TMP_SWEEP_LIMIT = 64; // registered paths retried per chunk
|
|
32
|
+
const ORPHAN_TMP_DRAIN_MAX_ATTEMPTS = 4096; // hard ceiling for one drain pass
|
|
33
|
+
const ORPHAN_TMP_TRACK_MAX = 256; // soft cap: only reclaimed paths leave
|
|
34
|
+
const UNLINK_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
35
|
+
|
|
36
|
+
const _activeSaveTmp = new Set(); // scratch paths this thread is writing now
|
|
37
|
+
const _orphanSaveTmp = new Set(); // scratch paths whose unlink failed
|
|
38
|
+
|
|
39
|
+
export function _trackSaveTmp(tmp) {
|
|
40
|
+
if (tmp) _activeSaveTmp.add(tmp);
|
|
41
|
+
return tmp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function _untrackSaveTmp(tmp) {
|
|
45
|
+
if (tmp) _activeSaveTmp.delete(tmp);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a scratch path as explicitly orphaned by THIS realm — the only
|
|
50
|
+
* class of file the sweeper may remove. Exported for the store's failure
|
|
51
|
+
* paths and for tests.
|
|
52
|
+
*/
|
|
53
|
+
export function _registerOrphanSaveTmp(tmp) {
|
|
54
|
+
if (!tmp) return;
|
|
55
|
+
_activeSaveTmp.delete(tmp);
|
|
56
|
+
_orphanSaveTmp.add(tmp);
|
|
57
|
+
if (_orphanSaveTmp.size <= ORPHAN_TMP_TRACK_MAX) return;
|
|
58
|
+
// Over the soft cap: try to RECLAIM the oldest entries instead of evicting
|
|
59
|
+
// them. Dropping a path that is still on disk turns it into an orphan no
|
|
60
|
+
// realm owns any more, which is exactly how scratch files leaked forever.
|
|
61
|
+
// A path whose unlink still fails keeps its registry slot (retry ownership).
|
|
62
|
+
for (const oldest of [..._orphanSaveTmp]) {
|
|
63
|
+
if (_orphanSaveTmp.size <= ORPHAN_TMP_TRACK_MAX) break;
|
|
64
|
+
if (oldest === tmp) continue;
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(oldest);
|
|
67
|
+
_orphanSaveTmp.delete(oldest);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err?.code === 'ENOENT') _orphanSaveTmp.delete(oldest);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drop a scratch file. Retries the transient win32 codes once, and on final
|
|
76
|
+
* failure registers the path so the bounded sweeper retries it later instead
|
|
77
|
+
* of leaking it (the previous `catch { /* ignore *\/ }` was terminal).
|
|
78
|
+
*/
|
|
79
|
+
export function _discardSaveTmp(tmp) {
|
|
80
|
+
if (!tmp) return true;
|
|
81
|
+
_activeSaveTmp.delete(tmp);
|
|
82
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(tmp);
|
|
85
|
+
_orphanSaveTmp.delete(tmp);
|
|
86
|
+
return true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err?.code === 'ENOENT') {
|
|
89
|
+
_orphanSaveTmp.delete(tmp);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (!UNLINK_RETRY_CODES.has(err?.code)) break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
_registerOrphanSaveTmp(tmp);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Retry the unlink of scratch files THIS realm minted and abandoned.
|
|
101
|
+
*
|
|
102
|
+
* Bounded: at most `limit` registered paths are retried per chunk (default
|
|
103
|
+
* 64). `drain: true` (exit) walks the WHOLE registry in such chunks, at most
|
|
104
|
+
* ONE attempt per path and at most `maxAttempts` in total, so a registry
|
|
105
|
+
* larger than one chunk cannot survive process exit while the pass still
|
|
106
|
+
* terminates.
|
|
107
|
+
* Conservative: no directory scan, so a scratch file this realm never
|
|
108
|
+
* registered — possibly the save worker's or another process's in-flight
|
|
109
|
+
* commit — is never even looked at, and a registered path whose name is not
|
|
110
|
+
* the store's exact scratch pattern is dropped from the registry rather than
|
|
111
|
+
* unlinked. A path whose unlink FAILS stays registered, so a transient
|
|
112
|
+
* failure is retried by the next pass instead of leaking.
|
|
113
|
+
* Returns the number of scratch files reclaimed.
|
|
114
|
+
*/
|
|
115
|
+
export function sweepOrphanSessionTmpFiles(options = {}) {
|
|
116
|
+
const limit = Number.isFinite(options.limit) && options.limit > 0
|
|
117
|
+
? options.limit
|
|
118
|
+
: ORPHAN_TMP_SWEEP_LIMIT;
|
|
119
|
+
if (options.drain !== true) return _sweepOrphanChunk(limit).removed;
|
|
120
|
+
const budget = Number.isFinite(options.maxAttempts) && options.maxAttempts > 0
|
|
121
|
+
? options.maxAttempts
|
|
122
|
+
: ORPHAN_TMP_DRAIN_MAX_ATTEMPTS;
|
|
123
|
+
// One attempt per path per drain: `attempted` is what makes the loop
|
|
124
|
+
// terminate even when every remaining unlink keeps failing.
|
|
125
|
+
const attempted = new Set();
|
|
126
|
+
let removed = 0;
|
|
127
|
+
let spent = 0;
|
|
128
|
+
while (spent < budget) {
|
|
129
|
+
const chunk = _sweepOrphanChunk(Math.min(limit, budget - spent), attempted);
|
|
130
|
+
if (chunk.attempts === 0) break;
|
|
131
|
+
removed += chunk.removed;
|
|
132
|
+
spent += chunk.attempts;
|
|
133
|
+
}
|
|
134
|
+
return removed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _sweepOrphanChunk(limit, attempted = null) {
|
|
138
|
+
let removed = 0;
|
|
139
|
+
let attempts = 0;
|
|
140
|
+
for (const path of [..._orphanSaveTmp]) {
|
|
141
|
+
if (attempts >= limit) break;
|
|
142
|
+
if (attempted?.has(path)) continue; // already retried in this drain
|
|
143
|
+
if (_activeSaveTmp.has(path)) continue; // re-minted since it was registered
|
|
144
|
+
if (!SAVE_TMP_RE.test(basename(path))) { // never an unrelated/external file
|
|
145
|
+
_orphanSaveTmp.delete(path);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
attempts++;
|
|
149
|
+
attempted?.add(path);
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(path);
|
|
152
|
+
_orphanSaveTmp.delete(path);
|
|
153
|
+
removed++;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err?.code === 'ENOENT') _orphanSaveTmp.delete(path);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { removed, attempts };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Deterministic commit-fault injection (test-gated) ───────────────────────
|
|
162
|
+
// STRUCTURAL GATE: the seam is inert unless the process was started in
|
|
163
|
+
// explicit fault-injection test mode. Arming it without the gate is refused
|
|
164
|
+
// (setSessionSaveFault returns false), and armed state is dropped the moment
|
|
165
|
+
// the gate stops being set, so no production process — and no environment
|
|
166
|
+
// variable — can make a real save fail.
|
|
167
|
+
const SESSION_SAVE_FAULT_ENV = 'MIXDOG_SESSION_SAVE_FAULT_HOOKS';
|
|
168
|
+
const _programmaticFault = { ids: null, remaining: 0, code: 'EIO' };
|
|
169
|
+
// Config epoch: bumped on every change so a realm that mirrors this state
|
|
170
|
+
// (the save worker) can detect a change — including a CLEAR — without diffing
|
|
171
|
+
// sets. Consumption of `remaining` never bumps it.
|
|
172
|
+
let _faultConfigEpoch = 0;
|
|
173
|
+
let _faultChangeListener = null;
|
|
174
|
+
// Authoritative state pushed in from the owning realm (parent → save worker).
|
|
175
|
+
// Once applied, it REPLACES programmatic resolution in this realm: the worker
|
|
176
|
+
// is long-lived and never runs setSessionSaveFault itself.
|
|
177
|
+
let _syncedFaultApplied = false;
|
|
178
|
+
let _syncedFault = null;
|
|
179
|
+
|
|
180
|
+
function _faultInjectionEnabled() {
|
|
181
|
+
return process.env[SESSION_SAVE_FAULT_ENV] === '1';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The store's explicit fault-injection test mode, shared by every structurally
|
|
186
|
+
* gated seam in the store (see save-worker's detach seam). Production never
|
|
187
|
+
* has this set, so those seams cannot be reached there.
|
|
188
|
+
*/
|
|
189
|
+
export function _sessionStoreTestMode() {
|
|
190
|
+
return _faultInjectionEnabled();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _clearFaultState() {
|
|
194
|
+
_programmaticFault.ids = null;
|
|
195
|
+
_programmaticFault.remaining = 0;
|
|
196
|
+
_programmaticFault.code = 'EIO';
|
|
197
|
+
_syncedFaultApplied = false;
|
|
198
|
+
_syncedFault = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _idSet(ids) {
|
|
202
|
+
if (ids === '*' || ids === true) return '*';
|
|
203
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
204
|
+
return new Set(list.map((v) => String(v)).filter(Boolean));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Configure (or clear with a falsy argument) in-process commit faults. Only
|
|
209
|
+
* honoured in explicit fault-injection test mode; returns whether the fault
|
|
210
|
+
* was armed.
|
|
211
|
+
* `{ ids: 'sess_x' | ['a','b'] | '*', count?: number, code?: string }`.
|
|
212
|
+
* `count` is the number of commits to fail (default: unlimited), so a test can
|
|
213
|
+
* fail exactly one save and let the NEXT save land without extra bookkeeping.
|
|
214
|
+
*/
|
|
215
|
+
export function setSessionSaveFault(spec = null) {
|
|
216
|
+
if (!spec || !_faultInjectionEnabled()) {
|
|
217
|
+
_clearFaultState();
|
|
218
|
+
_faultConfigEpoch++;
|
|
219
|
+
_notifyFaultChange();
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
_programmaticFault.ids = _idSet(spec.ids ?? spec.id ?? '*');
|
|
223
|
+
const count = Number(spec.count);
|
|
224
|
+
_programmaticFault.remaining = Number.isFinite(count) && count >= 0 ? count : Infinity;
|
|
225
|
+
_programmaticFault.code = typeof spec.code === 'string' && spec.code ? spec.code : 'EIO';
|
|
226
|
+
_faultConfigEpoch++;
|
|
227
|
+
_notifyFaultChange();
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _notifyFaultChange() {
|
|
232
|
+
if (typeof _faultChangeListener === 'function') {
|
|
233
|
+
try { _faultChangeListener(); } catch { /* best-effort */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Register the realm-sync hook (save-worker.mjs pushes state to the worker). */
|
|
238
|
+
export function _onSessionSaveFaultChange(listener) {
|
|
239
|
+
_faultChangeListener = typeof listener === 'function' ? listener : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Cheap change token for the fault config. A mirroring realm re-syncs only
|
|
244
|
+
* when this token moves, so steady-state saves pay one compare and never a
|
|
245
|
+
* postMessage.
|
|
246
|
+
*/
|
|
247
|
+
export function sessionSaveFaultSyncKey() {
|
|
248
|
+
return String(_faultConfigEpoch);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Wire form of the currently effective fault state (null when disabled). */
|
|
252
|
+
export function serializeSessionSaveFault() {
|
|
253
|
+
const state = _effectiveFaultState();
|
|
254
|
+
if (!state) return null;
|
|
255
|
+
return {
|
|
256
|
+
ids: state.ids === '*' ? '*' : [...state.ids],
|
|
257
|
+
remaining: state.remaining === Infinity ? null : state.remaining,
|
|
258
|
+
code: state.code,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Adopt fault state pushed from the owning realm. `null` DISABLES injection in
|
|
264
|
+
* this realm outright, which is what makes `setSessionSaveFault(null)` stop
|
|
265
|
+
* worker-thread faults too.
|
|
266
|
+
*/
|
|
267
|
+
export function applySessionSaveFault(serialized) {
|
|
268
|
+
_syncedFaultApplied = true;
|
|
269
|
+
_syncedFault = (serialized && _faultInjectionEnabled())
|
|
270
|
+
? {
|
|
271
|
+
ids: serialized.ids === '*' ? '*' : _idSet(serialized.ids),
|
|
272
|
+
remaining: serialized.remaining === null || serialized.remaining === undefined
|
|
273
|
+
? Infinity
|
|
274
|
+
: Number(serialized.remaining),
|
|
275
|
+
code: serialized.code || 'EIO',
|
|
276
|
+
}
|
|
277
|
+
: null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _effectiveFaultState() {
|
|
281
|
+
// Gate revoked after arming: stay inert and forget the armed state.
|
|
282
|
+
if (!_faultInjectionEnabled()) {
|
|
283
|
+
if (_programmaticFault.ids || _syncedFault) _clearFaultState();
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
// A synced realm is fully governed by its owner — never by a programmatic
|
|
287
|
+
// call that never happened there.
|
|
288
|
+
const state = _syncedFaultApplied ? _syncedFault : (_programmaticFault.ids ? _programmaticFault : null);
|
|
289
|
+
return state && state.remaining > 0 ? state : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _takeInjectedCommitFault(id) {
|
|
293
|
+
const state = _effectiveFaultState();
|
|
294
|
+
if (!state) return null;
|
|
295
|
+
if (state.ids !== '*' && !state.ids.has(id)) return null;
|
|
296
|
+
if (state.remaining !== Infinity) state.remaining -= 1;
|
|
297
|
+
const err = new Error(`[session-store] injected save commit fault for ${id}`);
|
|
298
|
+
err.code = state.code;
|
|
299
|
+
err.injectedSaveFault = true;
|
|
300
|
+
return err;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Commit a staged scratch file onto its canonical path. The injected fault is
|
|
305
|
+
* raised before the rename, so a fault leaves BOTH files exactly as they were:
|
|
306
|
+
* the canonical destination stays the last-good session JSON, and the scratch
|
|
307
|
+
* file is the caller's to discard (see _discardSaveTmp).
|
|
308
|
+
*/
|
|
309
|
+
export function _commitSessionWrite(tmp, target, id) {
|
|
310
|
+
const injected = _takeInjectedCommitFault(id);
|
|
311
|
+
if (injected) throw injected;
|
|
312
|
+
return _renameWithRetrySync(tmp, target);
|
|
313
|
+
}
|