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
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
// _runtimeState map (owned by manager.mjs) to read the in-memory session and
|
|
7
7
|
// flag usageMetricsTurnIncremental. manager.mjs wires it via configureUsageMetricsRuntime().
|
|
8
8
|
import { providerInputExcludesCache } from '../../providers/registry.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
loadSession,
|
|
11
|
+
saveSessionAsync,
|
|
12
|
+
parkSessionSnapshotForDrain,
|
|
13
|
+
unparkSessionSnapshotForDrain,
|
|
14
|
+
registerSessionPurgeHook,
|
|
15
|
+
} from '../store.mjs';
|
|
10
16
|
|
|
11
17
|
// Per-session idempotency tracking: sessionId → Set of seen
|
|
12
18
|
// turn:epoch:iteration:source keys.
|
|
@@ -24,46 +30,135 @@ const _metricSaveState = new Map();
|
|
|
24
30
|
// flush. Only populated for the window between "delta applied, save not yet
|
|
25
31
|
// posted to the worker" and the actual saveSessionAsync call — once a save
|
|
26
32
|
// is posted, store.mjs's own _saveWorkerPending covers process-exit drain.
|
|
33
|
+
// While parked, the snapshot is ALSO registered with the store's own deferred
|
|
34
|
+
// map (parkSessionSnapshotForDrain), so the canonical drain — not a second
|
|
35
|
+
// exit hook — persists it under the shared epoch/lock/fence ordering.
|
|
27
36
|
const _pendingMetricsFlush = new Map();
|
|
28
37
|
const METRICS_SAVE_THROTTLE_MS = 500;
|
|
29
38
|
|
|
30
39
|
function _metricsSaveState(sessionId) {
|
|
31
40
|
let state = _metricSaveState.get(sessionId);
|
|
41
|
+
if (state && state.closed) {
|
|
42
|
+
// A CLOSED state belongs to a previous incarnation of this id (hard
|
|
43
|
+
// delete or close). It is never reused — and it must not block a
|
|
44
|
+
// re-created session: drop it and mint a fresh one. The old object
|
|
45
|
+
// stays non-current, so its in-flight callbacks remain inert.
|
|
46
|
+
_metricSaveState.delete(sessionId);
|
|
47
|
+
state = null;
|
|
48
|
+
}
|
|
32
49
|
if (!state) {
|
|
33
|
-
state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0 };
|
|
50
|
+
state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0, drainHandle: null };
|
|
34
51
|
_metricSaveState.set(sessionId, state);
|
|
35
52
|
}
|
|
36
53
|
return state;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
56
|
+
// Park/unpark keep the module-local ref and the store's drain registration in
|
|
57
|
+
// lock-step. The park carries the generation observed when the delta was
|
|
58
|
+
// applied, so a lifecycle barrier landing later still fences it by ownership.
|
|
59
|
+
function _parkMetricsSnapshot(state, sessionId, session) {
|
|
60
|
+
_pendingMetricsFlush.set(sessionId, session);
|
|
61
|
+
state.drainHandle = parkSessionSnapshotForDrain(
|
|
62
|
+
session,
|
|
63
|
+
{ expectedGeneration: session.generation },
|
|
64
|
+
state.drainHandle,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _unparkMetricsSnapshot(state, sessionId) {
|
|
69
|
+
// The shared map belongs to whichever state currently OWNS the id: an old
|
|
70
|
+
// (closed) callback must never erase the snapshot a re-created session
|
|
71
|
+
// parked under the same id.
|
|
72
|
+
if (!_metricSaveState.has(sessionId) || _metricSaveState.get(sessionId) === state) {
|
|
73
|
+
_pendingMetricsFlush.delete(sessionId);
|
|
74
|
+
}
|
|
75
|
+
if (!state?.drainHandle) return;
|
|
76
|
+
unparkSessionSnapshotForDrain(state.drainHandle);
|
|
77
|
+
state.drainHandle = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Authoritative liveness of a save-state object. A hard delete (or a close)
|
|
82
|
+
* marks the CURRENT state closed and drops it from the registry, so a callback
|
|
83
|
+
* still holding that object must never re-park, re-arm a timer or re-flush —
|
|
84
|
+
* not even for one microtask, which would be enough to re-create the file the
|
|
85
|
+
* delete just unlinked.
|
|
86
|
+
*/
|
|
87
|
+
function _isMetricsStateCurrent(sessionId, state) {
|
|
88
|
+
return !!state && state.closed !== true && _metricSaveState.get(sessionId) === state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _flushMetricsSave(session, sessionId, state = _metricSaveState.get(sessionId)) {
|
|
92
|
+
if (!_isMetricsStateCurrent(sessionId, state)) {
|
|
93
|
+
// Purged/closed while this flush was queued: leave no trace behind.
|
|
94
|
+
_unparkMetricsSnapshot(state, sessionId);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
42
97
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
43
|
-
|
|
98
|
+
// The park STAYS for the whole in-flight lifecycle and carries this
|
|
99
|
+
// snapshot's identity: the save — and every retry of it — runs under the
|
|
100
|
+
// epoch and guarded options (expectedGeneration included) assigned when the
|
|
101
|
+
// delta was first deferred. Minting fresh authority on a retry would let a
|
|
102
|
+
// stale payload outrank a session save that landed in the meantime.
|
|
103
|
+
_parkMetricsSnapshot(state, sessionId, session);
|
|
104
|
+
const identity = state.drainHandle;
|
|
44
105
|
state.dirty = false;
|
|
45
106
|
state.inFlight = true;
|
|
46
107
|
state.lastFlushAt = Date.now();
|
|
47
|
-
|
|
108
|
+
// Set when the store's exit drain settles this write: the drain already
|
|
109
|
+
// wrote the newest snapshot for the id and cleared its deferred handle, so
|
|
110
|
+
// that settlement is a TERMINAL ownership transfer for this identity.
|
|
111
|
+
let drainSettled = false;
|
|
112
|
+
saveSessionAsync(
|
|
113
|
+
session,
|
|
114
|
+
identity?.opts ?? { expectedGeneration: session.generation },
|
|
115
|
+
identity ? { epoch: identity.epoch, revision: identity.revision } : undefined,
|
|
116
|
+
)
|
|
48
117
|
.then(() => {
|
|
118
|
+
if (!_isMetricsStateCurrent(sessionId, state)) return;
|
|
49
119
|
state.failCount = 0;
|
|
120
|
+
// Durable — release the identity, but ONLY when no newer delta
|
|
121
|
+
// parked itself behind this write (that snapshot is not durable
|
|
122
|
+
// yet and must stay in the canonical drain).
|
|
123
|
+
if (!state.dirty) _unparkMetricsSnapshot(state, sessionId);
|
|
50
124
|
})
|
|
51
125
|
.catch((err) => {
|
|
52
126
|
process.stderr.write(`[usage-metrics] iteration save failed: ${err?.message ?? err}\n`);
|
|
127
|
+
if (err?.sessionStoreDrained === true) {
|
|
128
|
+
// Drain owns durability now. Re-parking here would register a
|
|
129
|
+
// NEW identity (fresh epoch) for an old payload, which could
|
|
130
|
+
// then overwrite a save that lands after the drain; retrying
|
|
131
|
+
// would do the same. Release the identity and stay inert.
|
|
132
|
+
drainSettled = true;
|
|
133
|
+
state.dirty = false;
|
|
134
|
+
state.failCount = 0;
|
|
135
|
+
_unparkMetricsSnapshot(state, sessionId);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Hard delete / close settled this id while the write was in
|
|
139
|
+
// flight: a re-park here would put a deferred entry (and with it a
|
|
140
|
+
// drain-recreated file) back for a session that no longer exists.
|
|
141
|
+
if (!_isMetricsStateCurrent(sessionId, state)) return;
|
|
53
142
|
// A rejected save must not silently drop the delta already
|
|
54
143
|
// applied in-memory — re-park it so the process-exit drain
|
|
55
144
|
// and/or the retry below still see it.
|
|
56
145
|
state.failCount += 1;
|
|
57
146
|
state.dirty = true;
|
|
58
|
-
|
|
147
|
+
// NEVER let the rejected closure overwrite a newer snapshot that
|
|
148
|
+
// parked while this write was in flight: the latest parked ref
|
|
149
|
+
// wins, under the same preserved identity.
|
|
150
|
+
const latest = _pendingMetricsFlush.get(sessionId);
|
|
151
|
+
_parkMetricsSnapshot(state, sessionId, latest ?? session);
|
|
59
152
|
})
|
|
60
153
|
.finally(() => {
|
|
61
154
|
state.inFlight = false;
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
155
|
+
if (drainSettled) return; // terminal: no repark, no retry, no timer
|
|
156
|
+
if (!_isMetricsStateCurrent(sessionId, state)) {
|
|
157
|
+
// dropMetricSeenState (close) or the hard-delete purge ran
|
|
158
|
+
// while this save was in flight — do not resurrect per-session
|
|
159
|
+
// state, timers or deferred entries.
|
|
160
|
+
_unparkMetricsSnapshot(state, sessionId);
|
|
161
|
+
if (_metricSaveState.get(sessionId) === state) _metricSaveState.delete(sessionId);
|
|
67
162
|
return;
|
|
68
163
|
}
|
|
69
164
|
// A newer delta (or the rejection re-park above) landed while
|
|
@@ -78,7 +173,7 @@ function _flushMetricsSave(session, sessionId) {
|
|
|
78
173
|
// drain still gets a shot at persisting it.
|
|
79
174
|
if (state.dirty && state.failCount <= 3) {
|
|
80
175
|
const latest = _pendingMetricsFlush.get(sessionId) ?? session;
|
|
81
|
-
_flushMetricsSave(latest, sessionId);
|
|
176
|
+
_flushMetricsSave(latest, sessionId, state);
|
|
82
177
|
}
|
|
83
178
|
});
|
|
84
179
|
}
|
|
@@ -95,22 +190,22 @@ function _scheduleMetricsSave(session, sessionId) {
|
|
|
95
190
|
if (state.closed) return;
|
|
96
191
|
if (state.inFlight) {
|
|
97
192
|
state.dirty = true;
|
|
98
|
-
|
|
193
|
+
_parkMetricsSnapshot(state, sessionId, session);
|
|
99
194
|
return;
|
|
100
195
|
}
|
|
101
196
|
const elapsed = Date.now() - state.lastFlushAt;
|
|
102
197
|
if (elapsed >= METRICS_SAVE_THROTTLE_MS) {
|
|
103
|
-
_flushMetricsSave(session, sessionId);
|
|
198
|
+
_flushMetricsSave(session, sessionId, state);
|
|
104
199
|
return;
|
|
105
200
|
}
|
|
106
201
|
state.dirty = true;
|
|
107
|
-
|
|
202
|
+
_parkMetricsSnapshot(state, sessionId, session);
|
|
108
203
|
if (!state.timer) {
|
|
109
204
|
const t = setTimeout(() => {
|
|
110
205
|
state.timer = null;
|
|
111
|
-
if (!state.inFlight &&
|
|
206
|
+
if (!state.inFlight && _isMetricsStateCurrent(sessionId, state)) {
|
|
112
207
|
const latest = _pendingMetricsFlush.get(sessionId) ?? session;
|
|
113
|
-
_flushMetricsSave(latest, sessionId);
|
|
208
|
+
_flushMetricsSave(latest, sessionId, state);
|
|
114
209
|
}
|
|
115
210
|
}, METRICS_SAVE_THROTTLE_MS - elapsed);
|
|
116
211
|
if (t.unref) t.unref();
|
|
@@ -118,18 +213,19 @@ function _scheduleMetricsSave(session, sessionId) {
|
|
|
118
213
|
}
|
|
119
214
|
}
|
|
120
215
|
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
216
|
+
// NO second exit-flush path lives here on purpose. Process exit can land
|
|
217
|
+
// inside the throttle window, before the trailing timer ever calls
|
|
218
|
+
// saveSessionAsync — but the parked snapshot is registered with the store's
|
|
219
|
+
// own deferred map, so drainSessionStore() persists it in the canonical
|
|
220
|
+
// ordering (epoch rank, fresh cancellation guard, shared commit lock,
|
|
221
|
+
// ownership generation, landed-epoch fence, one global deadline). A
|
|
222
|
+
// sync-flush here would instead mint fresh write authority AFTER that drain
|
|
223
|
+
// and could overwrite a newer landed session or a lifecycle barrier.
|
|
224
|
+
//
|
|
225
|
+
// Hard delete is the mirror image: the store runs this module's own cleanup
|
|
226
|
+
// synchronously (timers cancelled, park dropped, state closed) before it
|
|
227
|
+
// unlinks the file, so no retry and no drain entry can resurrect it.
|
|
228
|
+
registerSessionPurgeHook((sessionId) => dropMetricSeenState(sessionId));
|
|
133
229
|
|
|
134
230
|
// Injected accessor for manager's _runtimeState. Defaults to a no-op lookup so
|
|
135
231
|
// the pure helpers remain usable (and unit-testable) before wiring.
|
|
@@ -155,7 +251,7 @@ export function dropMetricSeenState(sessionId) {
|
|
|
155
251
|
// that write is still outstanding.
|
|
156
252
|
if (!state.inFlight) _metricSaveState.delete(sessionId);
|
|
157
253
|
}
|
|
158
|
-
|
|
254
|
+
_unparkMetricsSnapshot(state, sessionId);
|
|
159
255
|
}
|
|
160
256
|
|
|
161
257
|
/** Monotonic per-session ask/turn id for incremental usage idempotency. */
|
|
@@ -105,8 +105,10 @@ export {
|
|
|
105
105
|
createSession,
|
|
106
106
|
updateSessionRoute,
|
|
107
107
|
prefetchSession,
|
|
108
|
+
prepareSessionProjection,
|
|
108
109
|
resumeSession,
|
|
109
110
|
isSessionOwnerGone,
|
|
111
|
+
recoverSessionAfterProcessRestart,
|
|
110
112
|
} from './manager/session-lifecycle.mjs';
|
|
111
113
|
export { askSession, _api_call_with_interrupt } from './manager/ask-session.mjs';
|
|
112
114
|
export {
|
|
@@ -120,11 +122,13 @@ export {
|
|
|
120
122
|
clearSessionMessages,
|
|
121
123
|
compactSessionMessages,
|
|
122
124
|
updateSessionStatus,
|
|
125
|
+
updateSessionGeneratedTitle,
|
|
126
|
+
updateSessionManualTitle,
|
|
123
127
|
flushSessionMetrics,
|
|
124
128
|
} from './manager/session-crud.mjs';
|
|
125
129
|
export { deleteSession } from './store.mjs';
|
|
126
130
|
// Read-only parsed-session access (desktop pane peek): no resume, no
|
|
127
131
|
// ownership, no liveness side effects.
|
|
128
132
|
export { loadSession } from './store.mjs';
|
|
129
|
-
export { closeSession, abortSessionTurn } from './manager/session-close.mjs';
|
|
133
|
+
export { closeSession, abortSessionTurn, unloadSessionRuntime } from './manager/session-close.mjs';
|
|
130
134
|
export { sweepTombstones, startIdleCleanup, stopIdleCleanup } from './manager/idle-cleanup.mjs';
|
|
@@ -25,12 +25,85 @@ try {
|
|
|
25
25
|
} catch { /* best-effort: worker still functions with default stdio */ }
|
|
26
26
|
|
|
27
27
|
import { _saveSessionSync } from './store.mjs';
|
|
28
|
+
import { applySessionSaveFault, _sessionStoreTestMode } from './store/save-fault.mjs';
|
|
29
|
+
import { SAVE_OUTCOME_SAVED } from './store/live-state.mjs';
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
// Per-id base transcripts for the parent's delta handoff (save-worker.mjs
|
|
32
|
+
// _buildWirePayload): a delta message carries only header + appended tail and
|
|
33
|
+
// is reconstructed against the messages written by this worker's previous
|
|
34
|
+
// save for that id. Bounded LRU — an evicted id answers deltaMiss and the
|
|
35
|
+
// parent retries with a full snapshot.
|
|
36
|
+
const _baseMessages = new Map(); // id → last successfully written messages (disk shape)
|
|
37
|
+
const BASE_MESSAGES_MAX = 8;
|
|
38
|
+
|
|
39
|
+
function _rememberBase(id, messages) {
|
|
40
|
+
_baseMessages.delete(id);
|
|
41
|
+
_baseMessages.set(id, messages);
|
|
42
|
+
while (_baseMessages.size > BASE_MESSAGES_MAX) {
|
|
43
|
+
const oldest = _baseMessages.keys().next().value;
|
|
44
|
+
if (oldest === undefined) break;
|
|
45
|
+
_baseMessages.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
parentPort.on('message', (message) => {
|
|
50
|
+
// Authoritative fault state pushed by the parent realm. It arrives before
|
|
51
|
+
// any write that may be affected by it, and `null` clears (the worker's
|
|
52
|
+
// inherited env snapshot must never outlive the parent's config).
|
|
53
|
+
if (message && Object.prototype.hasOwnProperty.call(message, '__fault')) {
|
|
54
|
+
applySessionSaveFault(message.__fault);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// TEST-ONLY, STRUCTURALLY GATED: forget the delta base of ONE id so the
|
|
58
|
+
// next delta for it legitimately misses. Nothing else in this realm (or in
|
|
59
|
+
// the parent's baseline) is touched, and the worker keeps running.
|
|
60
|
+
if (message && Object.prototype.hasOwnProperty.call(message, '__evictBase')) {
|
|
61
|
+
if (_sessionStoreTestMode() && message.__evictBase) _baseMessages.delete(message.__evictBase);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// TEST-ONLY, STRUCTURALLY GATED inspector: report whether this realm still
|
|
65
|
+
// holds a delta base for one id. Read-only; nothing is mutated.
|
|
66
|
+
if (message && Object.prototype.hasOwnProperty.call(message, '__baseProbe')) {
|
|
67
|
+
if (_sessionStoreTestMode()) {
|
|
68
|
+
parentPort.postMessage({
|
|
69
|
+
__baseProbeResult: { id: message.__baseProbe, hasBase: _baseMessages.has(message.__baseProbe) },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { session, delta, id, opts, reqId } = message || {};
|
|
30
75
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
76
|
+
let full = session;
|
|
77
|
+
const sessionId = id ?? session?.id;
|
|
78
|
+
if (!full && delta) {
|
|
79
|
+
const base = _baseMessages.get(sessionId);
|
|
80
|
+
// baseCount pins the exact chain position: the single-worker FIFO
|
|
81
|
+
// guarantees per-id ordering, so a length mismatch can only mean a
|
|
82
|
+
// broken chain (restart/eviction) — never silently reconstruct.
|
|
83
|
+
if (!base || base.length !== delta.baseCount) {
|
|
84
|
+
parentPort.postMessage({ ok: true, saved: false, deltaMiss: true, reqId });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
full = { ...delta.header, messages: [...base, ...delta.tailMessages] };
|
|
88
|
+
}
|
|
89
|
+
const outcome = _saveSessionSync(full, opts);
|
|
90
|
+
const saved = outcome === SAVE_OUTCOME_SAVED;
|
|
91
|
+
if (saved) _rememberBase(sessionId, full.messages);
|
|
92
|
+
else _baseMessages.delete(sessionId);
|
|
93
|
+
// `outcome` tells the parent WHY a write did not land: an
|
|
94
|
+
// ownership/cancellation drop and a stale-epoch refusal have opposite
|
|
95
|
+
// consequences for live-state markers.
|
|
96
|
+
parentPort.postMessage({ ok: true, saved, outcome, reqId });
|
|
33
97
|
} catch (err) {
|
|
34
|
-
|
|
98
|
+
// Preserve the original failure identity across the thread boundary:
|
|
99
|
+
// the parent rebuilds an Error carrying the same code/injection marker
|
|
100
|
+
// so callers can branch on it exactly as in the sync path.
|
|
101
|
+
parentPort.postMessage({
|
|
102
|
+
ok: false,
|
|
103
|
+
error: err?.message ?? String(err),
|
|
104
|
+
errorCode: err?.code ?? null,
|
|
105
|
+
injectedSaveFault: err?.injectedSaveFault === true,
|
|
106
|
+
reqId,
|
|
107
|
+
});
|
|
35
108
|
}
|
|
36
109
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// try/catch it replaced.
|
|
7
7
|
import { appendAgentTrace } from '../agent-trace.mjs';
|
|
8
8
|
import { isContextOverflowError } from '../providers/retry-classifier.mjs';
|
|
9
|
+
import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
|
|
9
10
|
import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
|
|
10
11
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
11
12
|
import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
|
|
@@ -43,6 +44,12 @@ export async function sendWithRecovery(ctx) {
|
|
|
43
44
|
try {
|
|
44
45
|
response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
|
|
45
46
|
} catch (sendErr) {
|
|
47
|
+
// Canonical stream outcome: ONE fail-closed read of what the
|
|
48
|
+
// provider stream actually produced (terminal vs continuation,
|
|
49
|
+
// observed text/reasoning, partial/complete/dispatched tool calls).
|
|
50
|
+
// Every branch below consumes it instead of re-inferring safety
|
|
51
|
+
// from provider-specific flags.
|
|
52
|
+
const outcome = readStreamOutcome(sendErr);
|
|
46
53
|
// Gemini REST/SDK reports MAX_TOKENS by throwing a typed
|
|
47
54
|
// ProviderIncompleteError after preserving the streamed candidate.
|
|
48
55
|
// Normalize only that exact, safe no-tool output-limit shape into a
|
|
@@ -54,9 +61,11 @@ export async function sendWithRecovery(ctx) {
|
|
|
54
61
|
&& isOutputLimitStopReason(sendErr.finishReason)
|
|
55
62
|
&& typeof sendErr.partialContent === 'string'
|
|
56
63
|
&& sendErr.partialContent.trim().length > 0
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
&&
|
|
64
|
+
// Terminal observed (explicit finish reason) and no tool
|
|
65
|
+
// exposure at all: the only shape safe to promote to success.
|
|
66
|
+
&& outcome.successEligible === true
|
|
67
|
+
&& outcome.toolCallsStarted !== true
|
|
68
|
+
&& outcome.sideEffectDispatched !== true
|
|
60
69
|
) {
|
|
61
70
|
response = {
|
|
62
71
|
content: sendErr.partialContent,
|
|
@@ -71,48 +80,46 @@ export async function sendWithRecovery(ctx) {
|
|
|
71
80
|
};
|
|
72
81
|
return { action: 'proceed', response };
|
|
73
82
|
} else
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
83
|
+
// Exhausted no-tool stall: NOT a success. A FINAL, no-tool stream
|
|
84
|
+
// that wedges (ping-only) carries streamed assistant text on the
|
|
85
|
+
// StreamStalledError, but the turn never completed — the model may
|
|
86
|
+
// have been mid-sentence, and there is no completion signal that
|
|
87
|
+
// says the work is done. Promoting it to a normal terminal response
|
|
88
|
+
// silently reported truncated/aborted turns as finished. Keep it an
|
|
89
|
+
// explicit failure (like codex's no-completed stream handling and
|
|
90
|
+
// opencode's persisted partial-failure): the already-streamed text
|
|
91
|
+
// is preserved by the interruption/error persistence path
|
|
92
|
+
// (turn-interruption.mjs records the deltas and commits them as the
|
|
93
|
+
// partial assistant message alongside the error), so nothing visible
|
|
94
|
+
// is lost while the caller/owner-notify still sees a failed turn.
|
|
95
|
+
// A stall WITH complete parsed tool calls is a different, genuinely
|
|
96
|
+
// recoverable case and is handled by the next branch.
|
|
86
97
|
if (
|
|
87
|
-
|
|
88
|
-
&&
|
|
89
|
-
// NOT gated on unsafeToRetry: live-text stalls stamp
|
|
90
|
-
// unsafeToRetry=true (replay would double-render), but
|
|
91
|
-
// ACCEPTING the already-streamed partial is exactly the safe
|
|
92
|
-
// move (CC rule). Only an emitted tool call blocks acceptance.
|
|
93
|
-
&& sendErr.emittedToolCall !== true
|
|
98
|
+
outcome.stallObserved === true
|
|
99
|
+
&& outcome.terminalObserved !== true
|
|
94
100
|
&& typeof sendErr.partialContent === 'string'
|
|
95
101
|
&& sendErr.partialContent.trim().length > 0
|
|
96
|
-
&&
|
|
102
|
+
&& outcome.toolCallsComplete === 0
|
|
97
103
|
) {
|
|
98
104
|
try {
|
|
99
105
|
process.stderr.write(
|
|
100
106
|
`[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
|
|
101
107
|
+ `iter=${nextIteration} len=${sendErr.partialContent.length}); `
|
|
102
|
-
+ `
|
|
108
|
+
+ `failing the turn (partial preserved via interrupted-turn persistence)\n`,
|
|
103
109
|
);
|
|
104
110
|
} catch { /* best-effort */ }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
try {
|
|
112
|
+
appendAgentTrace({
|
|
113
|
+
kind: 'stall_partial_final_rejected',
|
|
114
|
+
sessionId: sessionId || null,
|
|
115
|
+
iteration: nextIteration,
|
|
116
|
+
partialContentLen: sendErr.partialContent.length,
|
|
117
|
+
pendingToolUse: outcome.pendingToolInput,
|
|
118
|
+
emittedToolCall: outcome.sideEffectDispatched,
|
|
119
|
+
terminalObserved: outcome.terminalObserved,
|
|
120
|
+
});
|
|
121
|
+
} catch { /* best-effort */ }
|
|
122
|
+
throw sendErr;
|
|
116
123
|
} else
|
|
117
124
|
// Partial tool-call recovery (agent-hang fix): a stream that stalls
|
|
118
125
|
// AFTER fully-parsed tool calls were emitted used to lose the whole
|
|
@@ -128,8 +135,9 @@ export async function sendWithRecovery(ctx) {
|
|
|
128
135
|
// once. providerState stays undefined so the next iteration resends
|
|
129
136
|
// a full frame on a fresh stream.
|
|
130
137
|
if (
|
|
131
|
-
|
|
132
|
-
&&
|
|
138
|
+
outcome.stallObserved === true
|
|
139
|
+
&& outcome.pendingToolInput !== true
|
|
140
|
+
&& outcome.toolCallsComplete > 0
|
|
133
141
|
&& Array.isArray(sendErr.partialToolCalls)
|
|
134
142
|
&& sendErr.partialToolCalls.length > 0
|
|
135
143
|
) {
|
|
@@ -156,6 +164,22 @@ export async function sendWithRecovery(ctx) {
|
|
|
156
164
|
usage: sendErr.partialUsage || undefined,
|
|
157
165
|
stopReason: 'tool_use',
|
|
158
166
|
hasThinkingContent: sendErr.partialHasThinking === true,
|
|
167
|
+
// Ordered provider block state captured before the stall.
|
|
168
|
+
// Anthropic REQUIRES the verbatim thinking blocks (with
|
|
169
|
+
// their signatures) back ahead of tool_use on the
|
|
170
|
+
// continuation turn, and a native `server_tool_use` call is
|
|
171
|
+
// only valid immediately followed by its result block — so
|
|
172
|
+
// the recovered tool turn must replay exactly what a
|
|
173
|
+
// successful turn would have returned. The loop's committer
|
|
174
|
+
// already drops the redundant `thinkingBlocks` copy when
|
|
175
|
+
// `assistantBlocks` is present (which contains them
|
|
176
|
+
// verbatim), so blocks are neither lost nor doubled.
|
|
177
|
+
...(Array.isArray(sendErr.partialThinkingBlocks) && sendErr.partialThinkingBlocks.length
|
|
178
|
+
? { thinkingBlocks: sendErr.partialThinkingBlocks }
|
|
179
|
+
: {}),
|
|
180
|
+
...(Array.isArray(sendErr.partialAssistantBlocks) && sendErr.partialAssistantBlocks.length
|
|
181
|
+
? { assistantBlocks: sendErr.partialAssistantBlocks }
|
|
182
|
+
: {}),
|
|
159
183
|
providerMetadata: sendErr.providerMetadata,
|
|
160
184
|
partialToolRecovery: true,
|
|
161
185
|
};
|
|
@@ -175,7 +199,12 @@ export async function sendWithRecovery(ctx) {
|
|
|
175
199
|
throw sendErr;
|
|
176
200
|
}
|
|
177
201
|
const compactPolicyForRetry = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
|
|
178
|
-
|
|
202
|
+
// The reactive retry RE-SENDS the turn, so it is a replay: denied
|
|
203
|
+
// once visible text/reasoning was relayed or a tool call was
|
|
204
|
+
// dispatched (or ambiguously dispatched). A deterministic overflow
|
|
205
|
+
// refusal that exposed nothing may compact and re-send.
|
|
206
|
+
const replayPermitted = outcome.replaySafe === true;
|
|
207
|
+
if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto && replayPermitted) {
|
|
179
208
|
// Mark the next pre-send compact as REACTIVE (driven by a
|
|
180
209
|
// provider overflow refusal) rather than the normal proactive
|
|
181
210
|
// pressure trigger, so the compact event/telemetry the loop
|
|
@@ -192,7 +221,9 @@ export async function sendWithRecovery(ctx) {
|
|
|
192
221
|
try {
|
|
193
222
|
process.stderr.write(
|
|
194
223
|
`[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
|
|
195
|
-
`surfacing overflow
|
|
224
|
+
`surfacing overflow (retryUsed=${contextOverflowRetryUsed === true} ` +
|
|
225
|
+
`replaySafe=${outcome.replaySafe === true} replayUnsafe=${outcome.replayUnsafe === true}) ` +
|
|
226
|
+
`messages=${messages.length}\n`,
|
|
196
227
|
);
|
|
197
228
|
} catch { /* best-effort */ }
|
|
198
229
|
throw agentContextOverflowError({
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existence probes that never conflate "not there" with "cannot look".
|
|
3
|
+
*
|
|
4
|
+
* `existsSync` returns false for EVERY stat failure — ENOENT, but also
|
|
5
|
+
* EACCES/EIO/EBUSY/EPERM/ELOOP. Every store decision keyed on that boolean
|
|
6
|
+
* therefore reads a temporarily unreadable file as a DELETED one: the sweep
|
|
7
|
+
* queues its summary row for removal, sidecar rebuilds drop it, sidecars get
|
|
8
|
+
* unlinked as orphans and in-memory state is allowed to stand in for it.
|
|
9
|
+
* Absence is a fact; a failed probe is an unknown, and an unknown may never
|
|
10
|
+
* authorize a deletion or a fallback.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, statSync } from 'fs';
|
|
13
|
+
|
|
14
|
+
export const PROBE_PRESENT = 'present';
|
|
15
|
+
export const PROBE_ABSENT = 'absent';
|
|
16
|
+
export const PROBE_UNREADABLE = 'unreadable';
|
|
17
|
+
|
|
18
|
+
// ONLY these mean "nothing is at this path". Everything else means a file is
|
|
19
|
+
// very likely there and we simply cannot look at it (same classification the
|
|
20
|
+
// session load path uses).
|
|
21
|
+
const ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
22
|
+
|
|
23
|
+
// Deterministic fault seam (tests only): invoked with the path before every
|
|
24
|
+
// stat and every read (`phase` is 'stat' or 'read'); returning an Error makes
|
|
25
|
+
// that syscall fail. Structurally gated — without the explicit test-mode env
|
|
26
|
+
// the hook cannot be installed and an already installed one goes inert.
|
|
27
|
+
const PROBE_FAULT_ENV = 'MIXDOG_SESSION_LOAD_FAULT_HOOKS';
|
|
28
|
+
let _probeFaultHook = null;
|
|
29
|
+
|
|
30
|
+
export function _setStatProbeFaultHook(hook) {
|
|
31
|
+
if (process.env[PROBE_FAULT_ENV] !== '1') {
|
|
32
|
+
_probeFaultHook = null;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
_probeFaultHook = typeof hook === 'function' ? hook : null;
|
|
36
|
+
return _probeFaultHook !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _probeFault(path, phase) {
|
|
40
|
+
if (!_probeFaultHook) return;
|
|
41
|
+
if (process.env[PROBE_FAULT_ENV] !== '1') {
|
|
42
|
+
_probeFaultHook = null; // gate revoked after install: stay inert
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const injected = _probeFaultHook(path, phase);
|
|
46
|
+
if (injected instanceof Error) throw injected;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `{ state, mtimeMs, size, code }` — state is exactly one of
|
|
51
|
+
* present / absent / unreadable.
|
|
52
|
+
*/
|
|
53
|
+
export function probePath(path) {
|
|
54
|
+
try {
|
|
55
|
+
_probeFault(path, 'stat');
|
|
56
|
+
const info = statSync(path);
|
|
57
|
+
return { state: PROBE_PRESENT, mtimeMs: info.mtimeMs || 0, size: info.size, code: null };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const code = err?.code || 'EUNKNOWN';
|
|
60
|
+
if (ABSENT_CODES.has(code)) return { state: PROBE_ABSENT, mtimeMs: 0, size: 0, code };
|
|
61
|
+
return { state: PROBE_UNREADABLE, mtimeMs: 0, size: 0, code };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a text file with the SAME three-way classification.
|
|
67
|
+
* `{ state, text, code }` — 'present' carries the text, 'absent' means the
|
|
68
|
+
* file is provably gone, 'unreadable' means it very likely exists and could
|
|
69
|
+
* not be read (EACCES/EIO/EBUSY): the caller must retain whatever authority
|
|
70
|
+
* it already had instead of treating the content as missing.
|
|
71
|
+
*/
|
|
72
|
+
export function readTextFile(path) {
|
|
73
|
+
try {
|
|
74
|
+
_probeFault(path, 'read');
|
|
75
|
+
return { state: PROBE_PRESENT, text: readFileSync(path, 'utf8'), code: null };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const code = err?.code || 'EUNKNOWN';
|
|
78
|
+
if (ABSENT_CODES.has(code)) return { state: PROBE_ABSENT, text: '', code };
|
|
79
|
+
return { state: PROBE_UNREADABLE, text: '', code };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True only for a positively observed file/dir. */
|
|
84
|
+
export function probeExists(path) {
|
|
85
|
+
return probePath(path).state === PROBE_PRESENT;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True only when the path is PROVEN absent (never for an unreadable probe). */
|
|
89
|
+
export function probeAbsent(path) {
|
|
90
|
+
return probePath(path).state === PROBE_ABSENT;
|
|
91
|
+
}
|