mixdog 0.9.55 → 0.9.59
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 +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
|
@@ -13,6 +13,17 @@ import {
|
|
|
13
13
|
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
14
14
|
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
15
15
|
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
16
|
+
import { createContextState } from '../src/tui/engine/context-state.mjs';
|
|
17
|
+
import {
|
|
18
|
+
restoreTranscriptItems,
|
|
19
|
+
restoredAssistantTranscriptItems,
|
|
20
|
+
restoredTranscriptMetadata,
|
|
21
|
+
} from '../src/tui/engine/session-api-ext.mjs';
|
|
22
|
+
import {
|
|
23
|
+
attachAssistantTranscriptCompletion,
|
|
24
|
+
persistedAssistantTranscriptMetadata,
|
|
25
|
+
} from '../src/runtime/agent/orchestrator/session/manager/ask-session.mjs';
|
|
26
|
+
import { attachAssistantTranscriptMetadata } from '../src/runtime/agent/orchestrator/session/agent-loop.mjs';
|
|
16
27
|
import {
|
|
17
28
|
buildTranscriptRowIndexIncremental,
|
|
18
29
|
transcriptItemsWithStableTail,
|
|
@@ -191,12 +202,16 @@ test('failed mid-turn compact leaves prior transcript items untouched', async ()
|
|
|
191
202
|
|
|
192
203
|
test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
|
|
193
204
|
let identityStable = false;
|
|
205
|
+
let persistedAssistantMessage = null;
|
|
194
206
|
const harness = makeTurnHarness(async (_text, options) => {
|
|
195
207
|
const before = harness.getState().items;
|
|
196
208
|
options.onTextDelta('settled line\n');
|
|
197
209
|
await wait(30);
|
|
198
210
|
identityStable = harness.getState().items === before;
|
|
199
211
|
assert.match(harness.getState().streamingTail?.text || '', /settled line/);
|
|
212
|
+
persistedAssistantMessage = {
|
|
213
|
+
meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
|
|
214
|
+
};
|
|
200
215
|
return { result: { content: 'settled line\n' }, session: { messages: [] } };
|
|
201
216
|
});
|
|
202
217
|
|
|
@@ -207,6 +222,157 @@ test('stream flush keeps settled items identity and finalize appends one assista
|
|
|
207
222
|
assert.equal(assistants.length, 1);
|
|
208
223
|
assert.equal(assistants[0].streaming, false);
|
|
209
224
|
assert.equal(assistants[0].text, 'settled line\n');
|
|
225
|
+
assert.equal(
|
|
226
|
+
restoredTranscriptMetadata(persistedAssistantMessage).at,
|
|
227
|
+
assistants[0].at,
|
|
228
|
+
'restored assistant timestamp must match its live creation timestamp',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('no-delta final assistant persists the timestamp later used by the live item', async () => {
|
|
233
|
+
let persistedAssistantMessage = null;
|
|
234
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
235
|
+
persistedAssistantMessage = {
|
|
236
|
+
meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
|
|
237
|
+
};
|
|
238
|
+
return { result: { content: 'final without deltas' }, session: { messages: [] } };
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
assert.equal(await harness.runTurn('go'), 'done');
|
|
242
|
+
const assistant = harness.getState().items.find((item) => item.kind === 'assistant');
|
|
243
|
+
assert.equal(assistant?.text, 'final without deltas');
|
|
244
|
+
assert.equal(restoredTranscriptMetadata(persistedAssistantMessage).at, assistant?.at);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('assistant completion metadata survives session resume projection', () => {
|
|
248
|
+
const turnStartedAt = Date.parse('2026-07-18T01:00:00Z');
|
|
249
|
+
const messages = [
|
|
250
|
+
{ role: 'user', content: 'Question', meta: { transcript: { at: turnStartedAt } } },
|
|
251
|
+
{
|
|
252
|
+
role: 'assistant',
|
|
253
|
+
content: 'Answer',
|
|
254
|
+
meta: { transcript: { at: turnStartedAt + 1_000, model: 'model-a' } },
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
assert.equal(attachAssistantTranscriptCompletion(messages, {
|
|
258
|
+
status: 'done',
|
|
259
|
+
verb: 'Mapped',
|
|
260
|
+
elapsedMs: 2_000,
|
|
261
|
+
}, turnStartedAt), true);
|
|
262
|
+
let sequence = 0;
|
|
263
|
+
const restored = restoredAssistantTranscriptItems(messages[1], () => `restored-${++sequence}`);
|
|
264
|
+
assert.equal(restored.length, 2);
|
|
265
|
+
assert.deepEqual(restored[0], {
|
|
266
|
+
kind: 'assistant',
|
|
267
|
+
id: 'restored-1',
|
|
268
|
+
text: 'Answer',
|
|
269
|
+
at: turnStartedAt + 1_000,
|
|
270
|
+
model: 'model-a',
|
|
271
|
+
});
|
|
272
|
+
assert.deepEqual(restored[1], {
|
|
273
|
+
kind: 'turndone',
|
|
274
|
+
id: 'restored-2',
|
|
275
|
+
status: 'done',
|
|
276
|
+
verb: 'Mapped',
|
|
277
|
+
elapsedMs: 2_000,
|
|
278
|
+
at: turnStartedAt + 1_000,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('cold transcript restore incrementally projects only the visible tail', () => {
|
|
283
|
+
let oldBodyReads = 0;
|
|
284
|
+
const oldMessages = Array.from({ length: 4_000 }, (_, index) => {
|
|
285
|
+
const message = { role: index % 2 === 0 ? 'user' : 'assistant' };
|
|
286
|
+
Object.defineProperty(message, 'content', {
|
|
287
|
+
enumerable: true,
|
|
288
|
+
get() {
|
|
289
|
+
oldBodyReads += 1;
|
|
290
|
+
return `old body ${index}`;
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
return message;
|
|
294
|
+
});
|
|
295
|
+
const recentMessages = Array.from({ length: 400 }, (_, index) => ({
|
|
296
|
+
role: index % 2 === 0 ? 'user' : 'assistant',
|
|
297
|
+
content: `recent body ${index}`,
|
|
298
|
+
...(index % 2 === 1
|
|
299
|
+
? { meta: { transcript: { completion: { status: 'done', elapsedMs: index } } } }
|
|
300
|
+
: {}),
|
|
301
|
+
}));
|
|
302
|
+
const messages = [...oldMessages, ...recentMessages];
|
|
303
|
+
const restored = restoreTranscriptItems(messages, {
|
|
304
|
+
sessionId: 'fast',
|
|
305
|
+
itemLimit: 64,
|
|
306
|
+
});
|
|
307
|
+
const recentFull = restoreTranscriptItems(recentMessages, { sessionId: 'recent' });
|
|
308
|
+
|
|
309
|
+
assert.equal(oldBodyReads, 0, 'tail restore must not read old message bodies');
|
|
310
|
+
assert.equal(restored.length, 64);
|
|
311
|
+
assert.deepEqual(
|
|
312
|
+
restored.map((item) => [item.kind, item.text || '', item.status || '']),
|
|
313
|
+
recentFull.slice(-64).map((item) => [item.kind, item.text || '', item.status || '']),
|
|
314
|
+
);
|
|
315
|
+
assert.match(restored[0].id, /^hist_fast_4\d{3}_\d+$/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('restored transcripts publish estimated context before a new provider turn', () => {
|
|
319
|
+
let state = {
|
|
320
|
+
stats: { inputTokens: 0, latestInputTokens: 0, latestPromptTokens: 0 },
|
|
321
|
+
busy: false,
|
|
322
|
+
spinner: null,
|
|
323
|
+
thinking: null,
|
|
324
|
+
};
|
|
325
|
+
const context = createContextState({
|
|
326
|
+
runtime: {
|
|
327
|
+
contextStatus: () => ({
|
|
328
|
+
contextWindow: 20_000,
|
|
329
|
+
currentEstimatedTokens: 1_250,
|
|
330
|
+
usedTokens: 1_250,
|
|
331
|
+
usedSource: 'estimated',
|
|
332
|
+
messages: { count: 2 },
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
getState: () => state,
|
|
336
|
+
updateState: (patch) => { state = { ...state, ...patch }; },
|
|
337
|
+
getPendingSessionReset: () => false,
|
|
338
|
+
});
|
|
339
|
+
context.syncContextStats({ allowEstimated: true });
|
|
340
|
+
assert.equal(state.stats.currentContextTokens, 0);
|
|
341
|
+
assert.equal(state.stats.currentEstimatedContextTokens, 1_250);
|
|
342
|
+
assert.equal(state.stats.currentContextSource, 'estimated');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('multi-tool preamble and final assistant preserve distinct live timestamps on restore', async () => {
|
|
346
|
+
const persistedAssistantMessages = [];
|
|
347
|
+
const takeAssistantTranscriptMetadata = (options) => {
|
|
348
|
+
const transcript = persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now());
|
|
349
|
+
delete options.transcriptMeta.assistantAt;
|
|
350
|
+
return transcript;
|
|
351
|
+
};
|
|
352
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
353
|
+
options.onAssistantText('tool preamble');
|
|
354
|
+
await options.onToolCall(1, [{ id: 'call_1', name: 'grep', input: { pattern: 'x' } }]);
|
|
355
|
+
persistedAssistantMessages.push(attachAssistantTranscriptMetadata(
|
|
356
|
+
{ role: 'assistant', content: 'tool preamble', toolCalls: [] },
|
|
357
|
+
{ takeAssistantTranscriptMetadata: () => takeAssistantTranscriptMetadata(options) },
|
|
358
|
+
));
|
|
359
|
+
options.onToolResult({ tool_call_id: 'call_1', content: 'ok' });
|
|
360
|
+
persistedAssistantMessages.push({
|
|
361
|
+
role: 'assistant',
|
|
362
|
+
content: 'final answer',
|
|
363
|
+
meta: { transcript: persistedAssistantTranscriptMetadata(options.transcriptMeta, Date.now()) },
|
|
364
|
+
});
|
|
365
|
+
return { result: { content: 'final answer' }, session: { messages: [] } };
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
assert.equal(await harness.runTurn('go'), 'done');
|
|
369
|
+
const liveAssistants = harness.getState().items.filter((item) => item.kind === 'assistant');
|
|
370
|
+
assert.equal(liveAssistants.length, 2);
|
|
371
|
+
assert.equal(new Set(liveAssistants.map((item) => item.at)).size, 2);
|
|
372
|
+
assert.deepEqual(
|
|
373
|
+
persistedAssistantMessages.map((message) => restoredTranscriptMetadata(message).at),
|
|
374
|
+
liveAssistants.map((item) => item.at),
|
|
375
|
+
);
|
|
210
376
|
});
|
|
211
377
|
|
|
212
378
|
test('abort after stream progress settles the tail and leaves no orphan', async () => {
|
|
@@ -516,6 +682,7 @@ test('transcript spill caps the live window and restores every item in order', (
|
|
|
516
682
|
assert.deepEqual(live, original.slice(-live.length));
|
|
517
683
|
assert.ok(live.length <= TRANSCRIPT_LIVE_ITEM_CAP);
|
|
518
684
|
spill.reset();
|
|
685
|
+
spill.dispose();
|
|
519
686
|
});
|
|
520
687
|
|
|
521
688
|
test('spill restore succeeds before its asynchronous worker write completes', () => {
|
package/src/app.mjs
CHANGED
|
@@ -113,6 +113,29 @@ export async function run(argv = []) {
|
|
|
113
113
|
);
|
|
114
114
|
return 1;
|
|
115
115
|
}
|
|
116
|
+
// Stale-bundle guard: a dist built before hot runtime sources caused weeks
|
|
117
|
+
// of ghost failures (compaction placeholder patches) that no longer existed
|
|
118
|
+
// in src. Sample a few hot files — a newer source than the bundle means the
|
|
119
|
+
// running behavior will NOT match the tree, so warn loudly.
|
|
120
|
+
try {
|
|
121
|
+
const { statSync } = await import('node:fs');
|
|
122
|
+
const bundleMtime = statSync(bundle).mtimeMs;
|
|
123
|
+
const hotSources = [
|
|
124
|
+
join(__dirname, 'runtime', 'agent', 'orchestrator', 'session', 'agent-loop.mjs'),
|
|
125
|
+
join(__dirname, 'runtime', 'agent', 'orchestrator', 'session', 'loop', 'stored-tool-args.mjs'),
|
|
126
|
+
join(__dirname, 'tui', 'engine', 'session-api-ext.mjs'),
|
|
127
|
+
join(__dirname, 'standalone', 'agent-tool.mjs'),
|
|
128
|
+
];
|
|
129
|
+
const stale = hotSources.some((file) => {
|
|
130
|
+
try { return statSync(file).mtimeMs > bundleMtime + 1_000; } catch { return false; }
|
|
131
|
+
});
|
|
132
|
+
if (stale) {
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
'mixdog: TUI bundle is OLDER than runtime sources — behavior will not match the tree.\n'
|
|
135
|
+
+ ' Rebuild with: npm run build:tui\n',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch { /* advisory only */ }
|
|
116
139
|
const { runTui } = await import('./tui/dist/index.mjs');
|
|
117
140
|
bootProfile('tui:imported');
|
|
118
141
|
return await runTui(opts);
|
package/src/cli.mjs
CHANGED
|
@@ -10,6 +10,12 @@ import { stagedChildExitCode } from './runtime/shared/staged-child-result.mjs';
|
|
|
10
10
|
|
|
11
11
|
const argv = process.argv.slice(2);
|
|
12
12
|
|
|
13
|
+
// React/ink resolve their build flavor from NODE_ENV at require time. Unset
|
|
14
|
+
// NODE_ENV loads react-reconciler's DEVELOPMENT build, whose per-commit debug
|
|
15
|
+
// bookkeeping consumed ~16% of TUI frame CPU under transcript load (cpuprofile
|
|
16
|
+
// 2026-07-20). Default to production; an explicit NODE_ENV still wins.
|
|
17
|
+
process.env.NODE_ENV ||= 'production';
|
|
18
|
+
|
|
13
19
|
let swapped = false;
|
|
14
20
|
const invocation = classifyCliInvocation(argv);
|
|
15
21
|
const skipHostPrelude = invocation.kind === 'headless' || invocation.skipHostPrelude === true;
|
package/src/lib/keychain-cjs.cjs
CHANGED
|
@@ -128,7 +128,14 @@ function powershell(script) {
|
|
|
128
128
|
// secret encryption/decryption. (pwsh/PS7 is intentionally not used: it does
|
|
129
129
|
// not auto-load the ProtectedData assembly, so DPAPI silently fails there.)
|
|
130
130
|
const exe = resolvePowershellExe();
|
|
131
|
+
const profiled = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
|
|
132
|
+
const startedAt = profiled ? Date.now() : 0;
|
|
131
133
|
const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
|
|
134
|
+
if (profiled) {
|
|
135
|
+
const caller = (new Error().stack || '').split('\n').slice(2, 5)
|
|
136
|
+
.map((line) => line.trim().replace(/\s+/g, '_')).join('<-');
|
|
137
|
+
try { process.stderr.write(`[mixdog-boot] keychain:sync-powershell ms=${Date.now() - startedAt} by=${caller}\n`); } catch {}
|
|
138
|
+
}
|
|
132
139
|
if (r.error && r.error.code === 'ETIMEDOUT') {
|
|
133
140
|
throw new Error(`[keychain] PowerShell DPAPI command timed out after ${POWERSHELL_TIMEOUT_MS}ms (host: ${exe})`);
|
|
134
141
|
}
|
|
@@ -138,7 +145,7 @@ function powershell(script) {
|
|
|
138
145
|
return r;
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
function powershellAsync(script) {
|
|
148
|
+
function powershellAsync(script, input = null) {
|
|
142
149
|
return new Promise((resolve) => {
|
|
143
150
|
let child;
|
|
144
151
|
try {
|
|
@@ -149,7 +156,7 @@ function powershellAsync(script) {
|
|
|
149
156
|
env: powershellEnv(),
|
|
150
157
|
shell: false,
|
|
151
158
|
windowsHide: true,
|
|
152
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
159
|
+
stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
153
160
|
timeout: POWERSHELL_TIMEOUT_MS,
|
|
154
161
|
},
|
|
155
162
|
);
|
|
@@ -172,6 +179,10 @@ function powershellAsync(script) {
|
|
|
172
179
|
};
|
|
173
180
|
child.once('error', (error) => finish(null, error));
|
|
174
181
|
child.once('close', (status) => finish(status));
|
|
182
|
+
if (input != null) {
|
|
183
|
+
child.stdin.on('error', () => { /* child exited early — close() reports it */ });
|
|
184
|
+
child.stdin.end(input);
|
|
185
|
+
}
|
|
175
186
|
});
|
|
176
187
|
}
|
|
177
188
|
|
|
@@ -348,30 +359,69 @@ function win32Get(account) {
|
|
|
348
359
|
return out;
|
|
349
360
|
}
|
|
350
361
|
|
|
362
|
+
// Batch DPAPI decrypt used by prewarmSecrets: ONE PowerShell host decrypts
|
|
363
|
+
// every ciphertext. Windows CreateProcess for powershell.exe is a SYNCHRONOUS
|
|
364
|
+
// multi-hundred-ms operation on the calling thread (AV scanning), so the old
|
|
365
|
+
// per-secret spawn fan-out froze the caller's event loop for N x spawn cost —
|
|
366
|
+
// observed as a ~5s desktop boot stall with near-zero CPU. Accounts and
|
|
367
|
+
// ciphertexts travel via stdin (never interpolated into the script); decrypted
|
|
368
|
+
// values return base64-wrapped so arbitrary secret bytes survive the pipe.
|
|
369
|
+
const PS_UNPROTECT_BATCH = [
|
|
370
|
+
'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;',
|
|
371
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
|
372
|
+
'$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser;',
|
|
373
|
+
'while (($line = [Console]::In.ReadLine()) -ne $null) {',
|
|
374
|
+
' $parts = $line.Split("|", 2);',
|
|
375
|
+
' if ($parts.Count -lt 2) { continue }',
|
|
376
|
+
' try {',
|
|
377
|
+
' $enc = [Convert]::FromBase64String($parts[1]);',
|
|
378
|
+
' $dec = [System.Security.Cryptography.ProtectedData]::Unprotect($enc, $null, $scope);',
|
|
379
|
+
' [Console]::Out.WriteLine($parts[0] + "|" + [Convert]::ToBase64String($dec));',
|
|
380
|
+
' } catch {}',
|
|
381
|
+
'}',
|
|
382
|
+
].join(' ');
|
|
383
|
+
|
|
351
384
|
async function prewarmSecrets() {
|
|
352
385
|
try {
|
|
353
386
|
if (platform() !== 'win32' || KEYCHAIN_CACHE_TTL_MS === 0) return;
|
|
354
387
|
const dir = secretsDir();
|
|
355
388
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
356
389
|
const epoch = _cacheEpoch;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}))
|
|
390
|
+
const rows = [];
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
if (!entry.isFile() || !entry.name.endsWith('.dpapi')) continue;
|
|
393
|
+
const account = entry.name.slice(0, -'.dpapi'.length);
|
|
394
|
+
try {
|
|
395
|
+
const b64 = (await fs.promises.readFile(path.join(dir, entry.name), 'utf8')).trim();
|
|
396
|
+
// Account names are filenames (no '|' on Windows) and ciphertexts
|
|
397
|
+
// are base64 — the line protocol below stays unambiguous.
|
|
398
|
+
if (b64) rows.push({ account, generation: _cacheGenerations.get(account) || 0, b64 });
|
|
399
|
+
} catch {
|
|
400
|
+
// Individual corrupt or deleted entries must not prevent the
|
|
401
|
+
// remaining secrets from warming.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (rows.length === 0) return;
|
|
405
|
+
const r = await powershellAsync(
|
|
406
|
+
PS_UNPROTECT_BATCH,
|
|
407
|
+
rows.map((row) => `${row.account}|${row.b64}`).join('\n') + '\n',
|
|
408
|
+
);
|
|
409
|
+
if (r.error || r.status !== 0) return;
|
|
410
|
+
const generations = new Map(rows.map((row) => [row.account, row.generation]));
|
|
411
|
+
for (const line of String(r.stdout || '').split(/\r?\n/)) {
|
|
412
|
+
const separator = line.indexOf('|');
|
|
413
|
+
if (separator <= 0) continue;
|
|
414
|
+
const account = line.slice(0, separator);
|
|
415
|
+
if (!generations.has(account)) continue;
|
|
416
|
+
try {
|
|
417
|
+
const value = Buffer.from(line.slice(separator + 1), 'base64').toString('utf8');
|
|
418
|
+
if (!value) continue;
|
|
419
|
+
if (_cacheEpoch !== epoch || (_cacheGenerations.get(account) || 0) !== generations.get(account)) continue;
|
|
420
|
+
_cacheSet(account, value);
|
|
421
|
+
} catch {
|
|
422
|
+
// An undecodable row must not prevent the remaining secrets from warming.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
375
425
|
} catch {
|
|
376
426
|
// Prewarming is an optional startup optimization and must never fail boot.
|
|
377
427
|
}
|
|
@@ -285,7 +285,7 @@ export function makeAgentDispatch(opts = {}) {
|
|
|
285
285
|
}
|
|
286
286
|
const agent = opts.agent;
|
|
287
287
|
|
|
288
|
-
return async function agentDispatch({ prompt, preset: presetArg, sourceName: sourceNameArg, parentSignal: callParentSignal, idleTimeoutMs: callIdleTimeoutMs }) {
|
|
288
|
+
return async function agentDispatch({ prompt, preset: presetArg, sourceName: sourceNameArg, parentSignal: callParentSignal, idleTimeoutMs: callIdleTimeoutMs, cwd: callCwd }) {
|
|
289
289
|
if (typeof prompt !== 'string' || !prompt) {
|
|
290
290
|
throw new Error(`[agent-dispatch] prompt required for agent "${agent}"`);
|
|
291
291
|
}
|
|
@@ -355,7 +355,9 @@ export function makeAgentDispatch(opts = {}) {
|
|
|
355
355
|
// and the downstream skill-discovery path tolerates null. Combined
|
|
356
356
|
// with the frozen agent skill meta-tools (collect.mjs) this keeps
|
|
357
357
|
// every caller on the same provider cache shard.
|
|
358
|
-
const cwd = (typeof
|
|
358
|
+
const cwd = (typeof callCwd === 'string' && callCwd)
|
|
359
|
+
? callCwd
|
|
360
|
+
: ((typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : null);
|
|
359
361
|
|
|
360
362
|
// Unified dispatch: Pool B/C share bit-identical tools + system prompt
|
|
361
363
|
// unless a hidden role declares a narrow toolSchemaProfile. Per-role
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* BP_1 system#1 (1h) — shared tool policy + compact skill manifest
|
|
11
11
|
* BP_2 system#2 (1h) — role/system rules (Lead / agent / hidden role)
|
|
12
12
|
* BP_3 system#3 (1h) — stable memory/meta marker (sessionMarker system block; tier3)
|
|
13
|
-
* BP_4 messages (
|
|
13
|
+
* BP_4 messages (1h public/hidden agents; Lead linked to autoClear,
|
|
14
|
+
* whose Anthropic default is 1h — see below) —
|
|
14
15
|
* sliding tool_result / prior user-text tail
|
|
15
16
|
*
|
|
16
17
|
* Tool schemas still sit before system in the provider prompt prefix. We do
|
|
@@ -19,8 +20,8 @@
|
|
|
19
20
|
* agent worker tool schemas byte-stable is therefore still load-bearing.
|
|
20
21
|
*
|
|
21
22
|
* Tier 3 gets its own BP because memory/meta context is stable within the
|
|
22
|
-
* session. The sliding messages BP handles tool_result accumulation and
|
|
23
|
-
* per-call task/event data
|
|
23
|
+
* session. The sliding 1h messages BP handles tool_result accumulation and
|
|
24
|
+
* per-call task/event data while isolating volatile text from stable prefix BPs.
|
|
24
25
|
*
|
|
25
26
|
* Non-breakpoint providers:
|
|
26
27
|
* - OpenAI (public): prompt_cache_key + prompt_cache_retention=24h
|
|
@@ -73,18 +74,18 @@ function isOneShotMaintenanceAgent(agent) {
|
|
|
73
74
|
* BP1~3 stay at 1h: the system/role/tier3 prefix is shared across sessions
|
|
74
75
|
* (pool-stable), so the 2x write premium is amortized cross-session and the
|
|
75
76
|
* warm window survives per-session gaps. The volatile message tail (BP4) is
|
|
76
|
-
* per-session
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* per-session, but the 2026-07-16 6.8h trace supports a 1h tail: 24/25 Lead
|
|
78
|
+
* intra-session gaps over 5m were agent waits (5–27m), during which autoClear
|
|
79
|
+
* correctly cannot fire; 32.7% of agent intra-session gaps exceeded 5m due to
|
|
80
|
+
* long tool executions, with sessions surviving through reviewer fix-loop
|
|
81
|
+
* reuse; and no gap over 1h was observed. Tail-cost simulation (input-token
|
|
82
|
+
* equivalents) is Lead 1h=11.75M vs 5m=20.11M and agents 1h=14.46M vs
|
|
83
|
+
* 5m=45.04M: misses that rewrite the accumulated tail dominate the 2x-vs-1.25x
|
|
84
|
+
* write premium. Hidden and public-agent sessions therefore use a 1h tail.
|
|
81
85
|
*
|
|
82
|
-
* Lead sessions are linked to the user's autoClear idle-sweep config
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* benefits from the 1h tail TTL (fewer writes over a long-lived session),
|
|
86
|
-
* while a short idle-sweep window means the tail is going cold anyway and
|
|
87
|
-
* 5m is cheaper to write.
|
|
86
|
+
* Lead sessions are linked to the user's autoClear idle-sweep config (see
|
|
87
|
+
* resolveLeadMessagesTtl); its Anthropic default is 1h, while explicit
|
|
88
|
+
* shorter overrides retain the shorter-sweep behavior.
|
|
88
89
|
* (Tail TTL only affects explicit-breakpoint providers — Anthropic; no-op
|
|
89
90
|
* elsewhere.)
|
|
90
91
|
*
|
|
@@ -120,12 +121,12 @@ export function resolveCacheStrategy(agent, { autoClear } = {}) {
|
|
|
120
121
|
return { tools: 'none', system: 'none', tier3: 'none', messages: 'none' };
|
|
121
122
|
}
|
|
122
123
|
if (getHiddenAgent(agent)) {
|
|
123
|
-
return { tools: 'none', system: '1h', tier3: '1h', messages: '
|
|
124
|
+
return { tools: 'none', system: '1h', tier3: '1h', messages: '1h' };
|
|
124
125
|
}
|
|
125
126
|
if (agent && agent !== 'lead') {
|
|
126
|
-
// Public (non-hidden, non-lead) agents keep the flat
|
|
127
|
+
// Public (non-hidden, non-lead) agents keep the flat 1h tail — only
|
|
127
128
|
// the Lead session's tail is linked to autoClear.
|
|
128
|
-
return { tools: 'none', system: '1h', tier3: '1h', messages: '
|
|
129
|
+
return { tools: 'none', system: '1h', tier3: '1h', messages: '1h' };
|
|
129
130
|
}
|
|
130
131
|
// Lead session (agent === 'lead', or no agent — raw/CLI callers default
|
|
131
132
|
// to Lead behavior): message tail TTL is linked to autoClear (see
|
|
@@ -21,6 +21,15 @@ const MIXDOG_SLOW_TOOL_TRACE_NAMES = new Set(
|
|
|
21
21
|
.map((name) => name.trim())
|
|
22
22
|
.filter(Boolean)
|
|
23
23
|
);
|
|
24
|
+
const RECOVERED_ERROR_MESSAGE_MAX_CHARS = 300;
|
|
25
|
+
|
|
26
|
+
function compactRecoveredErrorMessage(value) {
|
|
27
|
+
if (value == null) return null;
|
|
28
|
+
const message = String(value);
|
|
29
|
+
return message.length > RECOVERED_ERROR_MESSAGE_MAX_CHARS
|
|
30
|
+
? `${message.slice(0, 299)}…`
|
|
31
|
+
: message;
|
|
32
|
+
}
|
|
24
33
|
|
|
25
34
|
function traceAgentLoop({ sessionId, iteration, sendMs, messageCount, bodyBytesEst, agent = null }) {
|
|
26
35
|
// Two emit modes, no behavior change either way:
|
|
@@ -67,6 +76,7 @@ function traceAgentCompact({
|
|
|
67
76
|
model,
|
|
68
77
|
error,
|
|
69
78
|
error_code,
|
|
79
|
+
recovered_error,
|
|
70
80
|
details,
|
|
71
81
|
}) {
|
|
72
82
|
appendAgentTrace({
|
|
@@ -96,6 +106,14 @@ function traceAgentCompact({
|
|
|
96
106
|
model: model || null,
|
|
97
107
|
error: error || null,
|
|
98
108
|
error_code: error_code || null,
|
|
109
|
+
recovered_error: recovered_error && typeof recovered_error === 'object'
|
|
110
|
+
? {
|
|
111
|
+
code: recovered_error.code ?? null,
|
|
112
|
+
// Capture already strips ANSI/newlines; retain this writer-side
|
|
113
|
+
// cap for any future compact-meta caller.
|
|
114
|
+
message: compactRecoveredErrorMessage(recovered_error.message),
|
|
115
|
+
}
|
|
116
|
+
: null,
|
|
99
117
|
details: details && typeof details === 'object' ? details : null,
|
|
100
118
|
});
|
|
101
119
|
}
|
|
@@ -3,6 +3,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
3
3
|
|
|
4
4
|
export const PROVIDER_ACCOUNT_CONCURRENCY = 64;
|
|
5
5
|
export const PROVIDER_ACCOUNT_MAX_QUEUE = 1024;
|
|
6
|
+
// A cooldown longer than this is a quota-window block (subscription limit),
|
|
7
|
+
// not a transient burst: parking requests silently for it would look like a
|
|
8
|
+
// dead chat. Longer cooldowns fail fast with a visible error instead.
|
|
9
|
+
export const PROVIDER_COOLDOWN_FAIL_FAST_MS = 60_000;
|
|
6
10
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
7
11
|
const currentAdmission = new AsyncLocalStorage();
|
|
8
12
|
|
|
@@ -23,6 +27,27 @@ function isAnthropicLane(key) {
|
|
|
23
27
|
return provider === 'anthropic' || provider === 'anthropic-oauth';
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function providerLabelForLane(key) {
|
|
31
|
+
const prefix = String(key).split(':', 1)[0];
|
|
32
|
+
const lower = prefix.toLowerCase();
|
|
33
|
+
if (lower === 'anthropic-oauth') return 'Anthropic OAuth';
|
|
34
|
+
if (lower === 'anthropic') return 'Anthropic';
|
|
35
|
+
return prefix;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Compact duration without spaces/colons so err-text's `retryAfter=([^\s:]+)`
|
|
39
|
+
// capture keeps the full value (e.g. 1h42m, 3m20s, 45s).
|
|
40
|
+
function formatCooldownMs(ms) {
|
|
41
|
+
const totalSec = Math.max(1, Math.ceil((Number(ms) || 0) / 1000));
|
|
42
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
43
|
+
const totalMin = Math.floor(totalSec / 60);
|
|
44
|
+
const sec = totalSec % 60;
|
|
45
|
+
if (totalMin < 60) return sec ? `${totalMin}m${sec}s` : `${totalMin}m`;
|
|
46
|
+
const hr = Math.floor(totalMin / 60);
|
|
47
|
+
const min = totalMin % 60;
|
|
48
|
+
return min ? `${hr}h${min}m` : `${hr}h`;
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
function retryAfterMs(error, now) {
|
|
27
52
|
const explicit = Number(error?.retryAfterMs);
|
|
28
53
|
if (Number.isFinite(explicit) && explicit >= 0) return explicit;
|
|
@@ -47,6 +72,28 @@ export class ProviderAdmissionQueueOverflowError extends Error {
|
|
|
47
72
|
}
|
|
48
73
|
}
|
|
49
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Deterministic refusal while a long rate-limit cooldown is active. Surfaced
|
|
77
|
+
* to the caller immediately (turn fails with a visible error) instead of
|
|
78
|
+
* silently parking the request until the quota window resets. httpStatus 429
|
|
79
|
+
* routes it through the existing quota/rate-limit error presentation.
|
|
80
|
+
*/
|
|
81
|
+
export class ProviderCooldownError extends Error {
|
|
82
|
+
constructor(key, cooldownUntil, now) {
|
|
83
|
+
const remaining = Math.max(0, Number(cooldownUntil) - Number(now));
|
|
84
|
+
super(
|
|
85
|
+
`${providerLabelForLane(key)} rate-limit cooldown active; retryAfter=${formatCooldownMs(remaining)} — `
|
|
86
|
+
+ 'wait for the quota window reset, or re-login / switch the provider account to continue now.',
|
|
87
|
+
);
|
|
88
|
+
this.name = 'ProviderCooldownError';
|
|
89
|
+
this.code = 'EPROVIDERCOOLDOWN';
|
|
90
|
+
this.httpStatus = 429;
|
|
91
|
+
this.laneKey = key;
|
|
92
|
+
this.cooldownUntil = cooldownUntil;
|
|
93
|
+
this.retryAfterMs = remaining;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
/**
|
|
51
98
|
* Per-account FIFO admission. Anthropic lanes use rate-limit cooldown and
|
|
52
99
|
* additive recovery; other providers retain a fixed concurrency limit.
|
|
@@ -95,6 +142,12 @@ export class ProviderAdmissionScheduler {
|
|
|
95
142
|
cooldownTimer: null,
|
|
96
143
|
};
|
|
97
144
|
this.lanes.set(laneKey, lane);
|
|
145
|
+
// Long cooldown = quota window. Fail fast with a visible error; short
|
|
146
|
+
// cooldowns (bursts) keep the silent park-and-drain smoothing below.
|
|
147
|
+
if (lane.adaptive && lane.cooldownUntil - this.now() > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
|
|
148
|
+
this._scheduleCooldown(laneKey, lane);
|
|
149
|
+
return Promise.reject(new ProviderCooldownError(laneKey, lane.cooldownUntil, this.now()));
|
|
150
|
+
}
|
|
98
151
|
if (lane.queue.length >= this.maxQueue) {
|
|
99
152
|
return Promise.reject(new ProviderAdmissionQueueOverflowError(laneKey, this.maxQueue));
|
|
100
153
|
}
|
|
@@ -120,6 +173,9 @@ export class ProviderAdmissionScheduler {
|
|
|
120
173
|
|
|
121
174
|
_drain(key, lane) {
|
|
122
175
|
if (lane.adaptive && lane.cooldownUntil > this.now()) {
|
|
176
|
+
if (lane.cooldownUntil - this.now() > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
|
|
177
|
+
this._rejectQueueForCooldown(key, lane);
|
|
178
|
+
}
|
|
123
179
|
this._scheduleCooldown(key, lane);
|
|
124
180
|
return;
|
|
125
181
|
}
|
|
@@ -187,9 +243,55 @@ export class ProviderAdmissionScheduler {
|
|
|
187
243
|
Math.min(Number.MAX_SAFE_INTEGER, now + retryAfterMs(error, now)),
|
|
188
244
|
);
|
|
189
245
|
this._scheduleCooldown(key, lane);
|
|
246
|
+
// A quota-window cooldown must not leave already-queued requests
|
|
247
|
+
// hanging silently for hours — reject them with the same visible error
|
|
248
|
+
// the fail-fast admission path raises.
|
|
249
|
+
if (lane.cooldownUntil - now > PROVIDER_COOLDOWN_FAIL_FAST_MS) {
|
|
250
|
+
this._rejectQueueForCooldown(key, lane);
|
|
251
|
+
}
|
|
190
252
|
return true;
|
|
191
253
|
}
|
|
192
254
|
|
|
255
|
+
_rejectQueueForCooldown(key, lane) {
|
|
256
|
+
if (!lane.queue.length) return;
|
|
257
|
+
for (const item of lane.queue.splice(0)) {
|
|
258
|
+
if (item.canceled) {
|
|
259
|
+
this._detach(item);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
item.canceled = true;
|
|
263
|
+
this._detach(item);
|
|
264
|
+
item.task = null;
|
|
265
|
+
item.reject(new ProviderCooldownError(key, lane.cooldownUntil, this.now()));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Clear rate-limit cooldowns (optionally for one provider). Called when
|
|
271
|
+
* provider credentials change — a re-login / account switch invalidates
|
|
272
|
+
* the old account's quota window, so requests must flow again immediately.
|
|
273
|
+
* Returns the number of lanes reset.
|
|
274
|
+
*/
|
|
275
|
+
resetCooldowns(providerName = null) {
|
|
276
|
+
const wanted = providerName ? String(providerName).toLowerCase() : null;
|
|
277
|
+
let resetCount = 0;
|
|
278
|
+
for (const [key, lane] of [...this.lanes]) {
|
|
279
|
+
if (!lane.adaptive) continue;
|
|
280
|
+
if (wanted && String(key).split(':', 1)[0].toLowerCase() !== wanted) continue;
|
|
281
|
+
if (lane.cooldownUntil <= this.now() && lane.limit >= this.concurrency) continue;
|
|
282
|
+
lane.cooldownUntil = 0;
|
|
283
|
+
lane.limit = this.concurrency;
|
|
284
|
+
lane.recoverySuccesses = 0;
|
|
285
|
+
if (lane.cooldownTimer) {
|
|
286
|
+
this.clearTimer(lane.cooldownTimer);
|
|
287
|
+
lane.cooldownTimer = null;
|
|
288
|
+
}
|
|
289
|
+
resetCount += 1;
|
|
290
|
+
this._drain(key, lane);
|
|
291
|
+
}
|
|
292
|
+
return resetCount;
|
|
293
|
+
}
|
|
294
|
+
|
|
193
295
|
_recordSuccess(lane) {
|
|
194
296
|
if (!lane.adaptive || lane.limit >= this.concurrency) return;
|
|
195
297
|
lane.recoverySuccesses += 1;
|
|
@@ -304,6 +406,15 @@ export function providerAdmissionKey(providerName, provider) {
|
|
|
304
406
|
export const providerAdmissionScheduler = new ProviderAdmissionScheduler();
|
|
305
407
|
const WRAPPED = Symbol.for('mixdog.providerAdmissionWrapped');
|
|
306
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Process-wide cooldown reset on the shared scheduler. Wired to provider auth
|
|
411
|
+
* mutations (login / API-key save / forget) so switching accounts resumes
|
|
412
|
+
* chat without a process restart.
|
|
413
|
+
*/
|
|
414
|
+
export function resetProviderAdmissionCooldowns(providerName = null) {
|
|
415
|
+
return providerAdmissionScheduler.resetCooldowns(providerName);
|
|
416
|
+
}
|
|
417
|
+
|
|
307
418
|
export function wrapProviderAdmission(provider, providerName, scheduler = providerAdmissionScheduler) {
|
|
308
419
|
if (!provider || typeof provider.send !== 'function' || provider[WRAPPED]) return provider;
|
|
309
420
|
const originalSend = provider.send;
|