mixdog 0.9.51 → 0.9.52
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 +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +320 -1
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +5 -7
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +6 -3
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/tui/dist/index.mjs +73 -278
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ANTHROPIC_RETRY_BACKOFF_MS,
|
|
6
|
+
ANTHROPIC_RETRY_JITTER_RATIO,
|
|
7
|
+
AnthropicFallbackTriggeredError,
|
|
8
|
+
anthropicMaxAttempts,
|
|
9
|
+
anthropicRequestTimeoutMs,
|
|
10
|
+
classifyError,
|
|
11
|
+
classifyMidstreamError,
|
|
12
|
+
MAX_SAFE_TIMEOUT_MS,
|
|
13
|
+
MIDSTREAM_RETRY_POLICY,
|
|
14
|
+
withRetry,
|
|
15
|
+
} from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
|
|
16
|
+
import { _midstreamSleepWithAbort, parseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-sse.mjs';
|
|
17
|
+
import { AnthropicProvider } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
|
|
18
|
+
import { AnthropicOAuthProvider } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
|
|
19
|
+
|
|
20
|
+
test('Anthropic transport classifier covers every 5xx, 529, and nested connection errno', () => {
|
|
21
|
+
for (const status of [500, 501, 505, 529, 599]) {
|
|
22
|
+
assert.equal(classifyError({ status }), 'transient');
|
|
23
|
+
}
|
|
24
|
+
assert.equal(classifyError({ code: 'EWRAPPED', cause: { code: 'EUNKNOWN', cause: { code: 'ECONNRESET' } } }), 'transient');
|
|
25
|
+
assert.equal(classifyError({ name: 'APIConnectionError' }), 'transient');
|
|
26
|
+
assert.equal(classifyError({ code: 'TRUNCATED_STREAM', partialToolCall: true }), 'permanent');
|
|
27
|
+
assert.equal(classifyError({ code: 'TRUNCATED_STREAM', emittedThinking: true }), 'permanent');
|
|
28
|
+
assert.equal(classifyError({ status: 403 }), 'auth');
|
|
29
|
+
assert.equal(classifyError({ code: 'TRUNCATED_STREAM', status: 401 }), 'auth');
|
|
30
|
+
assert.equal(classifyError(Object.assign(new Error('canceled'), {
|
|
31
|
+
name: 'AbortError',
|
|
32
|
+
cause: { code: 'ECONNRESET' },
|
|
33
|
+
})), 'permanent');
|
|
34
|
+
assert.equal(classifyError({ status: 418, cause: { code: 'ECONNRESET' } }), 'permanent');
|
|
35
|
+
assert.equal(classifyError({ status: 409, cause: { code: 'EUNKNOWN' } }), 'transient');
|
|
36
|
+
|
|
37
|
+
const sdkAbort = Object.assign(new Error('Request was aborted.'), {
|
|
38
|
+
cause: { code: 'ECONNRESET' },
|
|
39
|
+
});
|
|
40
|
+
Object.defineProperty(sdkAbort, 'constructor', { value: { name: 'APIUserAbortError' } });
|
|
41
|
+
assert.equal(sdkAbort.name, 'Error');
|
|
42
|
+
assert.equal(classifyError(sdkAbort), 'permanent');
|
|
43
|
+
assert.equal(classifyError({
|
|
44
|
+
name: 'Error',
|
|
45
|
+
type: 'api_user_abort_error',
|
|
46
|
+
cause: { code: 'ECONNRESET' },
|
|
47
|
+
}), 'permanent');
|
|
48
|
+
assert.equal(classifyError({
|
|
49
|
+
name: 'Error',
|
|
50
|
+
message: 'Request was aborted.',
|
|
51
|
+
cause: { code: 'ECONNRESET' },
|
|
52
|
+
}), 'transient', 'abort-like message alone is not an SDK type marker');
|
|
53
|
+
|
|
54
|
+
const cyclicA = { code: 'EWRAPPED' };
|
|
55
|
+
const cyclicB = { code: 'EUNKNOWN', cause: cyclicA };
|
|
56
|
+
cyclicA.cause = cyclicB;
|
|
57
|
+
assert.equal(classifyError(cyclicA), 'unknown');
|
|
58
|
+
cyclicB.code = 'ECONNRESET';
|
|
59
|
+
assert.equal(classifyError(cyclicA), 'transient');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('API-key Anthropic reloads typed 401 once while ordinary 403 is terminal', async () => {
|
|
63
|
+
const provider401 = Object.create(AnthropicProvider.prototype);
|
|
64
|
+
let attempts401 = 0;
|
|
65
|
+
let reloads401 = 0;
|
|
66
|
+
provider401._doSend = async () => {
|
|
67
|
+
attempts401 += 1;
|
|
68
|
+
if (attempts401 === 1) throw Object.assign(new Error('typed auth'), { status: 401 });
|
|
69
|
+
return 'ok';
|
|
70
|
+
};
|
|
71
|
+
provider401.reloadApiKey = () => { reloads401 += 1; };
|
|
72
|
+
assert.equal(await provider401.send([], 'claude', [], {}), 'ok');
|
|
73
|
+
assert.equal(attempts401, 2);
|
|
74
|
+
assert.equal(reloads401, 1);
|
|
75
|
+
|
|
76
|
+
const provider403 = Object.create(AnthropicProvider.prototype);
|
|
77
|
+
let attempts403 = 0;
|
|
78
|
+
let reloads403 = 0;
|
|
79
|
+
const forbidden = Object.assign(new Error('ordinary forbidden'), { status: 403 });
|
|
80
|
+
provider403._doSend = async () => { attempts403 += 1; throw forbidden; };
|
|
81
|
+
provider403.reloadApiKey = () => { reloads403 += 1; };
|
|
82
|
+
await assert.rejects(provider403.send([], 'claude', [], {}), (err) => err === forbidden);
|
|
83
|
+
assert.equal(attempts403, 1);
|
|
84
|
+
assert.equal(reloads403, 0);
|
|
85
|
+
|
|
86
|
+
for (const marker of ['partialToolCall', 'emittedThinking']) {
|
|
87
|
+
const provider = Object.create(AnthropicProvider.prototype);
|
|
88
|
+
let attempts = 0;
|
|
89
|
+
let reloads = 0;
|
|
90
|
+
const exposed401 = Object.assign(new Error(`401 after ${marker}`), {
|
|
91
|
+
status: 401,
|
|
92
|
+
[marker]: true,
|
|
93
|
+
});
|
|
94
|
+
provider._doSend = async () => { attempts += 1; throw exposed401; };
|
|
95
|
+
provider.reloadApiKey = () => { reloads += 1; };
|
|
96
|
+
await assert.rejects(provider.send([], 'claude', [], {}), (err) => err === exposed401);
|
|
97
|
+
assert.equal(attempts, 1, `${marker} must prevent 401 replay`);
|
|
98
|
+
assert.equal(reloads, 0, `${marker} must prevent key reload`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('permanent quota 429 is not retried by the generic rate-limit path', async () => {
|
|
103
|
+
let attempts = 0;
|
|
104
|
+
const quota = new Error('request failed: insufficient_quota');
|
|
105
|
+
await assert.rejects(withRetry(async () => {
|
|
106
|
+
attempts += 1;
|
|
107
|
+
throw quota;
|
|
108
|
+
}, { maxAttempts: 3, backoffMs: [0], retryJitterRatio: 0 }), (err) => err === quota);
|
|
109
|
+
assert.equal(attempts, 1);
|
|
110
|
+
assert.equal(classifyError(quota), 'permanent');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('structured and nested permanent quota codes are terminal; rate-limit codes retry', async () => {
|
|
114
|
+
const terminalCases = [
|
|
115
|
+
Object.assign(new Error('generic 429'), { status: 429, code: 'insufficient_quota' }),
|
|
116
|
+
Object.assign(new Error('generic 429'), { status: 429, error: { code: 'quota_exceeded' } }),
|
|
117
|
+
Object.assign(new Error('generic 429'), {
|
|
118
|
+
status: 429,
|
|
119
|
+
cause: { code: 'EWRAPPED', cause: { error: { code: 'resource_exhausted' } } },
|
|
120
|
+
}),
|
|
121
|
+
];
|
|
122
|
+
for (const quota of terminalCases) {
|
|
123
|
+
let attempts = 0;
|
|
124
|
+
await assert.rejects(withRetry(async () => {
|
|
125
|
+
attempts += 1;
|
|
126
|
+
throw quota;
|
|
127
|
+
}, { maxAttempts: 2, backoffMs: [0], retryJitterRatio: 0 }), (err) => err === quota);
|
|
128
|
+
assert.equal(attempts, 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const code of ['rate_limit_error', 'rate_limit_exceeded']) {
|
|
132
|
+
let attempts = 0;
|
|
133
|
+
const result = await withRetry(async () => {
|
|
134
|
+
attempts += 1;
|
|
135
|
+
if (attempts === 1) throw Object.assign(new Error('generic 429'), { status: 429, code });
|
|
136
|
+
return 'recovered';
|
|
137
|
+
}, { maxAttempts: 2, backoffMs: [0], retryJitterRatio: 0 });
|
|
138
|
+
assert.equal(result, 'recovered');
|
|
139
|
+
assert.equal(attempts, 2);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('partial tool/thinking markers veto every request retry path, including 429', async () => {
|
|
144
|
+
for (const status of [429, 529]) {
|
|
145
|
+
for (const marker of ['partialToolCall', 'emittedThinking']) {
|
|
146
|
+
let attempts = 0;
|
|
147
|
+
const exposed = Object.assign(new Error(`${status} after ${marker}`), {
|
|
148
|
+
status,
|
|
149
|
+
[marker]: true,
|
|
150
|
+
});
|
|
151
|
+
await assert.rejects(withRetry(async () => {
|
|
152
|
+
attempts += 1;
|
|
153
|
+
throw exposed;
|
|
154
|
+
}, {
|
|
155
|
+
maxAttempts: 4,
|
|
156
|
+
backoffMs: [0],
|
|
157
|
+
retryJitterRatio: 0,
|
|
158
|
+
model: 'claude-opus-primary',
|
|
159
|
+
fallbackModel: 'claude-sonnet-fallback',
|
|
160
|
+
}), (err) => err === exposed);
|
|
161
|
+
assert.equal(attempts, 1, `${status}/${marker} must not replay`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('optional fallback triggers on the third 529 and never when absent', async () => {
|
|
167
|
+
let attempts = 0;
|
|
168
|
+
await assert.rejects(withRetry(async () => {
|
|
169
|
+
attempts += 1;
|
|
170
|
+
throw Object.assign(new Error('busy'), { status: 529, httpStatus: 529 });
|
|
171
|
+
}, {
|
|
172
|
+
maxAttempts: 5,
|
|
173
|
+
backoffMs: [0],
|
|
174
|
+
retryJitterRatio: 0,
|
|
175
|
+
model: 'claude-opus-primary',
|
|
176
|
+
fallbackModel: 'claude-sonnet-fallback',
|
|
177
|
+
}), (err) => (
|
|
178
|
+
err instanceof AnthropicFallbackTriggeredError
|
|
179
|
+
&& err.originalModel === 'claude-opus-primary'
|
|
180
|
+
&& err.fallbackModel === 'claude-sonnet-fallback'
|
|
181
|
+
));
|
|
182
|
+
assert.equal(attempts, 3);
|
|
183
|
+
|
|
184
|
+
attempts = 0;
|
|
185
|
+
await assert.rejects(withRetry(async () => {
|
|
186
|
+
attempts += 1;
|
|
187
|
+
throw Object.assign(new Error('overloaded without fallback'), { status: 529 });
|
|
188
|
+
}, {
|
|
189
|
+
maxAttempts: 4,
|
|
190
|
+
backoffMs: [0],
|
|
191
|
+
retryJitterRatio: 0,
|
|
192
|
+
model: 'claude-opus-primary',
|
|
193
|
+
}), /without fallback/);
|
|
194
|
+
assert.equal(attempts, 4);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function successfulAnthropicResponse(content = 'fallback-ok') {
|
|
198
|
+
const bytes = new TextEncoder().encode([
|
|
199
|
+
{ type: 'message_start', message: { model: 'claude-sonnet-fallback', usage: { input_tokens: 1 } } },
|
|
200
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text' } },
|
|
201
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: content } },
|
|
202
|
+
{ type: 'content_block_stop', index: 0 },
|
|
203
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
|
|
204
|
+
{ type: 'message_stop' },
|
|
205
|
+
].map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''));
|
|
206
|
+
let sent = false;
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
status: 200,
|
|
210
|
+
headers: new Map(),
|
|
211
|
+
body: { getReader: () => ({
|
|
212
|
+
async read() {
|
|
213
|
+
if (sent) return { done: true };
|
|
214
|
+
sent = true;
|
|
215
|
+
return { done: false, value: bytes };
|
|
216
|
+
},
|
|
217
|
+
async cancel() {},
|
|
218
|
+
releaseLock() {},
|
|
219
|
+
}) },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test('OAuth and API-key providers switch to the opt-in fallback after three 529s', async () => {
|
|
224
|
+
const priorRetries = process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
225
|
+
process.env.CLAUDE_CODE_MAX_RETRIES = '2';
|
|
226
|
+
try {
|
|
227
|
+
const oauth = Object.create(AnthropicOAuthProvider.prototype);
|
|
228
|
+
oauth.credentials = { accessToken: 'fixture', expiresAt: Date.now() + 60_000 };
|
|
229
|
+
oauth.config = {};
|
|
230
|
+
oauth.fastModeBetaHeaderLatched = false;
|
|
231
|
+
oauth.ensureAuth = async () => oauth.credentials;
|
|
232
|
+
const oauthModels = [];
|
|
233
|
+
const oauthSend = AnthropicOAuthProvider.prototype.send;
|
|
234
|
+
oauth.send = function (messages, model, tools, opts) {
|
|
235
|
+
oauthModels.push(model);
|
|
236
|
+
return oauthSend.call(this, messages, model, tools, opts);
|
|
237
|
+
};
|
|
238
|
+
let oauthAttempts = 0;
|
|
239
|
+
const oauthResult = await oauth.send([], 'claude-opus-primary', [], {
|
|
240
|
+
fallbackModel: 'claude-sonnet-fallback',
|
|
241
|
+
_doRequestFn: async () => {
|
|
242
|
+
oauthAttempts += 1;
|
|
243
|
+
const ok = oauthAttempts > 3;
|
|
244
|
+
return {
|
|
245
|
+
response: {
|
|
246
|
+
status: ok ? 200 : 529,
|
|
247
|
+
ok,
|
|
248
|
+
headers: new Map([['retry-after', '0']]),
|
|
249
|
+
async text() { return ''; },
|
|
250
|
+
},
|
|
251
|
+
controller: { abort() {} },
|
|
252
|
+
cancelHandler: null,
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
_parseSSEFn: async () => ({
|
|
256
|
+
content: 'oauth-fallback-ok',
|
|
257
|
+
model: 'claude-sonnet-fallback',
|
|
258
|
+
usage: {},
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
assert.equal(oauthResult.content, 'oauth-fallback-ok');
|
|
262
|
+
assert.equal(oauthAttempts, 4);
|
|
263
|
+
assert.deepEqual(oauthModels, ['claude-opus-primary', 'claude-sonnet-fallback']);
|
|
264
|
+
|
|
265
|
+
const direct = Object.create(AnthropicProvider.prototype);
|
|
266
|
+
direct.name = 'anthropic';
|
|
267
|
+
direct.config = {};
|
|
268
|
+
direct.fastModeBetaHeaderLatched = false;
|
|
269
|
+
const directModels = [];
|
|
270
|
+
const directDoSend = AnthropicProvider.prototype._doSend;
|
|
271
|
+
direct._doSend = function (messages, model, tools, opts) {
|
|
272
|
+
directModels.push(model);
|
|
273
|
+
return directDoSend.call(this, messages, model, tools, opts);
|
|
274
|
+
};
|
|
275
|
+
let directAttempts = 0;
|
|
276
|
+
direct.client = { messages: { create() {
|
|
277
|
+
directAttempts += 1;
|
|
278
|
+
return { async asResponse() {
|
|
279
|
+
if (directAttempts <= 3) {
|
|
280
|
+
return { ok: false, status: 529, headers: new Map([['retry-after', '0']]), async text() { return ''; } };
|
|
281
|
+
}
|
|
282
|
+
return successfulAnthropicResponse('direct-fallback-ok');
|
|
283
|
+
} };
|
|
284
|
+
} } };
|
|
285
|
+
const directResult = await direct._doSend([], 'claude-opus-primary', [], {
|
|
286
|
+
fallbackModel: 'claude-sonnet-fallback',
|
|
287
|
+
});
|
|
288
|
+
assert.equal(directResult.content, 'direct-fallback-ok');
|
|
289
|
+
assert.equal(directAttempts, 4);
|
|
290
|
+
assert.deepEqual(directModels, ['claude-opus-primary', 'claude-sonnet-fallback']);
|
|
291
|
+
} finally {
|
|
292
|
+
if (priorRetries == null) delete process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
293
|
+
else process.env.CLAUDE_CODE_MAX_RETRIES = priorRetries;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('Anthropic retry budget defaults to ten retries and is configurable/bounded', () => {
|
|
298
|
+
const prior = process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
299
|
+
try {
|
|
300
|
+
delete process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
301
|
+
assert.equal(anthropicMaxAttempts(), 11);
|
|
302
|
+
process.env.CLAUDE_CODE_MAX_RETRIES = '2';
|
|
303
|
+
assert.equal(anthropicMaxAttempts(), 3);
|
|
304
|
+
process.env.CLAUDE_CODE_MAX_RETRIES = '9999';
|
|
305
|
+
assert.equal(anthropicMaxAttempts(), 101);
|
|
306
|
+
} finally {
|
|
307
|
+
if (prior == null) delete process.env.CLAUDE_CODE_MAX_RETRIES;
|
|
308
|
+
else process.env.CLAUDE_CODE_MAX_RETRIES = prior;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('Anthropic request timeout and exponential backoff match Claude Code defaults', async () => {
|
|
313
|
+
const priorTimeout = process.env.API_TIMEOUT_MS;
|
|
314
|
+
const priorRandom = Math.random;
|
|
315
|
+
try {
|
|
316
|
+
delete process.env.API_TIMEOUT_MS;
|
|
317
|
+
assert.equal(anthropicRequestTimeoutMs(), 600_000);
|
|
318
|
+
process.env.API_TIMEOUT_MS = '123456';
|
|
319
|
+
assert.equal(anthropicRequestTimeoutMs(), 123456);
|
|
320
|
+
|
|
321
|
+
Math.random = () => 0;
|
|
322
|
+
const waits = [];
|
|
323
|
+
let attempts = 0;
|
|
324
|
+
await assert.rejects(withRetry(async () => {
|
|
325
|
+
attempts += 1;
|
|
326
|
+
throw Object.assign(new Error('overloaded'), { status: 529 });
|
|
327
|
+
}, {
|
|
328
|
+
maxAttempts: 4,
|
|
329
|
+
backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
|
|
330
|
+
retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
|
|
331
|
+
retryJitterMode: 'positive',
|
|
332
|
+
sleepFn: async (ms) => { waits.push(ms); },
|
|
333
|
+
}), /overloaded/);
|
|
334
|
+
assert.equal(attempts, 4);
|
|
335
|
+
assert.deepEqual(waits, [500, 1000, 2000]);
|
|
336
|
+
} finally {
|
|
337
|
+
Math.random = priorRandom;
|
|
338
|
+
if (priorTimeout == null) delete process.env.API_TIMEOUT_MS;
|
|
339
|
+
else process.env.API_TIMEOUT_MS = priorTimeout;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('x-should-retry false vetoes retry before status defaults', async () => {
|
|
344
|
+
for (const status of [429, 529]) {
|
|
345
|
+
let attempts = 0;
|
|
346
|
+
const denied = Object.assign(new Error(`denied ${status}`), {
|
|
347
|
+
status,
|
|
348
|
+
headers: new Map([['x-should-retry', 'false'], ['retry-after', '0']]),
|
|
349
|
+
});
|
|
350
|
+
await assert.rejects(withRetry(async () => {
|
|
351
|
+
attempts += 1;
|
|
352
|
+
throw denied;
|
|
353
|
+
}, {
|
|
354
|
+
maxAttempts: 3,
|
|
355
|
+
backoffMs: [0],
|
|
356
|
+
retryJitterRatio: 0,
|
|
357
|
+
}), (err) => err === denied);
|
|
358
|
+
assert.equal(attempts, 1);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('Retry-After is not capped or jittered and remains abortable', async () => {
|
|
363
|
+
const ac = new AbortController();
|
|
364
|
+
let observed;
|
|
365
|
+
let calls = 0;
|
|
366
|
+
await assert.rejects(withRetry(async () => {
|
|
367
|
+
calls++;
|
|
368
|
+
const err = Object.assign(new Error('busy'), {
|
|
369
|
+
status: 529,
|
|
370
|
+
headers: new Map([['retry-after-ms', '123456']]),
|
|
371
|
+
});
|
|
372
|
+
throw err;
|
|
373
|
+
}, {
|
|
374
|
+
maxAttempts: 2,
|
|
375
|
+
signal: ac.signal,
|
|
376
|
+
maxRetryAfterMs: 1,
|
|
377
|
+
retryJitterRatio: 1,
|
|
378
|
+
onRetry: ({ delayMs }) => {
|
|
379
|
+
observed = delayMs;
|
|
380
|
+
ac.abort(new Error('test stop'));
|
|
381
|
+
},
|
|
382
|
+
}), /test stop/);
|
|
383
|
+
assert.equal(calls, 1);
|
|
384
|
+
assert.equal(observed, 123456);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('oversized HTTP Retry-After is chunked without Node timeout clamping', async () => {
|
|
388
|
+
const chunks = [];
|
|
389
|
+
let attempts = 0;
|
|
390
|
+
const result = await withRetry(async () => {
|
|
391
|
+
attempts += 1;
|
|
392
|
+
if (attempts === 1) {
|
|
393
|
+
throw Object.assign(new Error('busy'), {
|
|
394
|
+
status: 429,
|
|
395
|
+
headers: new Map([['retry-after-ms', '2147483648']]),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return 'ok';
|
|
399
|
+
}, {
|
|
400
|
+
maxAttempts: 2,
|
|
401
|
+
retryJitterRatio: 1,
|
|
402
|
+
sleepFn: async (ms) => { chunks.push(ms); },
|
|
403
|
+
});
|
|
404
|
+
assert.equal(result, 'ok');
|
|
405
|
+
assert.deepEqual(chunks, [MAX_SAFE_TIMEOUT_MS, 1]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('oversized Anthropic SSE Retry-After sleep uses the same safe chunks', async () => {
|
|
409
|
+
const chunks = [];
|
|
410
|
+
await _midstreamSleepWithAbort(
|
|
411
|
+
2_147_483_648,
|
|
412
|
+
null,
|
|
413
|
+
async (ms) => { chunks.push(ms); },
|
|
414
|
+
);
|
|
415
|
+
assert.deepEqual(chunks, [MAX_SAFE_TIMEOUT_MS, 1]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('SSE retries 429/5xx before output regardless of message_start, but not partial output', () => {
|
|
419
|
+
const policy = { mode: 'sse', defaultRetries: 3, perClassifierGate: false };
|
|
420
|
+
const clean = { attemptIndex: 0, sawCompleted: false, sawMessageStart: false };
|
|
421
|
+
assert.equal(classifyMidstreamError({ status: 429 }, clean, policy), 'http_429');
|
|
422
|
+
assert.equal(classifyMidstreamError({ status: 529 }, { ...clean, sawMessageStart: true }, policy), 'http_529');
|
|
423
|
+
for (const exposed of ['emittedText', 'emittedToolCall', 'partialToolCall', 'emittedThinking']) {
|
|
424
|
+
assert.equal(classifyMidstreamError({ status: 529 }, { ...clean, [exposed]: true }, policy), null);
|
|
425
|
+
}
|
|
426
|
+
assert.equal(MIDSTREAM_RETRY_POLICY.sse.defaultRetries, 3);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('successful SSE parsing cancels and releases the reader', async () => {
|
|
430
|
+
const encoded = new TextEncoder().encode(
|
|
431
|
+
'event: message_start\ndata: {"type":"message_start","message":{"model":"claude","usage":{}}}\n\n'
|
|
432
|
+
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
433
|
+
);
|
|
434
|
+
let reads = 0;
|
|
435
|
+
let cancels = 0;
|
|
436
|
+
let releases = 0;
|
|
437
|
+
const response = { body: { getReader: () => ({
|
|
438
|
+
read: async () => reads++ === 0 ? { done: false, value: encoded } : { done: true },
|
|
439
|
+
cancel: async () => { cancels++; },
|
|
440
|
+
releaseLock: () => { releases++; },
|
|
441
|
+
}) } };
|
|
442
|
+
const state = {};
|
|
443
|
+
await parseSSEStream(response, null, () => {}, null, null, state);
|
|
444
|
+
assert.equal(state.sawCompleted, true);
|
|
445
|
+
assert.equal(cancels, 1);
|
|
446
|
+
assert.equal(releases, 1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('pre-aborted SSE still cancels and releases its acquired reader', async () => {
|
|
450
|
+
const controller = new AbortController();
|
|
451
|
+
const reason = new Error('already canceled');
|
|
452
|
+
controller.abort(reason);
|
|
453
|
+
let cancels = 0;
|
|
454
|
+
let releases = 0;
|
|
455
|
+
const response = { body: { getReader: () => ({
|
|
456
|
+
read: async () => ({ done: true }),
|
|
457
|
+
cancel: async () => { cancels += 1; },
|
|
458
|
+
releaseLock: () => { releases += 1; },
|
|
459
|
+
}) } };
|
|
460
|
+
await assert.rejects(
|
|
461
|
+
parseSSEStream(response, controller.signal, () => {}, null, null, {}),
|
|
462
|
+
(err) => err === reason,
|
|
463
|
+
);
|
|
464
|
+
assert.equal(cancels, 1);
|
|
465
|
+
assert.equal(releases, 1);
|
|
466
|
+
});
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
// lock interop: neither can enter the critical section while the other holds.
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
utimesSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
8
15
|
import { tmpdir } from 'node:os';
|
|
9
16
|
import { join } from 'node:path';
|
|
10
17
|
import {
|
|
@@ -64,3 +71,55 @@ test('async holder blocks sync try-once, then sync acquires after release', asyn
|
|
|
64
71
|
rmSync(dir, { recursive: true, force: true });
|
|
65
72
|
}
|
|
66
73
|
});
|
|
74
|
+
|
|
75
|
+
test('published reclaim guards are non-revocable regardless of owner, contents, or age', () => {
|
|
76
|
+
for (const [name, content, aged] of [
|
|
77
|
+
['live', `${process.pid + 1} 1 live\n`, false],
|
|
78
|
+
['same-pid', `${process.pid} 1 sibling\n`, false],
|
|
79
|
+
['empty', '', false],
|
|
80
|
+
['malformed', 'not-a-guard', false],
|
|
81
|
+
['aged-dead', '2147483647 1 corpse\n', true],
|
|
82
|
+
]) {
|
|
83
|
+
const { dir, lockPath } = tmpLock();
|
|
84
|
+
try {
|
|
85
|
+
const guardPath = `${lockPath}.reclaim`;
|
|
86
|
+
writeFileSync(lockPath, '2147483647 1 dead-lock\n');
|
|
87
|
+
writeFileSync(guardPath, content);
|
|
88
|
+
if (aged) {
|
|
89
|
+
const old = new Date(Date.now() - 60000);
|
|
90
|
+
utimesSync(guardPath, old, old);
|
|
91
|
+
}
|
|
92
|
+
assert.throws(
|
|
93
|
+
() => withFileLockSync(lockPath, () => 'unreachable', { timeoutMs: 5, staleMs: 1 }),
|
|
94
|
+
(error) => error?.code === 'ELOCKTIMEOUT',
|
|
95
|
+
name,
|
|
96
|
+
);
|
|
97
|
+
assert.equal(readFileSync(guardPath, 'utf8'), content, name);
|
|
98
|
+
assert.equal(existsSync(lockPath), true, name);
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('overlapping dead-guard contenders neither delete nor replace the guard', async () => {
|
|
106
|
+
const { dir, lockPath } = tmpLock();
|
|
107
|
+
try {
|
|
108
|
+
const guardPath = `${lockPath}.reclaim`;
|
|
109
|
+
const guard = '2147483647 1 dead-guard\n';
|
|
110
|
+
writeFileSync(lockPath, '2147483647 1 dead-lock\n');
|
|
111
|
+
writeFileSync(guardPath, guard);
|
|
112
|
+
const attempts = await Promise.allSettled([
|
|
113
|
+
withFileLock(lockPath, () => 'unreachable', { timeoutMs: 10, staleMs: 1 }),
|
|
114
|
+
withFileLock(lockPath, () => 'unreachable', { timeoutMs: 10, staleMs: 1 }),
|
|
115
|
+
]);
|
|
116
|
+
assert.deepEqual(attempts.map(({ status, reason }) => [status, reason?.code]), [
|
|
117
|
+
['rejected', 'ELOCKTIMEOUT'],
|
|
118
|
+
['rejected', 'ELOCKTIMEOUT'],
|
|
119
|
+
]);
|
|
120
|
+
assert.equal(readFileSync(guardPath, 'utf8'), guard);
|
|
121
|
+
assert.equal(existsSync(lockPath), true);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
package/scripts/build-tui.mjs
CHANGED
|
@@ -31,6 +31,16 @@ const mixdogInkAliasPlugin = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
const sharedRuntimeExternalPlugin = {
|
|
35
|
+
name: 'mixdog-shared-runtime-external',
|
|
36
|
+
setup(build) {
|
|
37
|
+
build.onResolve({ filter: /^\.\.\/runtime\/shared\/process-(shutdown|lifecycle)\.mjs$/ }, (args) => ({
|
|
38
|
+
path: `../../runtime/shared/${args.path.slice('../runtime/shared/'.length)}`,
|
|
39
|
+
external: true,
|
|
40
|
+
}));
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
34
44
|
await build({
|
|
35
45
|
entryPoints: [join(SRC, 'index.jsx')],
|
|
36
46
|
outfile: join(SRC, 'dist', 'index.mjs'),
|
|
@@ -41,6 +51,8 @@ await build({
|
|
|
41
51
|
jsx: 'automatic',
|
|
42
52
|
// Keep package imports external like the original CLI flow. Local shared
|
|
43
53
|
// helpers are bundled so relative paths stay valid from src/tui/dist/.
|
|
54
|
+
// Process shutdown stays external so the CLI and checked-in TUI bundle use
|
|
55
|
+
// the same process-global lifecycle state.
|
|
44
56
|
// Only `ink` is redirected to Mixdog's checked-in renderer instead of
|
|
45
57
|
// node_modules/ink.
|
|
46
58
|
packages: 'external',
|
|
@@ -63,7 +75,7 @@ await build({
|
|
|
63
75
|
'../../runtime/channels/lib/voice-runtime-fetcher.mjs',
|
|
64
76
|
'../../runtime/channels/lib/whisper-server.mjs',
|
|
65
77
|
],
|
|
66
|
-
plugins: [mixdogInkAliasPlugin],
|
|
78
|
+
plugins: [mixdogInkAliasPlugin, sharedRuntimeExternalPlugin],
|
|
67
79
|
logLevel: 'info',
|
|
68
80
|
});
|
|
69
81
|
|