mixdog 0.9.53 → 0.9.55
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/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
// Extracted verbatim from manager.mjs (behavior-preserving).
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { readFileSync } from 'fs';
|
|
5
|
+
import { createHash, randomBytes } from 'crypto';
|
|
5
6
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
6
7
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
7
8
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
8
|
-
import { loadSession } from '../store.mjs';
|
|
9
|
+
import { loadSession, saveSessionAsync } from '../store.mjs';
|
|
9
10
|
import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
|
|
10
11
|
|
|
11
12
|
const _sessionPendingMessages = new Map();
|
|
13
|
+
// Persisted entries are claimed once, asynchronously, when askSession takes
|
|
14
|
+
// ownership of a session. Hot-path drains consume this in-memory snapshot and
|
|
15
|
+
// never touch the global spool (or its cross-process lock).
|
|
16
|
+
const _hydratedPendingMessages = new Map();
|
|
12
17
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
13
18
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
14
19
|
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -19,8 +24,33 @@ const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
|
19
24
|
// user/steering messages carry no marker and keep full queue + replay behavior.
|
|
20
25
|
export const COMPLETION_NOTIFICATION_KIND = 'completion_notification';
|
|
21
26
|
const _pendingPersistBuffers = new Map();
|
|
27
|
+
const _pendingPersistTails = new Map();
|
|
28
|
+
const _inDeliveryPendingIds = new Map();
|
|
29
|
+
const _ackedPendingIds = new Map();
|
|
30
|
+
const _pendingHydrations = new Map();
|
|
22
31
|
let _pendingPersistImmediate = null;
|
|
23
32
|
|
|
33
|
+
function pendingIdSet(map, sessionId) {
|
|
34
|
+
let ids = map.get(sessionId);
|
|
35
|
+
if (!ids) {
|
|
36
|
+
ids = new Set();
|
|
37
|
+
map.set(sessionId, ids);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function newPendingMessageId() {
|
|
43
|
+
return randomBytes(12).toString('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pendingMessageId(entry) {
|
|
47
|
+
return typeof entry?.id === 'string' && entry.id ? entry.id : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function legacyPendingMessageId(sessionId, index, value) {
|
|
51
|
+
return `legacy_${createHash('sha256').update(`${sessionId}:${index}:${value}`).digest('hex').slice(0, 24)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
function isCompletionNotificationEntry(entry) {
|
|
25
55
|
return Boolean(entry) && typeof entry === 'object'
|
|
26
56
|
&& entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
|
|
@@ -81,10 +111,24 @@ function normalizeTuiSteeringQueueEntry(entry) {
|
|
|
81
111
|
return text || null;
|
|
82
112
|
}
|
|
83
113
|
if (!entry || typeof entry !== 'object') return null;
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
const rawText = typeof entry.text === 'string'
|
|
115
|
+
? entry.text
|
|
116
|
+
: (typeof entry.message === 'string'
|
|
117
|
+
? entry.message
|
|
118
|
+
: (typeof entry.content === 'string' ? entry.content : ''));
|
|
119
|
+
if (rawText.trim()) {
|
|
120
|
+
const text = rawText.trim();
|
|
86
121
|
const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null;
|
|
87
|
-
|
|
122
|
+
if (!id) return text;
|
|
123
|
+
const normalized = {
|
|
124
|
+
id,
|
|
125
|
+
text,
|
|
126
|
+
message: text,
|
|
127
|
+
enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
|
|
128
|
+
};
|
|
129
|
+
return entry.notificationKind === COMPLETION_NOTIFICATION_KIND
|
|
130
|
+
? { ...normalized, notificationKind: COMPLETION_NOTIFICATION_KIND }
|
|
131
|
+
: normalized;
|
|
88
132
|
}
|
|
89
133
|
return null;
|
|
90
134
|
}
|
|
@@ -102,7 +146,10 @@ function normalizePendingStore(raw) {
|
|
|
102
146
|
if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
|
|
103
147
|
const q = isTuiSteeringPendingKey(sid)
|
|
104
148
|
? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
|
|
105
|
-
: value.map(normalizePersistedEntry
|
|
149
|
+
: value.map((entry, index) => normalizePersistedEntry(entry, {
|
|
150
|
+
legacyId: legacyPendingMessageId(sid, index, typeof entry === 'string' ? entry : JSON.stringify(entry)),
|
|
151
|
+
fallbackEnqueuedAt: storeUpdatedAt + index,
|
|
152
|
+
})).filter(Boolean);
|
|
106
153
|
if (q.length > 0) {
|
|
107
154
|
out.sessions[sid] = q;
|
|
108
155
|
const touched = Number(touchedRaw[sid]);
|
|
@@ -128,10 +175,18 @@ function normalizePendingMessageEntry(entry) {
|
|
|
128
175
|
return { content: entry, text };
|
|
129
176
|
}
|
|
130
177
|
if (!entry || typeof entry !== 'object') return null;
|
|
178
|
+
const identity = {
|
|
179
|
+
id: pendingMessageId(entry),
|
|
180
|
+
enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
|
|
181
|
+
};
|
|
131
182
|
const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
|
|
132
183
|
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
|
|
133
184
|
: null;
|
|
134
|
-
const content = Object.prototype.hasOwnProperty.call(entry, 'content')
|
|
185
|
+
const content = Object.prototype.hasOwnProperty.call(entry, 'content')
|
|
186
|
+
? entry.content
|
|
187
|
+
: (typeof entry.message === 'string'
|
|
188
|
+
? entry.message
|
|
189
|
+
: (typeof entry.text === 'string' ? entry.text : null));
|
|
135
190
|
if (content == null) return null;
|
|
136
191
|
const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
|
|
137
192
|
let out = null;
|
|
@@ -143,7 +198,8 @@ function normalizePendingMessageEntry(entry) {
|
|
|
143
198
|
const fallback = promptContentText(content).trim();
|
|
144
199
|
out = fallback ? { content: fallback, text: text || fallback } : null;
|
|
145
200
|
}
|
|
146
|
-
|
|
201
|
+
if (!out) return null;
|
|
202
|
+
return marker ? { ...out, ...identity, ...marker } : { ...out, ...identity };
|
|
147
203
|
}
|
|
148
204
|
|
|
149
205
|
function pendingMessageText(entry) {
|
|
@@ -154,11 +210,14 @@ function pendingMessageText(entry) {
|
|
|
154
210
|
function pendingMessageQueueEntry(entry) {
|
|
155
211
|
const normalized = normalizePendingMessageEntry(entry);
|
|
156
212
|
if (!normalized) return null;
|
|
213
|
+
const identity = {
|
|
214
|
+
id: normalized.id || newPendingMessageId(),
|
|
215
|
+
enqueuedAt: Number(normalized.enqueuedAt) || Date.now(),
|
|
216
|
+
};
|
|
157
217
|
const marker = isCompletionNotificationEntry(normalized)
|
|
158
218
|
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
|
|
159
219
|
: null;
|
|
160
|
-
|
|
161
|
-
const base = { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
220
|
+
const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
162
221
|
return marker ? { ...base, ...marker } : base;
|
|
163
222
|
}
|
|
164
223
|
|
|
@@ -167,20 +226,32 @@ function pendingMessageQueueEntry(entry) {
|
|
|
167
226
|
// notifications so the marker survives an on-disk round trip. Accepts either an
|
|
168
227
|
// in-memory queue entry (content/text) or an already-persisted entry
|
|
169
228
|
// (string | { message } legacy | marked object); back-compatible with both.
|
|
170
|
-
function normalizePersistedEntry(entry) {
|
|
171
|
-
if (typeof entry === 'string') {
|
|
229
|
+
function normalizePersistedEntry(entry, options = {}) {
|
|
230
|
+
if (typeof entry === 'string') {
|
|
231
|
+
const message = entry.trim();
|
|
232
|
+
return message ? {
|
|
233
|
+
id: options.legacyId || newPendingMessageId(),
|
|
234
|
+
message,
|
|
235
|
+
enqueuedAt: Number(options.fallbackEnqueuedAt) || Date.now(),
|
|
236
|
+
} : null;
|
|
237
|
+
}
|
|
172
238
|
if (!entry || typeof entry !== 'object') return null;
|
|
239
|
+
const id = pendingMessageId(entry) || options.legacyId || newPendingMessageId();
|
|
240
|
+
const enqueuedAt = Number(entry.enqueuedAt) || Number(options.fallbackEnqueuedAt) || Date.now();
|
|
173
241
|
if (isCompletionNotificationEntry(entry)) {
|
|
174
242
|
const message = (typeof entry.message === 'string' && entry.message.trim())
|
|
175
243
|
? entry.message.trim()
|
|
176
244
|
: pendingMessageText(entry);
|
|
177
245
|
return message
|
|
178
|
-
? { message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt
|
|
246
|
+
? { id, message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt }
|
|
179
247
|
: null;
|
|
180
248
|
}
|
|
181
|
-
if (typeof entry.message === 'string') {
|
|
249
|
+
if (typeof entry.message === 'string') {
|
|
250
|
+
const message = entry.message.trim();
|
|
251
|
+
return message ? { id, message, enqueuedAt } : null;
|
|
252
|
+
}
|
|
182
253
|
const t = pendingMessageText(entry);
|
|
183
|
-
return t
|
|
254
|
+
return t ? { id, message: t, enqueuedAt } : null;
|
|
184
255
|
}
|
|
185
256
|
|
|
186
257
|
function persistPendingMessages(sessionId, messages) {
|
|
@@ -194,7 +265,7 @@ function persistPendingMessages(sessionId, messages) {
|
|
|
194
265
|
// process contention on the shared spool never freezes the renderer.
|
|
195
266
|
// Best-effort: the returned promise is fire-and-forget; depth is reported
|
|
196
267
|
// optimistically from the buffered batch length.
|
|
197
|
-
updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
268
|
+
const operation = updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
198
269
|
const next = normalizePendingStore(raw);
|
|
199
270
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
200
271
|
q.push(...persistedMessages);
|
|
@@ -210,11 +281,16 @@ function persistPendingMessages(sessionId, messages) {
|
|
|
210
281
|
// were already cleared by the flush, so push them back so the next
|
|
211
282
|
// scheduled flush or session takeover retries instead of losing them.
|
|
212
283
|
try {
|
|
284
|
+
const acked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
213
285
|
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
214
|
-
q.push(...persistedMessages);
|
|
286
|
+
q.push(...persistedMessages.filter((entry) => !acked.has(pendingMessageId(entry))));
|
|
215
287
|
_pendingPersistBuffers.set(sessionId, q);
|
|
216
288
|
} catch {}
|
|
217
289
|
});
|
|
290
|
+
_pendingPersistTails.set(sessionId, operation);
|
|
291
|
+
operation.finally(() => {
|
|
292
|
+
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
293
|
+
}).catch(() => {});
|
|
218
294
|
return persistedMessages.length;
|
|
219
295
|
}
|
|
220
296
|
|
|
@@ -255,28 +331,188 @@ function takeBufferedPendingMessages(sessionId) {
|
|
|
255
331
|
return buffered.slice();
|
|
256
332
|
}
|
|
257
333
|
|
|
258
|
-
function
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
334
|
+
export function acknowledgePendingMessages(sessionId, deliveredEntries) {
|
|
335
|
+
const ids = new Set((Array.isArray(deliveredEntries) ? deliveredEntries : [])
|
|
336
|
+
.map(pendingMessageId).filter(Boolean));
|
|
337
|
+
if (ids.size === 0) return Promise.resolve(false);
|
|
338
|
+
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
339
|
+
const acked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
340
|
+
for (const id of ids) { inDelivery.delete(id); acked.add(id); }
|
|
341
|
+
const purgeMemory = () => {
|
|
342
|
+
for (const map of [_pendingPersistBuffers, _sessionPendingMessages, _hydratedPendingMessages]) {
|
|
343
|
+
const q = map.get(sessionId);
|
|
344
|
+
if (!Array.isArray(q)) continue;
|
|
345
|
+
const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
|
|
346
|
+
if (kept.length > 0) map.set(sessionId, kept);
|
|
347
|
+
else map.delete(sessionId);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
purgeMemory();
|
|
351
|
+
const precedingPersist = _pendingPersistTails.get(sessionId) || Promise.resolve();
|
|
352
|
+
const operation = precedingPersist.catch(() => {}).then(() => {
|
|
353
|
+
// A failed preceding persist may have requeued after the first purge.
|
|
354
|
+
purgeMemory();
|
|
355
|
+
return updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
356
|
+
const next = normalizePendingStore(raw);
|
|
357
|
+
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
358
|
+
const kept = q.filter((entry) => !ids.has(pendingMessageId(entry)));
|
|
359
|
+
const removed = q.length - kept.length;
|
|
360
|
+
if (removed === 0) return undefined;
|
|
361
|
+
if (kept.length > 0) next.sessions[sessionId] = kept;
|
|
362
|
+
else {
|
|
271
363
|
delete next.sessions[sessionId];
|
|
272
364
|
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
273
|
-
|
|
274
|
-
|
|
365
|
+
}
|
|
366
|
+
next.updatedAt = Date.now();
|
|
367
|
+
return next;
|
|
275
368
|
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
276
|
-
}
|
|
277
|
-
|
|
369
|
+
});
|
|
370
|
+
_pendingPersistTails.set(sessionId, operation);
|
|
371
|
+
const reported = operation.then(() => true).catch((err) => {
|
|
372
|
+
try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
373
|
+
return false;
|
|
374
|
+
}).finally(() => {
|
|
375
|
+
const currentAcked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
376
|
+
for (const id of ids) currentAcked.delete(id);
|
|
377
|
+
if (currentAcked.size === 0) _ackedPendingIds.delete(sessionId);
|
|
378
|
+
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
379
|
+
});
|
|
380
|
+
return reported;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function recordPendingMessageDelivery(session, deliveredEntries) {
|
|
384
|
+
if (!session || !Array.isArray(deliveredEntries) || deliveredEntries.length === 0) return;
|
|
385
|
+
const added = deliveredEntries.map(pendingMessageId).filter(Boolean);
|
|
386
|
+
if (added.length === 0) return;
|
|
387
|
+
const ledger = Array.isArray(session.deliveredPendingMessageIds)
|
|
388
|
+
? session.deliveredPendingMessageIds.filter((id) => typeof id === 'string' && id)
|
|
389
|
+
: [];
|
|
390
|
+
// This may temporarily exceed the nominal bound while spool cleanup is
|
|
391
|
+
// failing. Never evict an ID whose durable spool copy may still exist.
|
|
392
|
+
session.deliveredPendingMessageIds = [...new Set([...ledger, ...added])];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function pruneCleanupConfirmedLedger(sessionId, confirmedEntries, session = null, persist = null) {
|
|
396
|
+
const confirmedIds = new Set((Array.isArray(confirmedEntries) ? confirmedEntries : [])
|
|
397
|
+
.map(pendingMessageId).filter(Boolean));
|
|
398
|
+
if (confirmedIds.size === 0) return false;
|
|
399
|
+
const target = session || loadSession(sessionId);
|
|
400
|
+
if (!target) return false;
|
|
401
|
+
const ledger = Array.isArray(target.deliveredPendingMessageIds)
|
|
402
|
+
? target.deliveredPendingMessageIds.filter((id) => typeof id === 'string' && id)
|
|
403
|
+
: [];
|
|
404
|
+
const kept = ledger.filter((id) => !confirmedIds.has(id));
|
|
405
|
+
if (kept.length === ledger.length) return false;
|
|
406
|
+
// Confirmed IDs need no replay protection and are removed immediately
|
|
407
|
+
// (therefore bounded below any finite confirmed-ID retention cap).
|
|
408
|
+
// Unconfirmed IDs are never size-evicted.
|
|
409
|
+
target.deliveredPendingMessageIds = kept;
|
|
410
|
+
if (typeof persist === 'function') await persist();
|
|
411
|
+
else await saveSessionAsync(target, { expectedGeneration: target.generation });
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function finalizePendingMessageDelivery(session, deliveredEntries, durableSave, persistPrunedLedger) {
|
|
416
|
+
const ids = new Set((Array.isArray(deliveredEntries) ? deliveredEntries : [])
|
|
417
|
+
.map(pendingMessageId).filter(Boolean));
|
|
418
|
+
if (!session || ids.size === 0) return Promise.resolve(false);
|
|
419
|
+
// Strict durability order: ledger/session first, spool deletion second.
|
|
420
|
+
// Both operations are detached from the completion tick.
|
|
421
|
+
return Promise.resolve(durableSave).then(async () => {
|
|
422
|
+
const cleaned = await acknowledgePendingMessages(session.id, deliveredEntries);
|
|
423
|
+
if (!cleaned) return false;
|
|
424
|
+
await pruneCleanupConfirmedLedger(
|
|
425
|
+
session.id,
|
|
426
|
+
deliveredEntries,
|
|
427
|
+
session,
|
|
428
|
+
persistPrunedLedger,
|
|
429
|
+
);
|
|
430
|
+
return true;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function releasePendingMessages(sessionId, deliveredEntries) {
|
|
435
|
+
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
436
|
+
for (const entry of Array.isArray(deliveredEntries) ? deliveredEntries : []) {
|
|
437
|
+
const id = pendingMessageId(entry);
|
|
438
|
+
if (id) inDelivery.delete(id);
|
|
278
439
|
}
|
|
279
|
-
|
|
440
|
+
if (inDelivery.size === 0) _inDeliveryPendingIds.delete(sessionId);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function hydratePendingMessages(sessionId, options = {}) {
|
|
444
|
+
if (!isValidPendingSessionId(sessionId)) return Promise.resolve(0);
|
|
445
|
+
const existingHydration = _pendingHydrations.get(sessionId);
|
|
446
|
+
if (existingHydration) return existingHydration;
|
|
447
|
+
const hydration = (async () => {
|
|
448
|
+
const precedingPersist = _pendingPersistTails.get(sessionId);
|
|
449
|
+
if (precedingPersist) await precedingPersist.catch(() => {});
|
|
450
|
+
let hydrated = [];
|
|
451
|
+
let alreadyDelivered = [];
|
|
452
|
+
let staleLedgerEntries = [];
|
|
453
|
+
const ledgerSession = loadSession(sessionId);
|
|
454
|
+
try {
|
|
455
|
+
const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
|
|
456
|
+
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
457
|
+
const acked = pendingIdSet(_ackedPendingIds, sessionId);
|
|
458
|
+
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
459
|
+
const next = normalizePendingStore(raw);
|
|
460
|
+
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
461
|
+
const spoolIds = new Set(q.map(pendingMessageId).filter(Boolean));
|
|
462
|
+
// Ledger IDs only suppress matching durable spool entries. If no
|
|
463
|
+
// such entry exists, cleanup was already completed (possibly just
|
|
464
|
+
// before a crash) and the ledger ID is structurally stale.
|
|
465
|
+
staleLedgerEntries = [...deliveredLedger]
|
|
466
|
+
.filter((id) => !spoolIds.has(id))
|
|
467
|
+
.map((id) => ({ id }));
|
|
468
|
+
hydrated = q.filter((entry) => {
|
|
469
|
+
const id = pendingMessageId(entry);
|
|
470
|
+
if (id && deliveredLedger.has(id)) {
|
|
471
|
+
alreadyDelivered.push(entry);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
return id && !inDelivery.has(id) && !acked.has(id);
|
|
475
|
+
});
|
|
476
|
+
// Read-only claim: durable data remains until successful delivery
|
|
477
|
+
// acknowledges these exact ids. A crash here therefore redelivers.
|
|
478
|
+
return undefined;
|
|
479
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
480
|
+
await options.beforePublish?.(hydrated);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
try { process.stderr.write(`[session] pending-message hydrate failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
const cleanupConfirmed = staleLedgerEntries.slice();
|
|
486
|
+
if (alreadyDelivered.length > 0) {
|
|
487
|
+
const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
|
|
488
|
+
if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
|
|
489
|
+
}
|
|
490
|
+
if (cleanupConfirmed.length > 0) {
|
|
491
|
+
try {
|
|
492
|
+
// One session save prunes both IDs whose replay spool was removed
|
|
493
|
+
// now and IDs whose spool was already absent at hydration start.
|
|
494
|
+
await pruneCleanupConfirmedLedger(sessionId, cleanupConfirmed, ledgerSession);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
try { process.stderr.write(`[session] pending-message ledger prune failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (hydrated.length > 0) {
|
|
500
|
+
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
501
|
+
for (const entry of hydrated) {
|
|
502
|
+
const id = pendingMessageId(entry);
|
|
503
|
+
if (id) inDelivery.add(id);
|
|
504
|
+
}
|
|
505
|
+
const existing = _hydratedPendingMessages.get(sessionId) || [];
|
|
506
|
+
existing.push(...hydrated);
|
|
507
|
+
_hydratedPendingMessages.set(sessionId, existing);
|
|
508
|
+
}
|
|
509
|
+
return hydrated.length;
|
|
510
|
+
})();
|
|
511
|
+
_pendingHydrations.set(sessionId, hydration);
|
|
512
|
+
hydration.finally(() => {
|
|
513
|
+
if (_pendingHydrations.get(sessionId) === hydration) _pendingHydrations.delete(sessionId);
|
|
514
|
+
}).catch(() => {});
|
|
515
|
+
return hydration;
|
|
280
516
|
}
|
|
281
517
|
|
|
282
518
|
function clearPersistedPendingMessages(sessionId) {
|
|
@@ -391,7 +627,9 @@ export function _mergePendingMessageEntries(entries) {
|
|
|
391
627
|
}
|
|
392
628
|
|
|
393
629
|
export function enqueuePendingMessage(sessionId, message) {
|
|
394
|
-
const
|
|
630
|
+
const normalized = pendingMessageQueueEntry(message);
|
|
631
|
+
// Caller-provided ids are never trusted across sessions/processes.
|
|
632
|
+
const entry = normalized ? { ...normalized, id: newPendingMessageId() } : null;
|
|
395
633
|
if (!sessionId || !entry) return 0;
|
|
396
634
|
let q = _sessionPendingMessages.get(sessionId);
|
|
397
635
|
if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
|
|
@@ -404,11 +642,12 @@ export function drainPendingMessages(sessionId) {
|
|
|
404
642
|
const q = _sessionPendingMessages.get(sessionId);
|
|
405
643
|
const memory = q && q.length > 0 ? q.slice() : [];
|
|
406
644
|
_sessionPendingMessages.delete(sessionId);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
645
|
+
const hydrated = _hydratedPendingMessages.get(sessionId) || [];
|
|
646
|
+
_hydratedPendingMessages.delete(sessionId);
|
|
647
|
+
// FIFO: takeover-hydrated disk entries predate the not-yet-flushed buffer.
|
|
648
|
+
// This path is deliberately memory-only: no file lock, stat, parse, or
|
|
649
|
+
// atomic rename can run in the agent-completion tick.
|
|
650
|
+
const buffered = takeBufferedPendingMessages(sessionId);
|
|
412
651
|
// Deferred completion/task notifications are dropped ONLY from the persisted
|
|
413
652
|
// (disk/buffer) path. Those are the entries a later session resume/restart
|
|
414
653
|
// would replay out-of-order into a future turn, once the in-memory queue is
|
|
@@ -433,30 +672,40 @@ export function drainPendingMessages(sessionId) {
|
|
|
433
672
|
}
|
|
434
673
|
return true;
|
|
435
674
|
});
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const text = pendingMessageText(m);
|
|
455
|
-
if (!text || seen.has(text)) continue;
|
|
456
|
-
out.push(m);
|
|
457
|
-
seen.add(text);
|
|
675
|
+
const tagged = [
|
|
676
|
+
...hydrated.map((entry, index) => ({ entry, source: 0, index })),
|
|
677
|
+
...buffered.map((entry, index) => ({ entry, source: 1, index })),
|
|
678
|
+
...memoryKept.map((entry, index) => ({ entry, source: 2, index })),
|
|
679
|
+
];
|
|
680
|
+
tagged.sort((a, b) => {
|
|
681
|
+
const at = Number(a.entry?.enqueuedAt) || 0;
|
|
682
|
+
const bt = Number(b.entry?.enqueuedAt) || 0;
|
|
683
|
+
return at - bt || a.source - b.source || a.index - b.index;
|
|
684
|
+
});
|
|
685
|
+
const byId = new Map();
|
|
686
|
+
for (const item of tagged) {
|
|
687
|
+
const normalized = pendingMessageQueueEntry(item.entry);
|
|
688
|
+
if (!normalized?.id) continue;
|
|
689
|
+
// Prefer the live form for duplicate spool/buffer copies; content and id
|
|
690
|
+
// are identical, but the live completion marker is authoritative.
|
|
691
|
+
const prior = byId.get(normalized.id);
|
|
692
|
+
if (!prior || item.source > prior.source) byId.set(normalized.id, { ...item, entry: normalized });
|
|
458
693
|
}
|
|
459
|
-
|
|
694
|
+
const ordered = [...byId.values()].sort((a, b) => {
|
|
695
|
+
const at = Number(a.entry.enqueuedAt) || 0;
|
|
696
|
+
const bt = Number(b.entry.enqueuedAt) || 0;
|
|
697
|
+
return at - bt || a.source - b.source || a.index - b.index;
|
|
698
|
+
});
|
|
699
|
+
const dropped = ordered.filter(({ entry, source }) => source === 0
|
|
700
|
+
&& (isCompletionNotificationEntry(entry) || isLegacyUnmarkedCompletionNotification(pendingMessageText(entry))))
|
|
701
|
+
.map(({ entry }) => entry);
|
|
702
|
+
if (dropped.length > 0) acknowledgePendingMessages(sessionId, dropped);
|
|
703
|
+
const visible = modelVisiblePendingMessages(ordered
|
|
704
|
+
.filter(({ entry }) => !dropped.includes(entry))
|
|
705
|
+
.map(({ entry }) => entry));
|
|
706
|
+
const inDelivery = pendingIdSet(_inDeliveryPendingIds, sessionId);
|
|
707
|
+
for (const entry of visible) if (entry.id) inDelivery.add(entry.id);
|
|
708
|
+
return visible;
|
|
460
709
|
}
|
|
461
710
|
|
|
462
711
|
// Snapshot queued entries without draining them. Compaction uses this to keep
|
|
@@ -494,7 +743,11 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
|
494
743
|
}
|
|
495
744
|
}
|
|
496
745
|
try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
|
|
746
|
+
try { _hydratedPendingMessages.delete(id); } catch { /* ignore */ }
|
|
497
747
|
try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
|
|
748
|
+
try { _inDeliveryPendingIds.delete(id); } catch { /* ignore */ }
|
|
749
|
+
try { _ackedPendingIds.delete(id); } catch { /* ignore */ }
|
|
750
|
+
try { _pendingHydrations.delete(id); } catch { /* ignore */ }
|
|
498
751
|
if (clearPersisted) {
|
|
499
752
|
try { clearPersistedPendingMessages(id); } catch { /* ignore */ }
|
|
500
753
|
}
|
|
@@ -124,7 +124,7 @@ function finalizeInterruptedTurn({
|
|
|
124
124
|
const pairedMessages = sanitizeToolPairs(messages);
|
|
125
125
|
// Claude Code omits the synthetic marker when a queued user submission
|
|
126
126
|
// interrupted the active request; that queued message is the boundary.
|
|
127
|
-
if (abortReason !== 'interrupt') {
|
|
127
|
+
if (abortReason !== 'interrupt' && abortReason !== 'provider-error') {
|
|
128
128
|
pairedMessages.push({
|
|
129
129
|
role: 'user',
|
|
130
130
|
content: phase === 'tools'
|
|
@@ -138,6 +138,7 @@ function finalizeInterruptedTurn({
|
|
|
138
138
|
export function createTurnInterruptionTracker() {
|
|
139
139
|
let responseStarted = false;
|
|
140
140
|
let partialAssistantContent = '';
|
|
141
|
+
let tombstonedAssistantContent = '';
|
|
141
142
|
let partialReasoningContent = '';
|
|
142
143
|
let phase = 'streaming';
|
|
143
144
|
const observedToolCalls = new Map();
|
|
@@ -150,6 +151,26 @@ export function createTurnInterruptionTracker() {
|
|
|
150
151
|
responseStarted = true;
|
|
151
152
|
partialAssistantContent += value;
|
|
152
153
|
},
|
|
154
|
+
tombstoneText(chars) {
|
|
155
|
+
const count = Math.max(0, Number(chars) || 0);
|
|
156
|
+
if (!count) return;
|
|
157
|
+
const cutAt = Math.max(0, partialAssistantContent.length - count);
|
|
158
|
+
tombstonedAssistantContent = partialAssistantContent.slice(cutAt)
|
|
159
|
+
+ tombstonedAssistantContent;
|
|
160
|
+
partialAssistantContent = partialAssistantContent.slice(
|
|
161
|
+
0,
|
|
162
|
+
cutAt,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
restoreTombstonedText() {
|
|
166
|
+
if (!tombstonedAssistantContent) return false;
|
|
167
|
+
partialAssistantContent += tombstonedAssistantContent;
|
|
168
|
+
tombstonedAssistantContent = '';
|
|
169
|
+
return true;
|
|
170
|
+
},
|
|
171
|
+
hasResponseStarted() {
|
|
172
|
+
return responseStarted;
|
|
173
|
+
},
|
|
153
174
|
recordReasoningDelta(chunk) {
|
|
154
175
|
const value = String(chunk ?? '');
|
|
155
176
|
if (!value) return;
|
|
@@ -166,6 +187,7 @@ export function createTurnInterruptionTracker() {
|
|
|
166
187
|
},
|
|
167
188
|
markAssistantMessageCommitted() {
|
|
168
189
|
partialAssistantContent = '';
|
|
190
|
+
tombstonedAssistantContent = '';
|
|
169
191
|
partialReasoningContent = '';
|
|
170
192
|
observedToolCalls.clear();
|
|
171
193
|
},
|
|
@@ -347,6 +347,8 @@ let _saveAsyncQueued = new Map();
|
|
|
347
347
|
let _saveAsyncInflight = new Map();
|
|
348
348
|
let _saveWorkerReqId = 0;
|
|
349
349
|
let _saveWorkerRefCount = 0;
|
|
350
|
+
let _deferredSaveReqId = 0;
|
|
351
|
+
const _deferredSessionSaves = new Map();
|
|
350
352
|
|
|
351
353
|
function _getOrSpawnWorker() {
|
|
352
354
|
if (_saveWorker) return _saveWorker;
|
|
@@ -482,7 +484,7 @@ export function saveSessionAsync(session, opts) {
|
|
|
482
484
|
setLiveSession(session);
|
|
483
485
|
const id = session.id;
|
|
484
486
|
const summaryVersion = _cacheSessionSummary(session);
|
|
485
|
-
const safeOpts = _guardedSaveOptions(id, opts);
|
|
487
|
+
const safeOpts = opts?._sessionWriteGuard ? opts : _guardedSaveOptions(id, opts);
|
|
486
488
|
// The Worker `postMessage` below structured-clones the whole session on the
|
|
487
489
|
// main thread. `session.liveTurnMessages` (live working transcript) and
|
|
488
490
|
// `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
|
|
@@ -531,6 +533,31 @@ export function saveSessionAsync(session, opts) {
|
|
|
531
533
|
});
|
|
532
534
|
}
|
|
533
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Register a save for the exit drain now, but yield one check phase before
|
|
538
|
+
* Worker.postMessage performs its main-thread structured clone.
|
|
539
|
+
*/
|
|
540
|
+
export function saveSessionAsyncDeferred(session, opts) {
|
|
541
|
+
_ensureLifecycleFields(session);
|
|
542
|
+
setLiveSession(session);
|
|
543
|
+
_cacheSessionSummary(session);
|
|
544
|
+
const reqId = ++_deferredSaveReqId;
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
_deferredSessionSaves.set(reqId, {
|
|
547
|
+
session,
|
|
548
|
+
opts: _guardedSaveOptions(session.id, opts),
|
|
549
|
+
resolve,
|
|
550
|
+
reject,
|
|
551
|
+
});
|
|
552
|
+
setImmediate(() => {
|
|
553
|
+
const pending = _deferredSessionSaves.get(reqId);
|
|
554
|
+
if (!pending) return;
|
|
555
|
+
_deferredSessionSaves.delete(reqId);
|
|
556
|
+
saveSessionAsync(pending.session, pending.opts).then(resolve, reject);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
534
561
|
/**
|
|
535
562
|
* Exported for save-session-worker — not part of the public API.
|
|
536
563
|
* External callers should use saveSession / saveSessionAsync.
|
|
@@ -627,6 +654,13 @@ export function drainSessionStore() {
|
|
|
627
654
|
}
|
|
628
655
|
}
|
|
629
656
|
_savePending.clear();
|
|
657
|
+
// Invalidate older worker writes for sessions whose newer terminal snapshot
|
|
658
|
+
// is deferred, then wait out any commit already holding the write guard.
|
|
659
|
+
for (const [, pending] of _deferredSessionSaves) {
|
|
660
|
+
_cancelSessionWrites(pending.session.id);
|
|
661
|
+
_waitForWriteCommit(pending.session.id);
|
|
662
|
+
pending.opts = _guardedSaveOptions(pending.session.id, pending.opts);
|
|
663
|
+
}
|
|
630
664
|
// Summary-index ops queued by the deferred/no-wait flush path: give them
|
|
631
665
|
// one last best-effort flush before exit (still zero-wait; losing them is
|
|
632
666
|
// acceptable — the index self-heals on next rebuild).
|
|
@@ -662,6 +696,17 @@ export function drainSessionStore() {
|
|
|
662
696
|
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
663
697
|
}
|
|
664
698
|
}
|
|
699
|
+
// Terminal/deferred snapshots are newest and must be written LAST.
|
|
700
|
+
for (const [, pending] of _deferredSessionSaves) {
|
|
701
|
+
try {
|
|
702
|
+
_saveSessionSync(pending.session, pending.opts);
|
|
703
|
+
pending.resolve();
|
|
704
|
+
} catch (err) {
|
|
705
|
+
pending.reject(err);
|
|
706
|
+
process.stderr.write(`[session-store] drain deferred save failed: ${err?.message}\n`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
_deferredSessionSaves.clear();
|
|
665
710
|
_saveWorkerPending.clear();
|
|
666
711
|
_saveAsyncQueued.clear();
|
|
667
712
|
_saveAsyncInflight.clear();
|