mixdog 0.9.16 → 0.9.18
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 +2 -1
- package/scripts/atomic-lock-tryonce-test.mjs +66 -0
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/src/mixdog-session-runtime.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
- package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
- package/src/runtime/channels/backends/discord.mjs +97 -7
- package/src/runtime/channels/index.mjs +150 -23
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
- package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/index.mjs +37 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -0
- package/src/runtime/shared/atomic-file.mjs +110 -0
- package/src/runtime/shared/transcript-writer.mjs +46 -4
- package/src/session-runtime/provider-models.mjs +47 -8
- package/src/standalone/channel-worker.mjs +14 -2
- package/src/tui/app/transcript-window.mjs +137 -6
- package/src/tui/app/use-transcript-window.mjs +67 -10
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/dist/index.mjs +436 -93
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +66 -14
- package/src/tui/index.jsx +97 -6
- package/src/ui/statusline-segments.mjs +54 -36
- package/src/ui/statusline.mjs +141 -95
|
@@ -6,12 +6,131 @@
|
|
|
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 { loadSession, saveSessionAsync } from '../store.mjs';
|
|
9
|
+
import { loadSession, saveSessionAsync, _saveSessionSync } from '../store.mjs';
|
|
10
10
|
|
|
11
11
|
// Per-session idempotency tracking: sessionId → Set of seen
|
|
12
12
|
// turn:epoch:iteration:source keys.
|
|
13
13
|
const _metricSeenIter = new Map();
|
|
14
14
|
|
|
15
|
+
// ── Mid-turn save coalescing ──────────────────────────────────────────────
|
|
16
|
+
// saveSessionAsync structured-clones the ENTIRE session on the main thread
|
|
17
|
+
// per postMessage call (store.mjs saveSessionAsync). Calling it after EVERY
|
|
18
|
+
// provider.send iteration is the cost this coalesces away: in-memory session
|
|
19
|
+
// mutation above still happens every iteration (durability of the data is
|
|
20
|
+
// unaffected — only how often it hits saveSessionAsync's postMessage clone).
|
|
21
|
+
// sessionId → { inFlight, dirty, lastFlushAt, timer }
|
|
22
|
+
const _metricSaveState = new Map();
|
|
23
|
+
// sessionId → live session ref awaiting a still-deferred (timer-pending)
|
|
24
|
+
// flush. Only populated for the window between "delta applied, save not yet
|
|
25
|
+
// posted to the worker" and the actual saveSessionAsync call — once a save
|
|
26
|
+
// is posted, store.mjs's own _saveWorkerPending covers process-exit drain.
|
|
27
|
+
const _pendingMetricsFlush = new Map();
|
|
28
|
+
const METRICS_SAVE_THROTTLE_MS = 500;
|
|
29
|
+
|
|
30
|
+
function _metricsSaveState(sessionId) {
|
|
31
|
+
let state = _metricSaveState.get(sessionId);
|
|
32
|
+
if (!state) {
|
|
33
|
+
state = { inFlight: false, dirty: false, lastFlushAt: 0, timer: null, closed: false, failCount: 0 };
|
|
34
|
+
_metricSaveState.set(sessionId, state);
|
|
35
|
+
}
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _flushMetricsSave(session, sessionId) {
|
|
40
|
+
const state = _metricsSaveState(sessionId);
|
|
41
|
+
if (state.closed) return;
|
|
42
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
43
|
+
_pendingMetricsFlush.delete(sessionId);
|
|
44
|
+
state.dirty = false;
|
|
45
|
+
state.inFlight = true;
|
|
46
|
+
state.lastFlushAt = Date.now();
|
|
47
|
+
saveSessionAsync(session, { expectedGeneration: session.generation })
|
|
48
|
+
.then(() => {
|
|
49
|
+
state.failCount = 0;
|
|
50
|
+
})
|
|
51
|
+
.catch((err) => {
|
|
52
|
+
process.stderr.write(`[usage-metrics] iteration save failed: ${err?.message ?? err}\n`);
|
|
53
|
+
// A rejected save must not silently drop the delta already
|
|
54
|
+
// applied in-memory — re-park it so the process-exit drain
|
|
55
|
+
// and/or the retry below still see it.
|
|
56
|
+
state.failCount += 1;
|
|
57
|
+
state.dirty = true;
|
|
58
|
+
_pendingMetricsFlush.set(sessionId, session);
|
|
59
|
+
})
|
|
60
|
+
.finally(() => {
|
|
61
|
+
state.inFlight = false;
|
|
62
|
+
if (state.closed) {
|
|
63
|
+
// dropMetricSeenState ran while this save was in flight —
|
|
64
|
+
// do not resurrect per-session state/timers post-close.
|
|
65
|
+
_metricSaveState.delete(sessionId);
|
|
66
|
+
_pendingMetricsFlush.delete(sessionId);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// A newer delta (or the rejection re-park above) landed while
|
|
70
|
+
// this save was in flight — flush once more so it is never
|
|
71
|
+
// stranded. Always take the LATEST parked session, not the
|
|
72
|
+
// (possibly stale/pre-detach-resume) closure `session` — a
|
|
73
|
+
// newer delta may have parked a fresher session/generation.
|
|
74
|
+
// failCount cap: a persistently failing save (broken dir/disk)
|
|
75
|
+
// must not spin reject→re-park→retry forever with no backoff.
|
|
76
|
+
// The session stays parked in _pendingMetricsFlush, so a later
|
|
77
|
+
// _scheduleMetricsSave (next iteration) or the process-exit
|
|
78
|
+
// drain still gets a shot at persisting it.
|
|
79
|
+
if (state.dirty && state.failCount <= 3) {
|
|
80
|
+
const latest = _pendingMetricsFlush.get(sessionId) ?? session;
|
|
81
|
+
_flushMetricsSave(latest, sessionId);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Coalesce mid-turn metric saves: an in-flight guard collapses overlapping
|
|
88
|
+
* saves into one trailing flush, and a >=500ms per-session throttle caps
|
|
89
|
+
* how often a completed save re-triggers another. The idempotency Set above
|
|
90
|
+
* still updates per-iteration regardless of whether this call ends up
|
|
91
|
+
* actually posting to the worker this tick.
|
|
92
|
+
*/
|
|
93
|
+
function _scheduleMetricsSave(session, sessionId) {
|
|
94
|
+
const state = _metricsSaveState(sessionId);
|
|
95
|
+
if (state.closed) return;
|
|
96
|
+
if (state.inFlight) {
|
|
97
|
+
state.dirty = true;
|
|
98
|
+
_pendingMetricsFlush.set(sessionId, session);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const elapsed = Date.now() - state.lastFlushAt;
|
|
102
|
+
if (elapsed >= METRICS_SAVE_THROTTLE_MS) {
|
|
103
|
+
_flushMetricsSave(session, sessionId);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
state.dirty = true;
|
|
107
|
+
_pendingMetricsFlush.set(sessionId, session);
|
|
108
|
+
if (!state.timer) {
|
|
109
|
+
const t = setTimeout(() => {
|
|
110
|
+
state.timer = null;
|
|
111
|
+
if (!state.inFlight && !state.closed) {
|
|
112
|
+
const latest = _pendingMetricsFlush.get(sessionId) ?? session;
|
|
113
|
+
_flushMetricsSave(latest, sessionId);
|
|
114
|
+
}
|
|
115
|
+
}, METRICS_SAVE_THROTTLE_MS - elapsed);
|
|
116
|
+
if (t.unref) t.unref();
|
|
117
|
+
state.timer = t;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Process exit can land inside the throttle window, before the trailing
|
|
122
|
+
// timer above ever calls saveSessionAsync (which would otherwise register
|
|
123
|
+
// with store.mjs's own drainSessionStore-covered worker queue). Sync-flush
|
|
124
|
+
// any such still-deferred session here so a normal-completion turn that
|
|
125
|
+
// happens to exit mid-throttle never loses the latest iteration delta.
|
|
126
|
+
process.on('exit', () => {
|
|
127
|
+
for (const [sessionId, session] of _pendingMetricsFlush) {
|
|
128
|
+
try { _saveSessionSync(session, { expectedGeneration: session.generation }); }
|
|
129
|
+
catch { /* best-effort: process is exiting */ }
|
|
130
|
+
}
|
|
131
|
+
_pendingMetricsFlush.clear();
|
|
132
|
+
});
|
|
133
|
+
|
|
15
134
|
// Injected accessor for manager's _runtimeState. Defaults to a no-op lookup so
|
|
16
135
|
// the pure helpers remain usable (and unit-testable) before wiring.
|
|
17
136
|
let _getRuntimeEntry = () => null;
|
|
@@ -23,7 +142,20 @@ export function configureUsageMetricsRuntime({ getRuntimeEntry } = {}) {
|
|
|
23
142
|
|
|
24
143
|
/** Drop the per-session metric-idempotency Set (called on session close). */
|
|
25
144
|
export function dropMetricSeenState(sessionId) {
|
|
26
|
-
if (sessionId)
|
|
145
|
+
if (!sessionId) return;
|
|
146
|
+
_metricSeenIter.delete(sessionId);
|
|
147
|
+
const state = _metricSaveState.get(sessionId);
|
|
148
|
+
if (state) {
|
|
149
|
+
state.closed = true;
|
|
150
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
151
|
+
// Only delete the map entry when nothing is in flight — an in-flight
|
|
152
|
+
// save's own .finally (guarded by state.closed above) performs the
|
|
153
|
+
// cleanup itself once it settles, so a stray concurrent call cannot
|
|
154
|
+
// recreate a fresh (non-closed) entry via _metricsSaveState() while
|
|
155
|
+
// that write is still outstanding.
|
|
156
|
+
if (!state.inFlight) _metricSaveState.delete(sessionId);
|
|
157
|
+
}
|
|
158
|
+
_pendingMetricsFlush.delete(sessionId);
|
|
27
159
|
}
|
|
28
160
|
|
|
29
161
|
/** Monotonic per-session ask/turn id for incremental usage idempotency. */
|
|
@@ -206,5 +338,12 @@ export async function persistIterationMetrics(delta) {
|
|
|
206
338
|
}
|
|
207
339
|
session.lastIterationIndex = iterationIndex;
|
|
208
340
|
session.updatedAt = ts || Date.now();
|
|
209
|
-
|
|
341
|
+
// Coalesced mid-turn persistence (see _scheduleMetricsSave above): an
|
|
342
|
+
// in-flight guard + >=500ms per-session throttle collapse the per-
|
|
343
|
+
// iteration saveSessionAsync postMessage/clone cost. The idempotency Set
|
|
344
|
+
// update above already happened unconditionally this call, so a
|
|
345
|
+
// throttled/skipped flush here never loses delta accounting — only the
|
|
346
|
+
// disk-write cadence changes. Terminal save at turn end (askSession) and
|
|
347
|
+
// the exit drain above both still cover the last pending delta.
|
|
348
|
+
_scheduleMetricsSave(session, sessionId);
|
|
210
349
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { mkdirSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { getPluginData } from '../config.mjs';
|
|
11
|
-
import { updateJsonAtomicSync, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
11
|
+
import { updateJsonAtomicSync, updateJsonAtomic, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
12
12
|
|
|
13
13
|
export const SESSION_SUMMARY_INDEX_VERSION = 1;
|
|
14
14
|
|
|
@@ -158,34 +158,115 @@ export function _writeSummaryIndex(rows) {
|
|
|
158
158
|
return cleanRows;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
// ── Non-blocking upsert/remove ──────────────────────────────────────────────
|
|
162
|
+
// The summary index is a best-effort sidecar, but its writers used to take
|
|
163
|
+
// the cross-process file lock with the DEFAULT 8s timeout — and
|
|
164
|
+
// withFileLockSync waits with Atomics.wait, which BLOCKS the main thread.
|
|
165
|
+
// Every session save calls _upsertSessionSummary, so with several mixdog
|
|
166
|
+
// processes (TUI + agent workers + memory service) contending on a multi-MB
|
|
167
|
+
// index, a busy lock froze typing/rendering for seconds at a time.
|
|
168
|
+
// Now: callers only QUEUE the mutation (O(1), no I/O) and the flush runs on
|
|
169
|
+
// a deferred tick with a ZERO-WAIT try-lock — if another process holds the
|
|
170
|
+
// lock we never sleep on it, we re-queue and retry on an unref'd timer. The
|
|
171
|
+
// caller's thread never blocks on this lock at all.
|
|
172
|
+
const SUMMARY_LOCK_TIMEOUT_MS = 0; // try-lock: acquire-or-fail, never wait
|
|
173
|
+
const SUMMARY_RETRY_DELAY_MS = 1000;
|
|
174
|
+
const _pendingUpserts = new Map(); // id → summary row (latest wins)
|
|
175
|
+
const _pendingRemovals = new Set(); // ids to drop (upsert/removal are mutually exclusive per id)
|
|
176
|
+
let _summaryRetryTimer = null;
|
|
177
|
+
let _summaryFlushScheduled = false;
|
|
178
|
+
|
|
179
|
+
function _scheduleSummaryRetry() {
|
|
180
|
+
if (_summaryRetryTimer) return;
|
|
181
|
+
_summaryRetryTimer = setTimeout(() => {
|
|
182
|
+
_summaryRetryTimer = null;
|
|
183
|
+
_flushPendingSummaryOps();
|
|
184
|
+
}, SUMMARY_RETRY_DELAY_MS);
|
|
185
|
+
_summaryRetryTimer.unref?.();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Defer the flush off the caller's stack: the 1MB+ parse/stringify inside
|
|
189
|
+
// updateJsonAtomicSync (~10ms on a warm cache) has no business on the
|
|
190
|
+
// keystroke/session-save path.
|
|
191
|
+
function _scheduleSummaryFlush() {
|
|
192
|
+
if (_summaryFlushScheduled) return;
|
|
193
|
+
_summaryFlushScheduled = true;
|
|
194
|
+
setImmediate(() => {
|
|
195
|
+
_summaryFlushScheduled = false;
|
|
196
|
+
_flushPendingSummaryOps();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function _flushPendingSummaryOps({ sync = false } = {}) {
|
|
201
|
+
if (_pendingUpserts.size === 0 && _pendingRemovals.size === 0) return;
|
|
202
|
+
// Snapshot + clear first: ops queued by other callers DURING the flush
|
|
203
|
+
// must not be lost, and a failed flush re-queues only what it took
|
|
204
|
+
// (without clobbering anything newer queued meanwhile).
|
|
205
|
+
const upserts = new Map(_pendingUpserts);
|
|
206
|
+
const removals = new Set(_pendingRemovals);
|
|
207
|
+
_pendingUpserts.clear();
|
|
208
|
+
_pendingRemovals.clear();
|
|
209
|
+
const mutate = (cur) => {
|
|
166
210
|
const index = _normalizeSummaryIndex(cur);
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
211
|
+
let changed = false;
|
|
212
|
+
const rows = index.rows.filter((r) => {
|
|
213
|
+
if (removals.has(r.id) || upserts.has(r.id)) { changed = true; return false; }
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
for (const row of upserts.values()) {
|
|
217
|
+
const cleanRow = _normalizeSummaryRow(row);
|
|
218
|
+
if (!cleanRow) continue;
|
|
219
|
+
const existing = index.rows.find((r) => r.id === cleanRow.id) || null;
|
|
220
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) {
|
|
221
|
+
rows.push(existing);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
rows.push(cleanRow);
|
|
225
|
+
changed = true;
|
|
226
|
+
}
|
|
227
|
+
if (!changed) return undefined;
|
|
173
228
|
rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
174
229
|
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
175
|
-
|
|
176
|
-
|
|
230
|
+
};
|
|
231
|
+
const requeue = () => {
|
|
232
|
+
// Lock busy (or transient I/O failure) — re-queue what we took unless
|
|
233
|
+
// a newer op for the same id arrived during the flush, then retry
|
|
234
|
+
// asynchronously.
|
|
235
|
+
for (const [id, row] of upserts) {
|
|
236
|
+
if (!_pendingUpserts.has(id) && !_pendingRemovals.has(id)) _pendingUpserts.set(id, row);
|
|
237
|
+
}
|
|
238
|
+
for (const id of removals) {
|
|
239
|
+
if (!_pendingUpserts.has(id)) _pendingRemovals.add(id);
|
|
240
|
+
}
|
|
241
|
+
_scheduleSummaryRetry();
|
|
242
|
+
};
|
|
243
|
+
// Exit-drain path needs the synchronous write (the loop may not pump
|
|
244
|
+
// after 'exit'). Normal scheduled flushes on the lead/TUI main process
|
|
245
|
+
// use the async try-lock: the lock WAIT is off the event loop, so a
|
|
246
|
+
// busy multi-MB index never freezes typing/rendering.
|
|
247
|
+
if (sync) {
|
|
248
|
+
try {
|
|
249
|
+
updateJsonAtomicSync(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS });
|
|
250
|
+
} catch { requeue(); }
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
updateJsonAtomic(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS })
|
|
254
|
+
.catch(() => { requeue(); });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function _upsertSessionSummary(session) {
|
|
258
|
+
const row = _sessionSummary(session);
|
|
259
|
+
if (!row) return;
|
|
260
|
+
_pendingRemovals.delete(row.id);
|
|
261
|
+
_pendingUpserts.set(row.id, row);
|
|
262
|
+
_scheduleSummaryFlush();
|
|
177
263
|
}
|
|
178
264
|
|
|
179
265
|
export function _removeSessionSummary(id) {
|
|
180
266
|
if (!id) return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const rows = index.rows.filter((r) => r.id !== id);
|
|
185
|
-
if (rows.length === index.rows.length) return undefined;
|
|
186
|
-
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
187
|
-
}, { compact: true, lock: true });
|
|
188
|
-
} catch { /* summary index is best-effort */ }
|
|
267
|
+
_pendingUpserts.delete(id);
|
|
268
|
+
_pendingRemovals.add(id);
|
|
269
|
+
_scheduleSummaryFlush();
|
|
189
270
|
}
|
|
190
271
|
|
|
191
272
|
/**
|
|
@@ -196,12 +277,9 @@ export function _removeSessionSummary(id) {
|
|
|
196
277
|
*/
|
|
197
278
|
export function _pruneSummaryIndexIds(ids) {
|
|
198
279
|
if (!(ids instanceof Set) || ids.size === 0) return;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
205
|
-
}, { compact: true, lock: true });
|
|
206
|
-
} catch { /* summary index is best-effort */ }
|
|
280
|
+
for (const id of ids) {
|
|
281
|
+
_pendingUpserts.delete(id);
|
|
282
|
+
_pendingRemovals.add(id);
|
|
283
|
+
}
|
|
284
|
+
_scheduleSummaryFlush();
|
|
207
285
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
_upsertSessionSummary,
|
|
22
22
|
_removeSessionSummary,
|
|
23
23
|
_pruneSummaryIndexIds,
|
|
24
|
+
_flushPendingSummaryOps,
|
|
24
25
|
} from './store-summary-index.mjs';
|
|
25
26
|
// Facade re-export: summary-index API moved to store-summary-index.mjs; keep
|
|
26
27
|
// prior importers of store.mjs unchanged.
|
|
@@ -404,6 +405,10 @@ export function drainSessionStore() {
|
|
|
404
405
|
}
|
|
405
406
|
}
|
|
406
407
|
_savePending.clear();
|
|
408
|
+
// Summary-index ops queued by the deferred/no-wait flush path: give them
|
|
409
|
+
// one last best-effort flush before exit (still zero-wait; losing them is
|
|
410
|
+
// acceptable — the index self-heals on next rebuild).
|
|
411
|
+
try { _flushPendingSummaryOps({ sync: true }); } catch { /* best-effort */ }
|
|
407
412
|
// Outstanding worker-queue writes: process exit may interrupt the worker
|
|
408
413
|
// thread before it processes its message queue, so each pending payload
|
|
409
414
|
// is sync-flushed directly here. The Promise is then rejected so the
|
|
@@ -68,13 +68,17 @@ export const DEFAULT_ACTIVITY_HEARTBEAT_MS = resolveTimeoutMs(
|
|
|
68
68
|
|
|
69
69
|
export const PROVIDER_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
|
|
70
70
|
'MIXDOG_PROVIDER_FIRST_BYTE_TIMEOUT_MS',
|
|
71
|
-
// 2026-07 trace audit:
|
|
72
|
-
//
|
|
73
|
-
// window
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
71
|
+
// 2026-07-05 trace audit (24h): worker claude-sonnet-5 successful TTFT
|
|
72
|
+
// p90 43s / p99 164s / max 171s — the distribution hugs the previous 180s
|
|
73
|
+
// window, and 16/178 fetches "died" in repeated back-to-back 183s
|
|
74
|
+
// fetch→fetch cycles (3+ consecutive in the worst session). That pattern
|
|
75
|
+
// is queued-but-alive first bytes being axed AT the window and re-queued
|
|
76
|
+
// from scratch, not dead sockets: each trip doubled the wait (one worker
|
|
77
|
+
// session burned ~40min purely on 180s-abort→retry loops). Raise to the
|
|
78
|
+
// policy ceiling (STALL_WARN - tick, ~285s): models with fast first bytes
|
|
79
|
+
// never reach the timer, and a truly wedged socket is still bounded by
|
|
80
|
+
// the agent stall first-byte abort (300s, DEFAULT_STALL_FIRST_BYTE_ABORT_S).
|
|
81
|
+
PROVIDER_MAX_BEFORE_WARN_MS,
|
|
78
82
|
{ minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
79
83
|
);
|
|
80
84
|
|
|
@@ -140,11 +144,17 @@ export const PROVIDER_SSE_IDLE_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
140
144
|
// env-overridable and disablable via MIXDOG_ENABLE_STREAM_WATCHDOG=0.
|
|
141
145
|
export const PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
|
|
142
146
|
['MIXDOG_PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_SSE_IDLE_TIMEOUT_MS'],
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
147
|
+
// 2026-07-05 trace audit: effort-mode (output_config.effort) sonnet-5
|
|
148
|
+
// streams deliver NO deltas during the thinking phase — the whole
|
|
149
|
+
// thinking+text body flushes at the end (44/47 slow turns had
|
|
150
|
+
// stream_total-ttft < 2s; silent-window token rate a steady ~92 tok/s,
|
|
151
|
+
// i.e. live generation, not a wedge). Successful turns topped out at
|
|
152
|
+
// ttft 171s while 13 kills sat at exactly ~183s fetch→fetch — the old
|
|
153
|
+
// 180s window was beheading every turn whose silent thinking ran past
|
|
154
|
+
// it, then retrying the whole thinking run from zero. Raised to the
|
|
155
|
+
// policy ceiling (STALL_WARN - tick ≈ 285s); the agent stall watchdog
|
|
156
|
+
// (worker 300s) remains the true-wedge backstop just above it.
|
|
157
|
+
PROVIDER_MAX_BEFORE_WARN_MS,
|
|
148
158
|
{ minMs: 10_000, maxMs: STALL_WARN_MS },
|
|
149
159
|
);
|
|
150
160
|
|
|
@@ -117,7 +117,9 @@ export const BUILTIN_TOOLS = [
|
|
|
117
117
|
offset: { type: 'number', minimum: 0, description: 'Skip output lines for paging.' },
|
|
118
118
|
'-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
|
|
119
119
|
'-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
|
|
120
|
-
|
|
120
|
+
// Description nudges -C 5 on content modes — measured fix for
|
|
121
|
+
// grep→read same-file re-open round-trips (2026-07 diag).
|
|
122
|
+
'-C': { type: 'number', minimum: 0, description: 'Lines before/after each match. Content modes: pass -C 5+ so the hit is edit-ready; skipping it forces a follow-up read.' },
|
|
121
123
|
},
|
|
122
124
|
required: [],
|
|
123
125
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, realpathSync, readdirSync, statSync } from 'fs';
|
|
2
2
|
import * as fsPromises from 'fs/promises';
|
|
3
3
|
import { readFile } from 'fs/promises';
|
|
4
4
|
import { extname } from 'path';
|
|
@@ -26,12 +26,12 @@ function withSymbolReadNote(text, args) {
|
|
|
26
26
|
// decoding. utf8-with-BOM (EF BB BF) keeps the utf-8 decoder; its leading
|
|
27
27
|
// U+FEFF is stripped for display downstream, so bomLen is reported but not
|
|
28
28
|
// applied for utf8.
|
|
29
|
-
function detectReadEncoding(fullPath) {
|
|
30
|
-
let
|
|
29
|
+
async function detectReadEncoding(fullPath) {
|
|
30
|
+
let fh;
|
|
31
31
|
try {
|
|
32
|
-
|
|
32
|
+
fh = await fsPromises.open(fullPath, 'r');
|
|
33
33
|
const head = Buffer.alloc(3);
|
|
34
|
-
const n =
|
|
34
|
+
const { bytesRead: n } = await fh.read(head, 0, 3, 0);
|
|
35
35
|
if (n >= 2 && head[0] === 0xff && head[1] === 0xfe) {
|
|
36
36
|
return { encoding: 'utf16le', bomLen: 2 };
|
|
37
37
|
}
|
|
@@ -45,7 +45,7 @@ function detectReadEncoding(fullPath) {
|
|
|
45
45
|
} catch {
|
|
46
46
|
return { encoding: 'utf8', bomLen: 0 };
|
|
47
47
|
} finally {
|
|
48
|
-
if (
|
|
48
|
+
if (fh !== undefined) { try { await fh.close(); } catch {} }
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -260,17 +260,17 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
260
260
|
// entry stores a contentPrefixHash, recompute the current prefix and bail
|
|
261
261
|
// to a fresh read on mismatch. Helper kept local (not hoisted) so it can
|
|
262
262
|
// close over fullPath and st without an extra arg.
|
|
263
|
-
const _readPrefixHashForCacheGuard = () => {
|
|
263
|
+
const _readPrefixHashForCacheGuard = async () => {
|
|
264
264
|
try {
|
|
265
265
|
if (st.size <= 65536) {
|
|
266
|
-
return _hashText(
|
|
266
|
+
return _hashText(await readFile(fullPath, 'utf-8'));
|
|
267
267
|
}
|
|
268
|
-
const
|
|
268
|
+
const _fh = await fsPromises.open(fullPath, 'r');
|
|
269
269
|
try {
|
|
270
270
|
const _buf = Buffer.allocUnsafe(65536);
|
|
271
|
-
const _n =
|
|
271
|
+
const { bytesRead: _n } = await _fh.read(_buf, 0, 65536, 0);
|
|
272
272
|
return _hashText(_buf.subarray(0, _n));
|
|
273
|
-
} finally { try {
|
|
273
|
+
} finally { try { await _fh.close(); } catch {} }
|
|
274
274
|
} catch { return ''; }
|
|
275
275
|
};
|
|
276
276
|
const cachedEntry = _cacheGetEntry(cacheKey);
|
|
@@ -295,7 +295,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
295
295
|
// exact full contentHash when present, else the prefix hash
|
|
296
296
|
// (also full-body here). A read failure drops to fresh read.
|
|
297
297
|
try {
|
|
298
|
-
const _freshHash = _hashText(
|
|
298
|
+
const _freshHash = _hashText(await readFile(fullPath, 'utf-8'));
|
|
299
299
|
const _expect = _snapHash || _prefixHash;
|
|
300
300
|
if (!_freshHash || _freshHash !== _expect) _entryStillValid = false;
|
|
301
301
|
} catch { _entryStillValid = false; }
|
|
@@ -310,7 +310,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
310
310
|
// writes through edit/apply_patch/write invalidate by path,
|
|
311
311
|
// and shell mutationMode='global' wipes both builtin +
|
|
312
312
|
// code-graph caches, bounding stale risk past the head.
|
|
313
|
-
const _curHash = _readPrefixHashForCacheGuard();
|
|
313
|
+
const _curHash = await _readPrefixHashForCacheGuard();
|
|
314
314
|
if (!_curHash || _curHash !== _prefixHash) _entryStillValid = false;
|
|
315
315
|
}
|
|
316
316
|
}
|
|
@@ -377,7 +377,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
377
377
|
&& typeof _snap.contentHash === 'string'
|
|
378
378
|
&& _snap.contentHash) {
|
|
379
379
|
let _diskHash = '';
|
|
380
|
-
try { _diskHash = _hashText(
|
|
380
|
+
try { _diskHash = _hashText(await readFile(fullPath, 'utf-8')); } catch {}
|
|
381
381
|
if (_diskHash && _diskHash === _snap.contentHash) {
|
|
382
382
|
if (options?.suppressReadUnchangedStub !== true) {
|
|
383
383
|
return withSymbolReadNote(`[file unchanged: ${normalizeOutputPath(filePath)}]`, args);
|
|
@@ -389,7 +389,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
389
389
|
// large UTF-16LE+BOM file is recognized as text up front and routed to
|
|
390
390
|
// the bounded in-memory utf16le path below, never mis-decoded as utf-8
|
|
391
391
|
// or rejected by the utf-8 streaming/binary branch (Bug 1).
|
|
392
|
-
const _readEnc = detectReadEncoding(fullPath);
|
|
392
|
+
const _readEnc = await detectReadEncoding(fullPath);
|
|
393
393
|
// UTF-16 (LE or BE) reads share one constraint: the streaming/binary
|
|
394
394
|
// paths decode chunks as utf-8, so a BOM-flagged UTF-16 file must route
|
|
395
395
|
// to the bounded in-memory decode below regardless of byte order.
|
|
@@ -126,7 +126,7 @@ function _reviveInfinitySentinels(value) {
|
|
|
126
126
|
return value;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
function persistScopeSync(scopeKey) {
|
|
129
|
+
function persistScopeSync(scopeKey, { exitDrain = false } = {}) {
|
|
130
130
|
const path = snapshotScopeFilePath(scopeKey);
|
|
131
131
|
if (!path) return;
|
|
132
132
|
const readFiles = readFilesByScope.get(scopeKey);
|
|
@@ -141,6 +141,14 @@ function persistScopeSync(scopeKey) {
|
|
|
141
141
|
writeJsonAtomicSync(path, _withInfinitySentinels(obj), {
|
|
142
142
|
compact: true,
|
|
143
143
|
lock: true,
|
|
144
|
+
// Try-once (timeoutMs:0): scheduleScopePersist fires this from the
|
|
145
|
+
// debounced timer on the lead/TUI main process after every read/grep
|
|
146
|
+
// tool. withFileLockSync's Atomics.wait would block the loop on
|
|
147
|
+
// cross-process contention; best-effort — a busy lock just drops
|
|
148
|
+
// this flush (the next read re-schedules, exit-drain still writes).
|
|
149
|
+
// Exit drain uses a short blocking wait instead so the final flush
|
|
150
|
+
// is not silently dropped under contention (loop no longer matters).
|
|
151
|
+
timeoutMs: exitDrain ? 1000 : 0,
|
|
144
152
|
mode: 0o600,
|
|
145
153
|
fsync: false,
|
|
146
154
|
fsyncDir: false,
|
|
@@ -191,7 +199,7 @@ export function scheduleScopePersist(scopeKey) {
|
|
|
191
199
|
function flushAllScopesSync() {
|
|
192
200
|
for (const [key, timer] of persistPending) {
|
|
193
201
|
try { clearTimeout(timer); } catch {}
|
|
194
|
-
try { persistScopeSync(key); } catch {}
|
|
202
|
+
try { persistScopeSync(key, { exitDrain: true }); } catch {}
|
|
195
203
|
}
|
|
196
204
|
persistPending.clear();
|
|
197
205
|
}
|
|
@@ -301,7 +301,13 @@ function _persistDiskCodeGraphCacheNow() {
|
|
|
301
301
|
for (const [cwd, entry] of _diskCodeGraphCache) {
|
|
302
302
|
const hash = _hashCwd(cwd);
|
|
303
303
|
const file = join(dir, `${hash}.json`);
|
|
304
|
-
|
|
304
|
+
// Try-once (timeoutMs:0): the debounced flush runs on the lead/TUI main
|
|
305
|
+
// process (codeGraph tool + prewarm). withFileLockSync's Atomics.wait
|
|
306
|
+
// would freeze the renderer on cross-process cache contention. Cache is
|
|
307
|
+
// rebuildable — a busy lock just skips this flush; _scheduleDiskCodeGraphCacheFlush
|
|
308
|
+
// re-schedules on the next build. Exit drain still runs (unref'd timer
|
|
309
|
+
// cancelled + direct call).
|
|
310
|
+
writeJsonAtomicSync(file, entry, { compact: true, lock: true, timeoutMs: 0 });
|
|
305
311
|
manifest[cwd] = { hash, builtAt: entry.builtAt || Date.now() };
|
|
306
312
|
}
|
|
307
313
|
const pruned = _pruneCodeGraphManifestForBudget(manifest, dir);
|
|
@@ -314,7 +320,7 @@ function _persistDiskCodeGraphCacheNow() {
|
|
|
314
320
|
}
|
|
315
321
|
|
|
316
322
|
const manifestFile = join(dir, 'manifest.json');
|
|
317
|
-
writeJsonAtomicSync(manifestFile, manifest, { compact: true, lock: true });
|
|
323
|
+
writeJsonAtomicSync(manifestFile, manifest, { compact: true, lock: true, timeoutMs: 0 });
|
|
318
324
|
_diskManifest = manifest;
|
|
319
325
|
|
|
320
326
|
// Sweep orphan per-cwd files. validHashes now includes every hash in
|
|
@@ -5,13 +5,13 @@ import { join, sep } from "path";
|
|
|
5
5
|
|
|
6
6
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
async function downloadSingleAttachment(att, inboxDir) {
|
|
8
|
+
async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
|
|
9
9
|
if (att.size > MAX_ATTACHMENT_BYTES) {
|
|
10
10
|
throw new Error(
|
|
11
11
|
`attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
|
-
const res = await fetch(att.url, { signal: AbortSignal.timeout(
|
|
14
|
+
const res = await fetch(att.url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
15
15
|
if (!res.ok) {
|
|
16
16
|
throw new Error(`attachment download failed: HTTP ${res.status}`);
|
|
17
17
|
}
|
|
@@ -212,13 +212,23 @@ async function awaitLogin(self) {
|
|
|
212
212
|
resolve();
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
|
+
// Observe the ready timer from the start: login() can hang past the 30s
|
|
216
|
+
// timer (gateway reconnect), and a rejection on an un-awaited readyPromise
|
|
217
|
+
// becomes a process-global unhandledRejection → fatal crash path
|
|
218
|
+
// (channels/index.mjs unhandledRejection → _fatalCrash → exit(1)). Racing
|
|
219
|
+
// login with readyPromise keeps the rejection inside this connect() chain,
|
|
220
|
+
// which every caller already catches non-fatally.
|
|
221
|
+
const loginPromise = self.client.login(self.token);
|
|
222
|
+
// A late login settlement after the timeout raced ahead must not itself
|
|
223
|
+
// become a second unhandled rejection.
|
|
224
|
+
loginPromise.catch(() => {});
|
|
215
225
|
try {
|
|
216
|
-
await
|
|
226
|
+
await Promise.race([loginPromise, readyPromise]);
|
|
227
|
+
await readyPromise;
|
|
217
228
|
} catch (err) {
|
|
218
229
|
clearTimeout(readyTimeout);
|
|
219
230
|
throw err;
|
|
220
231
|
}
|
|
221
|
-
await readyPromise;
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
export {
|