mixdog 0.9.38 → 0.9.39
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 +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +38 -2
- package/scripts/explore-prompt-policy-test.mjs +152 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +50 -60
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -45
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/runtime-core.mjs +35 -2
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +517 -142
- package/src/tui/engine/agent-job-feed.mjs +144 -17
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +19 -1
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +77 -42
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -99,6 +99,212 @@ export function estimateMessagesTokens(messages) {
|
|
|
99
99
|
return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Context status is polled while the agent loop mutates and replaces message
|
|
103
|
+
// arrays. Keep the accumulated summary on that array, but cheaply validate
|
|
104
|
+
// every entry before reusing its contribution. The fingerprint deliberately
|
|
105
|
+
// avoids serializing content/blocks on the warm path; it compares the
|
|
106
|
+
// references of every estimator-visible string instead.
|
|
107
|
+
// Producer invariant: compaction copies message/call objects, transcript repair
|
|
108
|
+
// replaces array entries, stored-tool-args replaces `arguments`, and MCP reload
|
|
109
|
+
// replaces tool descriptors; settled nested non-string payloads are not mutated
|
|
110
|
+
// in place without replacing their containing reference.
|
|
111
|
+
const contextMessageMemo = new WeakMap();
|
|
112
|
+
const contextTranscriptMemo = new WeakMap();
|
|
113
|
+
|
|
114
|
+
function contextValueFingerprint(value) {
|
|
115
|
+
if (typeof value === 'string') return { value, entries: null };
|
|
116
|
+
if (Array.isArray(value)) return {
|
|
117
|
+
value,
|
|
118
|
+
entries: value.map(contextBlockFingerprint),
|
|
119
|
+
};
|
|
120
|
+
return value && typeof value === 'object'
|
|
121
|
+
? { value, entries: [contextBlockFingerprint(value)] }
|
|
122
|
+
: { value, entries: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function contextBlockFingerprint(block) {
|
|
126
|
+
if (!block || typeof block !== 'object') return { value: block };
|
|
127
|
+
const fn = block.function;
|
|
128
|
+
return {
|
|
129
|
+
value: block,
|
|
130
|
+
text: typeof block.text === 'string' ? block.text : null,
|
|
131
|
+
content: typeof block.content === 'string' ? block.content : null,
|
|
132
|
+
args: typeof block.args === 'string' ? block.args : null,
|
|
133
|
+
arguments: block.arguments,
|
|
134
|
+
input: typeof block.input === 'string' ? block.input : null,
|
|
135
|
+
function: fn,
|
|
136
|
+
functionArguments: typeof fn?.arguments === 'string' ? fn.arguments : null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function contextMessageFingerprint(message) {
|
|
141
|
+
if (!message || typeof message !== 'object') {
|
|
142
|
+
return {
|
|
143
|
+
role: undefined,
|
|
144
|
+
content: contextValueFingerprint(''),
|
|
145
|
+
toolCalls: contextValueFingerprint(null),
|
|
146
|
+
thinkingBlocks: contextValueFingerprint(null),
|
|
147
|
+
assistantBlocks: contextValueFingerprint(null),
|
|
148
|
+
toolCallId: null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
role: message.role,
|
|
153
|
+
content: contextValueFingerprint(message.content),
|
|
154
|
+
toolCalls: contextValueFingerprint(message.toolCalls),
|
|
155
|
+
thinkingBlocks: contextValueFingerprint(message.thinkingBlocks),
|
|
156
|
+
assistantBlocks: contextValueFingerprint(message.assistantBlocks),
|
|
157
|
+
toolCallId: message?.toolCallId || null,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sameContextValueFingerprint(a, b) {
|
|
162
|
+
if (!a || !b || a.value !== b.value) return false;
|
|
163
|
+
if (a.entries === null || b.entries === null) return a.entries === b.entries;
|
|
164
|
+
if (a.entries.length !== b.entries.length) return false;
|
|
165
|
+
for (let index = 0; index < a.entries.length; index += 1) {
|
|
166
|
+
if (!sameContextBlockFingerprint(a.entries[index], b.entries[index])) return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sameContextBlockFingerprint(a, b) {
|
|
172
|
+
return !!a && !!b
|
|
173
|
+
&& a.value === b.value
|
|
174
|
+
&& a.text === b.text
|
|
175
|
+
&& a.content === b.content
|
|
176
|
+
&& a.args === b.args
|
|
177
|
+
&& Object.is(a.arguments, b.arguments)
|
|
178
|
+
&& a.input === b.input
|
|
179
|
+
&& a.function === b.function
|
|
180
|
+
&& a.functionArguments === b.functionArguments;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sameContextMessageFingerprint(a, b) {
|
|
184
|
+
return !!a && a.role === b.role
|
|
185
|
+
&& sameContextValueFingerprint(a.content, b.content)
|
|
186
|
+
&& sameContextValueFingerprint(a.toolCalls, b.toolCalls)
|
|
187
|
+
&& sameContextValueFingerprint(a.thinkingBlocks, b.thinkingBlocks)
|
|
188
|
+
&& sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
|
|
189
|
+
&& a.toolCallId === b.toolCallId;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function contextMessageContribution(message) {
|
|
193
|
+
const fingerprint = contextMessageFingerprint(message);
|
|
194
|
+
if (message && typeof message === 'object') {
|
|
195
|
+
const cached = contextMessageMemo.get(message);
|
|
196
|
+
if (cached && sameContextMessageFingerprint(cached.fingerprint, fingerprint)) return cached.contribution;
|
|
197
|
+
}
|
|
198
|
+
const role = ['system', 'user', 'assistant', 'tool'].includes(fingerprint.role) ? fingerprint.role : 'other';
|
|
199
|
+
const text = messageEstimateText(message);
|
|
200
|
+
const tokens = estimateTokens(text) + 4;
|
|
201
|
+
const contribution = {
|
|
202
|
+
role,
|
|
203
|
+
tokens,
|
|
204
|
+
reminderBuckets: null,
|
|
205
|
+
toolCallCount: 0,
|
|
206
|
+
toolCallTokens: 0,
|
|
207
|
+
toolResultCount: role === 'tool' ? 1 : 0,
|
|
208
|
+
toolResultTokens: role === 'tool' ? tokens : 0,
|
|
209
|
+
};
|
|
210
|
+
if (role === 'user' && String(text || '').trim().startsWith('<system-reminder>')) {
|
|
211
|
+
const buckets = { tokens: contribution.tokens, otherTokens: contribution.tokens };
|
|
212
|
+
let sectionTokens = 0;
|
|
213
|
+
for (const section of splitMarkdownSections(stripSystemReminder(text))) {
|
|
214
|
+
const bucket = reminderSectionBucket(section);
|
|
215
|
+
const sectionTokenCount = estimateTokens(section);
|
|
216
|
+
buckets[bucket] = (buckets[bucket] || 0) + sectionTokenCount;
|
|
217
|
+
sectionTokens += sectionTokenCount;
|
|
218
|
+
}
|
|
219
|
+
buckets.otherTokens = Math.max(0, contribution.tokens - sectionTokens);
|
|
220
|
+
contribution.reminderBuckets = buckets;
|
|
221
|
+
}
|
|
222
|
+
if (fingerprint.role === 'assistant' && Array.isArray(message?.toolCalls) && message.toolCalls.length) {
|
|
223
|
+
contribution.toolCallCount = message.toolCalls.length;
|
|
224
|
+
try { contribution.toolCallTokens = estimateTokens(JSON.stringify(message.toolCalls)); }
|
|
225
|
+
catch { contribution.toolCallTokens = estimateTokens(`[${message.toolCalls.length} tool calls]`); }
|
|
226
|
+
}
|
|
227
|
+
if (message && typeof message === 'object') {
|
|
228
|
+
contextMessageMemo.set(message, { fingerprint, contribution });
|
|
229
|
+
}
|
|
230
|
+
return contribution;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function emptyContextSummaryState() {
|
|
234
|
+
return {
|
|
235
|
+
rows: {
|
|
236
|
+
system: { count: 0, tokens: 0 },
|
|
237
|
+
user: { count: 0, tokens: 0 },
|
|
238
|
+
assistant: { count: 0, tokens: 0 },
|
|
239
|
+
tool: { count: 0, tokens: 0 },
|
|
240
|
+
other: { count: 0, tokens: 0 },
|
|
241
|
+
},
|
|
242
|
+
semantic: {
|
|
243
|
+
system: { count: 0, tokens: 0 },
|
|
244
|
+
chat: { count: 0, tokens: 0 },
|
|
245
|
+
assistant: { count: 0, tokens: 0 },
|
|
246
|
+
toolResults: { count: 0, tokens: 0 },
|
|
247
|
+
reminders: { count: 0, tokens: 0, otherTokens: 0 },
|
|
248
|
+
workflow: { tokens: 0 },
|
|
249
|
+
memory: { tokens: 0 },
|
|
250
|
+
workspace: { tokens: 0 },
|
|
251
|
+
environment: { tokens: 0 },
|
|
252
|
+
other: { tokens: 0 },
|
|
253
|
+
},
|
|
254
|
+
estimatedTokens: 0,
|
|
255
|
+
toolCallCount: 0,
|
|
256
|
+
toolCallTokens: 0,
|
|
257
|
+
toolResultCount: 0,
|
|
258
|
+
toolResultTokens: 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function applyContextMessageContribution(state, contribution, direction) {
|
|
263
|
+
const { role, tokens } = contribution;
|
|
264
|
+
state.estimatedTokens += direction * tokens;
|
|
265
|
+
state.rows[role].count += direction;
|
|
266
|
+
state.rows[role].tokens += direction * tokens;
|
|
267
|
+
if (role === 'system') {
|
|
268
|
+
state.semantic.system.count += direction;
|
|
269
|
+
state.semantic.system.tokens += direction * tokens;
|
|
270
|
+
} else if (role === 'user') {
|
|
271
|
+
if (contribution.reminderBuckets) {
|
|
272
|
+
state.semantic.reminders.count += direction;
|
|
273
|
+
state.semantic.reminders.tokens += direction * contribution.reminderBuckets.tokens;
|
|
274
|
+
state.semantic.reminders.otherTokens += direction * contribution.reminderBuckets.otherTokens;
|
|
275
|
+
for (const bucket of ['workflow', 'memory', 'workspace', 'environment', 'other']) {
|
|
276
|
+
state.semantic[bucket].tokens += direction * (contribution.reminderBuckets[bucket] || 0);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
state.semantic.chat.count += direction;
|
|
280
|
+
state.semantic.chat.tokens += direction * tokens;
|
|
281
|
+
}
|
|
282
|
+
} else if (role === 'assistant') {
|
|
283
|
+
state.semantic.assistant.count += direction;
|
|
284
|
+
state.semantic.assistant.tokens += direction * tokens;
|
|
285
|
+
} else if (role === 'tool') {
|
|
286
|
+
state.semantic.toolResults.count += direction;
|
|
287
|
+
state.semantic.toolResults.tokens += direction * tokens;
|
|
288
|
+
}
|
|
289
|
+
state.toolCallCount += direction * contribution.toolCallCount;
|
|
290
|
+
state.toolCallTokens += direction * contribution.toolCallTokens;
|
|
291
|
+
state.toolResultCount += direction * contribution.toolResultCount;
|
|
292
|
+
state.toolResultTokens += direction * contribution.toolResultTokens;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function contextSummaryResult(state, count) {
|
|
296
|
+
return {
|
|
297
|
+
count,
|
|
298
|
+
estimatedTokens: state.estimatedTokens,
|
|
299
|
+
roles: Object.fromEntries(Object.entries(state.rows).map(([role, row]) => [role, { ...row }])),
|
|
300
|
+
semantic: Object.fromEntries(Object.entries(state.semantic).map(([name, row]) => [name, { ...row }])),
|
|
301
|
+
toolCallCount: state.toolCallCount,
|
|
302
|
+
toolCallTokens: state.toolCallTokens,
|
|
303
|
+
toolResultCount: state.toolResultCount,
|
|
304
|
+
toolResultTokens: state.toolResultTokens,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
102
308
|
export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
|
|
103
309
|
export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
104
310
|
export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
|
|
@@ -301,81 +507,23 @@ function reminderSectionBucket(section) {
|
|
|
301
507
|
}
|
|
302
508
|
|
|
303
509
|
export function summarizeContextMessages(messages) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
other: { count: 0, tokens: 0 },
|
|
310
|
-
};
|
|
311
|
-
const semantic = {
|
|
312
|
-
system: { count: 0, tokens: 0 },
|
|
313
|
-
chat: { count: 0, tokens: 0 },
|
|
314
|
-
assistant: { count: 0, tokens: 0 },
|
|
315
|
-
toolResults: { count: 0, tokens: 0 },
|
|
316
|
-
reminders: { count: 0, tokens: 0, otherTokens: 0 },
|
|
317
|
-
workflow: { tokens: 0 },
|
|
318
|
-
memory: { tokens: 0 },
|
|
319
|
-
workspace: { tokens: 0 },
|
|
320
|
-
environment: { tokens: 0 },
|
|
321
|
-
other: { tokens: 0 },
|
|
322
|
-
};
|
|
323
|
-
let toolCallCount = 0;
|
|
324
|
-
let toolCallTokens = 0;
|
|
325
|
-
let toolResultCount = 0;
|
|
326
|
-
let toolResultTokens = 0;
|
|
327
|
-
for (const message of messages || []) {
|
|
328
|
-
const role = rows[message?.role] ? message.role : 'other';
|
|
329
|
-
const text = messageEstimateText(message);
|
|
330
|
-
const tokens = estimateMessageTokens(message);
|
|
331
|
-
rows[role].count += 1;
|
|
332
|
-
rows[role].tokens += tokens;
|
|
333
|
-
if (role === 'system') {
|
|
334
|
-
semantic.system.count += 1;
|
|
335
|
-
semantic.system.tokens += tokens;
|
|
336
|
-
} else if (role === 'user') {
|
|
337
|
-
if (String(text || '').trim().startsWith('<system-reminder>')) {
|
|
338
|
-
semantic.reminders.count += 1;
|
|
339
|
-
semantic.reminders.tokens += tokens;
|
|
340
|
-
let sectionTokens = 0;
|
|
341
|
-
for (const section of splitMarkdownSections(stripSystemReminder(text))) {
|
|
342
|
-
const bucket = reminderSectionBucket(section);
|
|
343
|
-
const sectionTokenCount = estimateTokens(section);
|
|
344
|
-
semantic[bucket].tokens += sectionTokenCount;
|
|
345
|
-
sectionTokens += sectionTokenCount;
|
|
346
|
-
}
|
|
347
|
-
semantic.reminders.otherTokens += Math.max(0, tokens - sectionTokens);
|
|
348
|
-
} else {
|
|
349
|
-
semantic.chat.count += 1;
|
|
350
|
-
semantic.chat.tokens += tokens;
|
|
351
|
-
}
|
|
352
|
-
} else if (role === 'assistant') {
|
|
353
|
-
semantic.assistant.count += 1;
|
|
354
|
-
semantic.assistant.tokens += tokens;
|
|
355
|
-
} else if (role === 'tool') {
|
|
356
|
-
semantic.toolResults.count += 1;
|
|
357
|
-
semantic.toolResults.tokens += tokens;
|
|
358
|
-
}
|
|
359
|
-
if (message?.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
|
|
360
|
-
toolCallCount += message.toolCalls.length;
|
|
361
|
-
try { toolCallTokens += estimateTokens(JSON.stringify(message.toolCalls)); }
|
|
362
|
-
catch { toolCallTokens += estimateTokens(`[${message.toolCalls.length} tool calls]`); }
|
|
363
|
-
}
|
|
364
|
-
if (message?.role === 'tool') {
|
|
365
|
-
toolResultCount += 1;
|
|
366
|
-
toolResultTokens += tokens;
|
|
367
|
-
}
|
|
510
|
+
if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
|
|
511
|
+
let cached = contextTranscriptMemo.get(messages);
|
|
512
|
+
if (!cached || messages.length < cached.count) {
|
|
513
|
+
cached = { count: 0, contributions: [], state: emptyContextSummaryState() };
|
|
514
|
+
contextTranscriptMemo.set(messages, cached);
|
|
368
515
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
516
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
517
|
+
const previous = cached.contributions[index];
|
|
518
|
+
const contribution = contextMessageContribution(messages[index]);
|
|
519
|
+
if (previous === contribution) continue;
|
|
520
|
+
if (previous) applyContextMessageContribution(cached.state, previous, -1);
|
|
521
|
+
cached.contributions[index] = contribution;
|
|
522
|
+
applyContextMessageContribution(cached.state, contribution, 1);
|
|
523
|
+
}
|
|
524
|
+
cached.contributions.length = messages.length;
|
|
525
|
+
cached.count = messages.length;
|
|
526
|
+
return contextSummaryResult(cached.state, messages.length);
|
|
379
527
|
}
|
|
380
528
|
|
|
381
529
|
// Per-request overhead the provider injects that never appears in the
|
|
@@ -383,6 +531,39 @@ export function summarizeContextMessages(messages) {
|
|
|
383
531
|
// provider wraps around the request. The chars/4 message estimate misses all
|
|
384
532
|
// of it, so a "fits" verdict computed from messages alone is optimistic.
|
|
385
533
|
const REQUEST_OVERHEAD_TOKENS = 512;
|
|
534
|
+
const toolSchemaTokenMemo = new WeakMap();
|
|
535
|
+
const requestReserveTokenMemo = new WeakMap();
|
|
536
|
+
|
|
537
|
+
function sameToolArrayEntries(cached, tools) {
|
|
538
|
+
if (!cached || cached.entries.length !== tools.length) return false;
|
|
539
|
+
for (let index = 0; index < tools.length; index += 1) {
|
|
540
|
+
const entry = cached.entries[index];
|
|
541
|
+
const tool = tools[index];
|
|
542
|
+
if (entry.tool !== tool
|
|
543
|
+
|| entry.name !== tool?.name
|
|
544
|
+
|| entry.description !== tool?.description
|
|
545
|
+
|| entry.inputSchema !== tool?.inputSchema
|
|
546
|
+
|| entry.input_schema !== tool?.input_schema
|
|
547
|
+
|| entry.parameters !== tool?.parameters
|
|
548
|
+
|| entry.schema !== tool?.schema) return false;
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function cacheToolArrayValue(tools, value) {
|
|
554
|
+
return {
|
|
555
|
+
entries: tools.map((tool) => ({
|
|
556
|
+
tool,
|
|
557
|
+
name: tool?.name,
|
|
558
|
+
description: tool?.description,
|
|
559
|
+
inputSchema: tool?.inputSchema,
|
|
560
|
+
input_schema: tool?.input_schema,
|
|
561
|
+
parameters: tool?.parameters,
|
|
562
|
+
schema: tool?.schema,
|
|
563
|
+
})),
|
|
564
|
+
value,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
386
567
|
|
|
387
568
|
/**
|
|
388
569
|
* Estimate the token cost of the tool/function schemas a provider appends to
|
|
@@ -394,10 +575,14 @@ const REQUEST_OVERHEAD_TOKENS = 512;
|
|
|
394
575
|
*/
|
|
395
576
|
export function estimateToolSchemaTokens(tools) {
|
|
396
577
|
if (!Array.isArray(tools) || tools.length === 0) return 0;
|
|
578
|
+
const cached = toolSchemaTokenMemo.get(tools);
|
|
579
|
+
if (sameToolArrayEntries(cached, tools)) return cached.value;
|
|
397
580
|
let text = '';
|
|
398
581
|
try { text = JSON.stringify(tools); }
|
|
399
582
|
catch { text = tools.map(t => String(t?.name ?? '')).join(''); }
|
|
400
|
-
|
|
583
|
+
const tokens = estimateTokens(text);
|
|
584
|
+
toolSchemaTokenMemo.set(tools, cacheToolArrayValue(tools, tokens));
|
|
585
|
+
return tokens;
|
|
401
586
|
}
|
|
402
587
|
|
|
403
588
|
/**
|
|
@@ -407,7 +592,12 @@ export function estimateToolSchemaTokens(tools) {
|
|
|
407
592
|
* request-side bytes the message estimate cannot see.
|
|
408
593
|
*/
|
|
409
594
|
export function estimateRequestReserveTokens(tools) {
|
|
410
|
-
return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
|
|
595
|
+
if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
|
|
596
|
+
const cached = requestReserveTokenMemo.get(tools);
|
|
597
|
+
if (sameToolArrayEntries(cached, tools)) return cached.value;
|
|
598
|
+
const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
|
|
599
|
+
requestReserveTokenMemo.set(tools, cacheToolArrayValue(tools, reserve));
|
|
600
|
+
return reserve;
|
|
411
601
|
}
|
|
412
602
|
|
|
413
603
|
/**
|
|
@@ -418,13 +608,15 @@ export function estimateRequestReserveTokens(tools) {
|
|
|
418
608
|
*
|
|
419
609
|
* @param {unknown[]} messages
|
|
420
610
|
* @param {unknown[]|number} toolsOrReserve tool list or precomputed reserve tokens
|
|
421
|
-
* @param {{ messageCount?: number }} [opts]
|
|
611
|
+
* @param {{ messageCount?: number, estimatedMessageTokens?: number }} [opts]
|
|
422
612
|
*/
|
|
423
613
|
export function estimateTranscriptContextUsage(messages, toolsOrReserve, opts = {}) {
|
|
424
614
|
const list = Array.isArray(messages) ? messages : [];
|
|
425
615
|
const count = Number.isFinite(Number(opts.messageCount)) ? Number(opts.messageCount) : list.length;
|
|
426
616
|
if (count <= 0 || list.length === 0) return 0;
|
|
427
|
-
const messageTokens =
|
|
617
|
+
const messageTokens = Number.isFinite(Number(opts.estimatedMessageTokens))
|
|
618
|
+
? Number(opts.estimatedMessageTokens)
|
|
619
|
+
: summarizeContextMessages(list).estimatedTokens;
|
|
428
620
|
const reserve = typeof toolsOrReserve === 'number' && Number.isFinite(toolsOrReserve)
|
|
429
621
|
? Math.max(0, toolsOrReserve)
|
|
430
622
|
: estimateRequestReserveTokens(toolsOrReserve);
|
|
@@ -15,7 +15,8 @@ import { isAgentOwner } from '../../agent-owner.mjs';
|
|
|
15
15
|
|
|
16
16
|
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
17
17
|
// to execute during SSE parsing so tool work overlaps with the rest of the
|
|
18
|
-
// stream.
|
|
18
|
+
// stream. MCP follows that annotation; writes, bash, and skills stay serial
|
|
19
|
+
// after send() returns.
|
|
19
20
|
// Memoized: the read-only name Set is built once per distinct `tools` array
|
|
20
21
|
// (keyed by identity via a module-level WeakMap) so repeated per-call lookups
|
|
21
22
|
// are O(1) instead of O(N) tools.find scans.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Process-local delivered-completion registry.
|
|
2
|
+
//
|
|
3
|
+
// Purpose: a task-completion body that was already injected into the active TUI
|
|
4
|
+
// loop and ACKed (modelVisibleDelivered) must never ALSO be enqueued into — or
|
|
5
|
+
// drained from — the session pending queue one turn later. The TUI ack and the
|
|
6
|
+
// racing background/fallback enqueue run on different code paths within the SAME
|
|
7
|
+
// process, so this in-memory registry lets an enqueue/drain site recognize "this
|
|
8
|
+
// completion was already delivered" and skip it.
|
|
9
|
+
//
|
|
10
|
+
// Recorded keys (either one matching = already delivered):
|
|
11
|
+
// - meta.execution_id (when present)
|
|
12
|
+
// - a hash of the model-visible completion text
|
|
13
|
+
//
|
|
14
|
+
// Bounded + TTL'd so a long-lived process never accumulates unbounded entries.
|
|
15
|
+
// Both record and a matching lookup REFRESH the entry (sliding TTL), so an
|
|
16
|
+
// ACKed completion survives across long-idle turns as long as it keeps being
|
|
17
|
+
// checked. Headless/API sessions never ack, so the registry stays empty for
|
|
18
|
+
// them and marked in-memory completions drain exactly once (behavior unchanged).
|
|
19
|
+
//
|
|
20
|
+
// Residual risk: with a 6h sliding TTL the only remaining eviction path is the
|
|
21
|
+
// bounded-size cap — if MORE than DELIVERED_MAX_ENTRIES distinct completions are
|
|
22
|
+
// delivered before an older ACKed key is next checked, that oldest key is
|
|
23
|
+
// evicted and its (now un-refreshed) completion could enqueue/drain a second
|
|
24
|
+
// time. This requires >512 live completions between a delivery and its drain,
|
|
25
|
+
// which is far outside normal single-process interactive use.
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
const DELIVERED_TTL_MS = 6 * 60 * 60 * 1000; // 6h, sliding (refreshed on record + hit)
|
|
29
|
+
const DELIVERED_MAX_ENTRIES = 512;
|
|
30
|
+
// key -> expiresAt (ms). Insertion order = age order (Map guarantee), so the
|
|
31
|
+
// oldest key is always the first — used for bounded-size eviction.
|
|
32
|
+
const _delivered = new Map();
|
|
33
|
+
|
|
34
|
+
export function hashCompletionText(text) {
|
|
35
|
+
const value = String(text ?? '').trim();
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
return createHash('sha1').update(value).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function execKey(executionId) {
|
|
41
|
+
const id = executionId == null ? '' : String(executionId).trim();
|
|
42
|
+
return id ? `id:${id}` : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function textKey(text) {
|
|
46
|
+
const h = hashCompletionText(text);
|
|
47
|
+
return h ? `tx:${h}` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pruneExpired(now) {
|
|
51
|
+
for (const [key, expiresAt] of _delivered) {
|
|
52
|
+
if (expiresAt <= now) _delivered.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function enforceBound() {
|
|
57
|
+
while (_delivered.size > DELIVERED_MAX_ENTRIES) {
|
|
58
|
+
const oldest = _delivered.keys().next().value;
|
|
59
|
+
if (oldest === undefined) break;
|
|
60
|
+
_delivered.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Record a delivered completion under its execution_id (when present) and its
|
|
65
|
+
// model-visible text hash. Both keys point at the same expiry so a later
|
|
66
|
+
// enqueue/drain matching EITHER is recognized as already delivered. Re-recording
|
|
67
|
+
// an existing key refreshes its expiry (sliding TTL).
|
|
68
|
+
export function recordDeliveredCompletion({ executionId, text } = {}) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
pruneExpired(now);
|
|
71
|
+
const expiresAt = now + DELIVERED_TTL_MS;
|
|
72
|
+
let recorded = false;
|
|
73
|
+
for (const key of [execKey(executionId), textKey(text)]) {
|
|
74
|
+
if (!key) continue;
|
|
75
|
+
_delivered.delete(key); // re-insert to refresh age order
|
|
76
|
+
_delivered.set(key, expiresAt);
|
|
77
|
+
recorded = true;
|
|
78
|
+
}
|
|
79
|
+
if (recorded) enforceBound();
|
|
80
|
+
return recorded;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isDeliveredCompletion({ executionId, text } = {}) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const keys = [execKey(executionId), textKey(text)].filter(Boolean);
|
|
86
|
+
let hit = false;
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
const expiresAt = _delivered.get(key);
|
|
89
|
+
if (expiresAt === undefined) continue;
|
|
90
|
+
if (expiresAt <= now) { _delivered.delete(key); continue; }
|
|
91
|
+
hit = true;
|
|
92
|
+
}
|
|
93
|
+
if (!hit) return false;
|
|
94
|
+
// Sliding refresh: extend every present key (and move it to the age tail) so
|
|
95
|
+
// a long-idle drain that checks it later still recognizes it as delivered.
|
|
96
|
+
const expiresAt = now + DELIVERED_TTL_MS;
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (!_delivered.has(key)) continue;
|
|
99
|
+
_delivered.delete(key);
|
|
100
|
+
_delivered.set(key, expiresAt);
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// One diagnostic stderr line per dedup skip, naming the site
|
|
106
|
+
// (mirror/fallback/notify-enqueue/drain) so the actual racing edge is
|
|
107
|
+
// empirically confirmable in logs.
|
|
108
|
+
export function logDuplicateSkip(site, { executionId, text } = {}) {
|
|
109
|
+
try {
|
|
110
|
+
const id = executionId == null ? '' : String(executionId).trim();
|
|
111
|
+
const h = hashCompletionText(text);
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`[completion-dedup] skip site=${site}`
|
|
114
|
+
+ `${id ? ` execution_id=${id}` : ''}`
|
|
115
|
+
+ `${h ? ` hash=${h.slice(0, 12)}` : ''}\n`,
|
|
116
|
+
);
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Test-only reset hook.
|
|
121
|
+
export function _clearDeliveredCompletions() {
|
|
122
|
+
_delivered.clear();
|
|
123
|
+
}
|
|
@@ -13,10 +13,22 @@ import { nonNegativeIntEnv } from './env-utils.mjs';
|
|
|
13
13
|
const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
|
|
14
14
|
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
|
|
15
15
|
const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
|
|
16
|
-
|
|
16
|
+
// Tombstone unlink TTL. The guarded resurrection race (temp-write→rename inside
|
|
17
|
+
// _doSave) resolves in microseconds, so 1h is still vastly longer than any
|
|
18
|
+
// realistic in-flight ask race — while short enough that matured tombstones are
|
|
19
|
+
// reclaimed promptly instead of accumulating for a full day.
|
|
20
|
+
const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
17
21
|
let _cleanupTimer = null;
|
|
18
22
|
let _cleanupInitialTimer = null;
|
|
19
23
|
|
|
24
|
+
// A session is "live" when it still owns a non-closed runtime entry. Passed to
|
|
25
|
+
// the retention cap so the active/current and any in-flight session is never
|
|
26
|
+
// pruned by the open-session max-age/max-count bounds.
|
|
27
|
+
function _isSessionLive(id) {
|
|
28
|
+
const entry = _getRuntimeEntry(id);
|
|
29
|
+
return !!(entry && entry.closed !== true);
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
function _previewIds(items, limit = 5) {
|
|
21
33
|
const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
|
|
22
34
|
if (ids.length === 0) return '';
|
|
@@ -29,6 +41,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
29
41
|
try {
|
|
30
42
|
const result = sweepStaleSessions({
|
|
31
43
|
tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
|
|
44
|
+
isSessionLive: _isSessionLive,
|
|
32
45
|
});
|
|
33
46
|
const {
|
|
34
47
|
cleaned,
|
|
@@ -81,7 +94,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
81
94
|
* Rationale: closeSession() leaves the tombstone on disk as the authoritative
|
|
82
95
|
* resurrection-blocker for racing saveSession() calls. That race resolves in
|
|
83
96
|
* microseconds (the window inside _doSave between temp write and rename), so
|
|
84
|
-
*
|
|
97
|
+
* 1h is vastly safe. After the TTL expires we reclaim the disk slot.
|
|
85
98
|
*
|
|
86
99
|
* Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
|
|
87
100
|
* latter's inline 30-min idle cleanup would race-unlink tombstones before we
|
|
@@ -5,6 +5,7 @@ import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
|
5
5
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
6
6
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
7
7
|
import { loadSession } from '../store.mjs';
|
|
8
|
+
import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
|
|
8
9
|
|
|
9
10
|
const _sessionPendingMessages = new Map();
|
|
10
11
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
@@ -24,6 +25,31 @@ function isCompletionNotificationEntry(entry) {
|
|
|
24
25
|
&& entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Pre-marker completion notifications were persisted as plain strings,
|
|
29
|
+
// indistinguishable from genuine user/steering messages except by their
|
|
30
|
+
// model-visible wrapper shape. Such stale strings must never replay into a
|
|
31
|
+
// resumed session, but they carry no notificationKind marker, so the marker
|
|
32
|
+
// check alone leaves them behind. Because this is a SILENT-drop path, the
|
|
33
|
+
// shared lenient wrapper detector is too broad (a user message quoting a
|
|
34
|
+
// completion card could be dropped), so this uses its OWN strict recognizer:
|
|
35
|
+
// the string must be a verbatim full-card paste and nothing else —
|
|
36
|
+
// (1) start with the exact instruction preamble + "\n\nResult:\n",
|
|
37
|
+
// (2) have EVERY non-empty body line quoted with "> " (100%),
|
|
38
|
+
// (3) carry no extra leading/trailing prose (whitespace-trim only).
|
|
39
|
+
// Conservative by design: a false negative just keeps a legacy string, but a
|
|
40
|
+
// false positive on genuine user text would silently drop a real message.
|
|
41
|
+
const LEGACY_COMPLETION_CARD_PREAMBLE_RE = /^The async \S+ task \S+ has finished \([^)]*\) - review this result in your next step\.\n\nResult:\n/;
|
|
42
|
+
function isLegacyUnmarkedCompletionNotification(entry) {
|
|
43
|
+
if (typeof entry !== 'string') return false;
|
|
44
|
+
const value = entry.trim();
|
|
45
|
+
const match = LEGACY_COMPLETION_CARD_PREAMBLE_RE.exec(value);
|
|
46
|
+
if (!match) return false;
|
|
47
|
+
const body = value.slice(match[0].length);
|
|
48
|
+
const lines = body.split(/\r?\n/).filter((line) => line.length > 0);
|
|
49
|
+
if (lines.length === 0) return false;
|
|
50
|
+
return lines.every((line) => line.startsWith('> '));
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
// Canonical completion-enqueue tagger. Every deferred tool/agent completion
|
|
28
54
|
// notification MUST be enqueued through this so drain can discard it on resume
|
|
29
55
|
// (never replay out-of-order). Pass the model-visible completion text (or an
|
|
@@ -393,8 +419,21 @@ export function drainPendingMessages(sessionId) {
|
|
|
393
419
|
// in-memory queue is empty, so keeping it only ever delivers live entries.
|
|
394
420
|
// Genuine user/steering messages carry no marker and are kept in order in
|
|
395
421
|
// both paths.
|
|
396
|
-
|
|
397
|
-
|
|
422
|
+
// Drain-time belt: drop MARKED in-memory completion entries whose text hash
|
|
423
|
+
// was already delivered+ACKed (TUI execution-ui) this process — those would
|
|
424
|
+
// double-inject next turn. Only marked completion entries are eligible;
|
|
425
|
+
// genuine user/steering entries carry no marker and are always kept.
|
|
426
|
+
const memoryKept = memory.filter((m) => {
|
|
427
|
+
if (!isCompletionNotificationEntry(m)) return true;
|
|
428
|
+
const text = pendingMessageText(m);
|
|
429
|
+
if (text && isDeliveredCompletion({ text })) {
|
|
430
|
+
logDuplicateSkip('drain', { text });
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m)
|
|
436
|
+
&& !isLegacyUnmarkedCompletionNotification(m));
|
|
398
437
|
const memoryVisible = modelVisiblePendingMessages(memoryKept);
|
|
399
438
|
const persistedVisible = modelVisiblePendingMessages(persistedKept);
|
|
400
439
|
if (memoryVisible.length === 0) return persistedVisible;
|
|
@@ -24,7 +24,7 @@ import { _closeBashSessionLazy } from './runtime-loaders.mjs';
|
|
|
24
24
|
* and recreates the session in its pre-close state.
|
|
25
25
|
*
|
|
26
26
|
* Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
|
|
27
|
-
* TOMBSTONE_MAX_AGE_MS (
|
|
27
|
+
* TOMBSTONE_MAX_AGE_MS (1h — vastly longer than the microsecond in-flight race).
|
|
28
28
|
*/
|
|
29
29
|
export function closeSession(id, reason = 'manual', opts = {}) {
|
|
30
30
|
// tombstone=false: detach runtime resources (heartbeat, bash shells,
|