create-walle 0.9.30 → 0.9.32
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 +1 -1
- package/template/claude-task-manager/api-prompts.js +410 -5
- package/template/claude-task-manager/api-reviews.js +251 -3
- package/template/claude-task-manager/approval-agent.js +322 -24
- package/template/claude-task-manager/db.js +77 -13
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
- package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
- package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
- package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
- package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
- package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
- package/template/claude-task-manager/git-utils.js +164 -26
- package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
- package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
- package/template/claude-task-manager/lib/agent-presets.js +12 -0
- package/template/claude-task-manager/lib/agent-version-service.js +650 -0
- package/template/claude-task-manager/lib/approval-drift.js +57 -0
- package/template/claude-task-manager/lib/approval-hook.js +13 -0
- package/template/claude-task-manager/lib/auth-rules.js +6 -0
- package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
- package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
- package/template/claude-task-manager/lib/document-review.js +75 -1
- package/template/claude-task-manager/lib/escalation-review.js +33 -3
- package/template/claude-task-manager/lib/headless-term-service.js +54 -9
- package/template/claude-task-manager/lib/mcp-risk.js +128 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
- package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
- package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
- package/template/claude-task-manager/lib/permission-match.js +77 -5
- package/template/claude-task-manager/lib/read-pool-client.js +15 -6
- package/template/claude-task-manager/lib/restore-backoff.js +82 -0
- package/template/claude-task-manager/lib/runtime-registry.js +1 -0
- package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
- package/template/claude-task-manager/lib/session-capture.js +33 -6
- package/template/claude-task-manager/lib/session-history.js +60 -3
- package/template/claude-task-manager/lib/session-jobs.js +19 -0
- package/template/claude-task-manager/lib/session-restore.js +5 -4
- package/template/claude-task-manager/lib/session-standup.js +2 -0
- package/template/claude-task-manager/lib/session-stream.js +9 -0
- package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
- package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
- package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
- package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
- package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
- package/template/claude-task-manager/package.json +1 -1
- package/template/claude-task-manager/providers/claude-code.js +20 -1
- package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
- package/template/claude-task-manager/providers/codex-mcp.js +23 -20
- package/template/claude-task-manager/providers/codex.js +65 -1
- package/template/claude-task-manager/public/css/prompts.css +345 -0
- package/template/claude-task-manager/public/css/reviews.css +248 -40
- package/template/claude-task-manager/public/css/walle-session.css +90 -26
- package/template/claude-task-manager/public/index.html +2031 -428
- package/template/claude-task-manager/public/js/document-review-links.js +255 -13
- package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
- package/template/claude-task-manager/public/js/message-renderer.js +46 -1
- package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
- package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
- package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
- package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
- package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
- package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
- package/template/claude-task-manager/public/js/prompts.js +683 -215
- package/template/claude-task-manager/public/js/resume-state.js +186 -0
- package/template/claude-task-manager/public/js/reviews.js +402 -121
- package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
- package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
- package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
- package/template/claude-task-manager/public/js/stream-view.js +93 -20
- package/template/claude-task-manager/public/js/walle-session.js +183 -32
- package/template/claude-task-manager/public/js/walle.js +21 -9
- package/template/claude-task-manager/public/m/app.css +117 -0
- package/template/claude-task-manager/public/m/app.js +309 -3
- package/template/claude-task-manager/public/m/index.html +56 -1
- package/template/claude-task-manager/server.js +724 -305
- package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
- package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
- package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
- package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
- package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +21 -3
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
- package/template/wall-e/brain.js +50 -5
- package/template/wall-e/chat/force-compact.js +73 -0
- package/template/wall-e/chat.js +137 -9
- package/template/wall-e/coding/action-memory-policy.js +120 -1
- package/template/wall-e/coding/compaction-service.js +10 -3
- package/template/wall-e/coding/model-router.js +116 -0
- package/template/wall-e/coding-orchestrator.js +303 -11
- package/template/wall-e/coding-prompts.js +1 -1
- package/template/wall-e/docs/skill-self-heal-design.html +429 -0
- package/template/wall-e/llm/client.js +115 -6
- package/template/wall-e/llm/codex-cli.js +71 -23
- package/template/wall-e/llm/codex-cli.plugin.json +1 -0
- package/template/wall-e/llm/default-fallback.js +71 -11
- package/template/wall-e/llm/mlx-worker.js +0 -0
- package/template/wall-e/llm/mlx.js +46 -12
- package/template/wall-e/llm/provider-error.js +9 -3
- package/template/wall-e/llm/provider-health-state.js +561 -11
- package/template/wall-e/llm/registry.js +6 -0
- package/template/wall-e/llm/routing-policy.js +255 -25
- package/template/wall-e/llm/tool-call-validator.js +156 -0
- package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
- package/template/wall-e/skills/skill-planner.js +72 -5
- package/template/wall-e/tools/command-registry.js +34 -0
- package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
- package/template/wall-e/training/mlx_lora_launch.py +196 -0
- package/template/wall-e/training/training-coexistence.sh +87 -0
- package/template/wall-e/training/training-mem-guard.sh +76 -0
|
@@ -25,14 +25,12 @@ const MODELS = [
|
|
|
25
25
|
name: 'GPT-5.4 Mini (via ChatGPT subscription)',
|
|
26
26
|
capabilities: { vision: false, tools: false, streaming: false, reasoning: true },
|
|
27
27
|
},
|
|
28
|
-
{
|
|
29
|
-
id: 'gpt-5.3-codex',
|
|
30
|
-
name: 'GPT-5.3 Codex (via ChatGPT subscription)',
|
|
31
|
-
capabilities: { vision: false, tools: false, streaming: false, reasoning: true },
|
|
32
|
-
},
|
|
33
28
|
];
|
|
34
29
|
|
|
35
|
-
const
|
|
30
|
+
const DEFAULT_CODEX_CLI_MODEL = 'gpt-5.5';
|
|
31
|
+
const CODEX_CLI_MODEL_RE = /^(gpt-5\.5|gpt-5\.4|gpt-5\.4-mini)$/i;
|
|
32
|
+
const DEFAULT_CODEX_CLI_REASONING_EFFORT = 'low';
|
|
33
|
+
const CODEX_CLI_REASONING_EFFORT_RE = /^(minimal|low|medium|high|xhigh)$/i;
|
|
36
34
|
|
|
37
35
|
const CODEX_APP_BINARY = '/Applications/Codex.app/Contents/Resources/codex';
|
|
38
36
|
|
|
@@ -76,7 +74,58 @@ function _serializeMessages(messages, system) {
|
|
|
76
74
|
return parts.join('\n\n');
|
|
77
75
|
}
|
|
78
76
|
|
|
79
|
-
|
|
77
|
+
function resolveCodexCliModel(model) {
|
|
78
|
+
const text = String(model || '').trim();
|
|
79
|
+
return text && CODEX_CLI_MODEL_RE.test(text) ? text : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveCodexCliReasoningEffort(value) {
|
|
83
|
+
if (value === false || value == null) return undefined;
|
|
84
|
+
const text = String(value || '').trim();
|
|
85
|
+
return text && CODEX_CLI_REASONING_EFFORT_RE.test(text) ? text : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildCodexExecArgs(prompt, {
|
|
89
|
+
model,
|
|
90
|
+
ignoreUserConfig = true,
|
|
91
|
+
reasoningEffort = DEFAULT_CODEX_CLI_REASONING_EFFORT,
|
|
92
|
+
outFile,
|
|
93
|
+
} = {}) {
|
|
94
|
+
const args = ['exec'];
|
|
95
|
+
// Wall-E uses Codex CLI as a backend LLM provider, not as an interactive
|
|
96
|
+
// coding session. Keep ChatGPT auth, but avoid inheriting user/project
|
|
97
|
+
// coding-agent config such as xhigh reasoning or large skill/MCP contexts.
|
|
98
|
+
if (ignoreUserConfig !== false) args.push('--ignore-user-config');
|
|
99
|
+
args.push(
|
|
100
|
+
'--skip-git-repo-check',
|
|
101
|
+
'--ephemeral',
|
|
102
|
+
'--output-last-message', outFile,
|
|
103
|
+
);
|
|
104
|
+
const resolvedReasoningEffort = resolveCodexCliReasoningEffort(reasoningEffort);
|
|
105
|
+
if (resolvedReasoningEffort) {
|
|
106
|
+
args.push('-c', `model_reasoning_effort="${resolvedReasoningEffort}"`);
|
|
107
|
+
}
|
|
108
|
+
// ChatGPT-authed codex restricts which models work. Only forward `-m` for
|
|
109
|
+
// known-subscription-compatible models — otherwise let codex pick its own
|
|
110
|
+
// default. The Wall-E caller's model preference is best-effort; the trade-off
|
|
111
|
+
// for subscription billing.
|
|
112
|
+
const forwardedModel = resolveCodexCliModel(model);
|
|
113
|
+
if (forwardedModel) {
|
|
114
|
+
args.push('-m', forwardedModel);
|
|
115
|
+
}
|
|
116
|
+
args.push(prompt);
|
|
117
|
+
return { args, forwardedModel, resolvedReasoningEffort };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function _spawnCodex(prompt, {
|
|
121
|
+
model,
|
|
122
|
+
signal,
|
|
123
|
+
timeoutMs = 300000,
|
|
124
|
+
cwd,
|
|
125
|
+
binaryPath,
|
|
126
|
+
ignoreUserConfig = true,
|
|
127
|
+
reasoningEffort = DEFAULT_CODEX_CLI_REASONING_EFFORT,
|
|
128
|
+
} = {}) {
|
|
80
129
|
// Codex writes its session journal to ~/.codex/. We pass --ephemeral to
|
|
81
130
|
// suppress that. We also need to capture the final assistant message
|
|
82
131
|
// somehow — `--output-last-message <FILE>` is the cleanest path. Use a
|
|
@@ -89,20 +138,7 @@ async function _spawnCodex(prompt, { model, signal, timeoutMs = 300000, cwd, bin
|
|
|
89
138
|
rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
90
139
|
return reject(new Error('Aborted'));
|
|
91
140
|
}
|
|
92
|
-
const args =
|
|
93
|
-
'exec',
|
|
94
|
-
'--skip-git-repo-check',
|
|
95
|
-
'--ephemeral',
|
|
96
|
-
'--output-last-message', outFile,
|
|
97
|
-
];
|
|
98
|
-
// ChatGPT-authed codex restricts which models work. Only forward `-m` for
|
|
99
|
-
// known-subscription-compatible
|
|
100
|
-
// models — otherwise let codex pick its own default. The wall-e caller's
|
|
101
|
-
// model preference is best-effort; the trade-off for subscription billing.
|
|
102
|
-
if (model && CODEX_CLI_MODEL_RE.test(model)) {
|
|
103
|
-
args.push('-m', model);
|
|
104
|
-
}
|
|
105
|
-
args.push(prompt);
|
|
141
|
+
const { args } = buildCodexExecArgs(prompt, { model, ignoreUserConfig, reasoningEffort, outFile });
|
|
106
142
|
|
|
107
143
|
// Force ChatGPT auth path — strip OPENAI_API_KEY so codex doesn't
|
|
108
144
|
// silently fall back to API-key billing when env exposes one.
|
|
@@ -239,13 +275,15 @@ function createCodexCliProvider(config = {}) {
|
|
|
239
275
|
};
|
|
240
276
|
}
|
|
241
277
|
const start = Date.now();
|
|
242
|
-
const forwardedModel =
|
|
278
|
+
const forwardedModel = resolveCodexCliModel(model);
|
|
243
279
|
const { reply, stdout } = await _spawnCodex(prompt, {
|
|
244
280
|
model: forwardedModel,
|
|
245
281
|
signal,
|
|
246
282
|
timeoutMs: config.timeoutMs,
|
|
247
283
|
cwd: cwd || config.cwd,
|
|
248
284
|
binaryPath: config.binaryPath,
|
|
285
|
+
ignoreUserConfig: config.ignoreUserConfig,
|
|
286
|
+
reasoningEffort: config.reasoningEffort,
|
|
249
287
|
});
|
|
250
288
|
const latencyMs = Date.now() - start;
|
|
251
289
|
|
|
@@ -263,7 +301,7 @@ function createCodexCliProvider(config = {}) {
|
|
|
263
301
|
stopReason: 'end_turn',
|
|
264
302
|
usage: totalTokens > 0 ? { input: 0, output: totalTokens } : undefined,
|
|
265
303
|
latencyMs,
|
|
266
|
-
model: forwardedModel ||
|
|
304
|
+
model: forwardedModel || DEFAULT_CODEX_CLI_MODEL,
|
|
267
305
|
provider: 'codex-cli',
|
|
268
306
|
raw: { reply, totalTokens },
|
|
269
307
|
};
|
|
@@ -283,4 +321,14 @@ module.exports = {
|
|
|
283
321
|
createCodexCliProvider,
|
|
284
322
|
createCodexCliFromEnv,
|
|
285
323
|
_spawnCodex,
|
|
324
|
+
_internals: {
|
|
325
|
+
CODEX_CLI_MODEL_RE,
|
|
326
|
+
CODEX_CLI_REASONING_EFFORT_RE,
|
|
327
|
+
DEFAULT_CODEX_CLI_MODEL,
|
|
328
|
+
DEFAULT_CODEX_CLI_REASONING_EFFORT,
|
|
329
|
+
MODELS,
|
|
330
|
+
buildCodexExecArgs,
|
|
331
|
+
resolveCodexCliModel,
|
|
332
|
+
resolveCodexCliReasoningEffort,
|
|
333
|
+
},
|
|
286
334
|
};
|
|
@@ -36,10 +36,11 @@ const TRANSIENT_CLASSES = new Set(['network', 'timeout', 'provider_unavailable',
|
|
|
36
36
|
// fall through to the next candidate. auth_error is included (misconfigured /
|
|
37
37
|
// expired credential) but is NOT retried on the same provider; likewise
|
|
38
38
|
// quota_exceeded (depleted account/credits, e.g. DeepSeek "402 Insufficient
|
|
39
|
-
// Balance")
|
|
40
|
-
//
|
|
41
|
-
// error we
|
|
42
|
-
|
|
39
|
+
// Balance") and model_unavailable (the selected provider/model cannot serve)
|
|
40
|
+
// can't be fixed by retrying the same provider, but another candidate can serve.
|
|
41
|
+
// Anything else (invalid_request, content filter, unknown) is a real error we
|
|
42
|
+
// must surface.
|
|
43
|
+
const FALLTHROUGH_CLASSES = new Set(['network', 'timeout', 'provider_unavailable', 'rate_limited', 'auth_error', 'quota_exceeded', 'model_unavailable']);
|
|
43
44
|
|
|
44
45
|
function classify(err) {
|
|
45
46
|
try {
|
|
@@ -53,6 +54,26 @@ function isTransientClass(cls) { return TRANSIENT_CLASSES.has(cls); }
|
|
|
53
54
|
function shouldFallThroughClass(cls) { return FALLTHROUGH_CLASSES.has(cls); }
|
|
54
55
|
function defaultDelay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
55
56
|
|
|
57
|
+
function noEligibleProvidersError(chain) {
|
|
58
|
+
const labels = (Array.isArray(chain) ? chain : [])
|
|
59
|
+
.map((candidate) => candidate?.label || candidate?.type || candidate?.id)
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
const detail = labels.length ? ` Skipped routes: ${labels.join(', ')}.` : '';
|
|
62
|
+
const err = new Error(`No configured AI provider/model route is currently eligible. Waiting for health recovery probes or Setup changes.${detail}`);
|
|
63
|
+
err.status = 503;
|
|
64
|
+
err.code = 'AI_PROVIDER_UNAVAILABLE_LOCAL';
|
|
65
|
+
err.suppressProviderHealthRecord = true;
|
|
66
|
+
try {
|
|
67
|
+
err.providerError = classifyProviderError(err, {
|
|
68
|
+
type: 'provider_unavailable_local',
|
|
69
|
+
provider: chain?.[0]?.type || chain?.[0]?.id || null,
|
|
70
|
+
model: chain?.[0]?.model || null,
|
|
71
|
+
providerId: chain?.[0]?.id || null,
|
|
72
|
+
});
|
|
73
|
+
} catch {}
|
|
74
|
+
return err;
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
// A request that was CANCELLED (caller aborted, or an AbortError) is not a provider-health
|
|
57
78
|
// signal — the caller no longer wants a result. classifyProviderError() lumps "aborted" into
|
|
58
79
|
// 'timeout' (transient), which would otherwise make us retry + fall through the whole chain
|
|
@@ -96,9 +117,13 @@ function isCliBinaryAvailable(bin, opts = {}) {
|
|
|
96
117
|
* normal provider client (chat / chatStream / capabilities / type).
|
|
97
118
|
* @param {object} deps
|
|
98
119
|
* @param {object} [deps.health] ProviderHealth instance (trackCall used).
|
|
99
|
-
* @param {Function} [deps.isDown] (id) => bool — skip providers already down.
|
|
120
|
+
* @param {Function} [deps.isDown] (id,candidate,opts) => bool — skip providers/models already down.
|
|
100
121
|
* @param {Function} [deps.onFallback] ({from,to,error}) => void — fired once when
|
|
101
122
|
* a non-primary candidate ends up serving the call.
|
|
123
|
+
* @param {Function} [deps.onCandidateOk] ({provider,model}) => void — fired when any
|
|
124
|
+
* candidate succeeds.
|
|
125
|
+
* @param {Function} [deps.onCandidateFailure] ({provider,model,error,type,attempt}) => void —
|
|
126
|
+
* fired for each real provider attempt that fails.
|
|
102
127
|
* @param {Function} [deps.onExhausted] ({chain,error}) => void — every candidate failed.
|
|
103
128
|
* @param {number} [deps.maxRetries=2] Per-candidate transient retries.
|
|
104
129
|
* @param {number} [deps.baseDelayMs=400] Backoff base (exponential).
|
|
@@ -114,6 +139,8 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
114
139
|
const baseDelayMs = Number.isFinite(deps.baseDelayMs) ? deps.baseDelayMs : 400;
|
|
115
140
|
const onFallback = typeof deps.onFallback === 'function' ? deps.onFallback : () => {};
|
|
116
141
|
const onPrimaryOk = typeof deps.onPrimaryOk === 'function' ? deps.onPrimaryOk : () => {};
|
|
142
|
+
const onCandidateOk = typeof deps.onCandidateOk === 'function' ? deps.onCandidateOk : () => {};
|
|
143
|
+
const onCandidateFailure = typeof deps.onCandidateFailure === 'function' ? deps.onCandidateFailure : () => {};
|
|
117
144
|
const onExhausted = typeof deps.onExhausted === 'function' ? deps.onExhausted : () => {};
|
|
118
145
|
const sleep = typeof deps.sleep === 'function' ? deps.sleep : defaultDelay;
|
|
119
146
|
const primary = chain[0];
|
|
@@ -137,16 +164,16 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
137
164
|
// Active candidates for this call: drop providers health marked 'down', and —
|
|
138
165
|
// when the call needs tool calls — drop providers that can't do tools (e.g. the
|
|
139
166
|
// claude-cli tail, capabilities.tools === false), leaving those to the coding
|
|
140
|
-
// orchestrator's own CLI fallback.
|
|
141
|
-
//
|
|
167
|
+
// orchestrator's own CLI fallback. Returning empty is intentional: a circuit-open
|
|
168
|
+
// model must only be retried through its explicit recovery-probe path.
|
|
142
169
|
function activeChain(opts = {}) {
|
|
143
170
|
const wantsTools = Array.isArray(opts.tools) && opts.tools.length > 0;
|
|
144
|
-
let cands = chain.filter((c) => !isDown(c.id));
|
|
171
|
+
let cands = chain.filter((c) => !isDown(c.id, c, optsForCandidate(c, opts)));
|
|
145
172
|
if (wantsTools) {
|
|
146
173
|
const toolCapable = cands.filter((c) => c.client?.capabilities?.tools !== false);
|
|
147
174
|
if (toolCapable.length) cands = toolCapable;
|
|
148
175
|
}
|
|
149
|
-
return cands
|
|
176
|
+
return cands;
|
|
150
177
|
}
|
|
151
178
|
|
|
152
179
|
// Run one candidate with transient retries. Returns {ok, res} or {ok:false, err, cls}.
|
|
@@ -157,6 +184,7 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
157
184
|
try {
|
|
158
185
|
const res = await invoke(cand.client, callOpts);
|
|
159
186
|
track(cand.id, true);
|
|
187
|
+
onCandidateOk({ provider: cand, model: callOpts?.model || cand.model || null });
|
|
160
188
|
return { ok: true, res };
|
|
161
189
|
} catch (err) {
|
|
162
190
|
// Caller cancelled — propagate at once (no retry/fallthrough/health-failure).
|
|
@@ -165,7 +193,17 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
165
193
|
lastErr = err;
|
|
166
194
|
lastCls = cls;
|
|
167
195
|
track(cand.id, false, err);
|
|
168
|
-
|
|
196
|
+
const willRetry = isTransientClass(cls) && attempt < maxRetries;
|
|
197
|
+
onCandidateFailure({
|
|
198
|
+
provider: cand,
|
|
199
|
+
model: callOpts?.model || cand.model || null,
|
|
200
|
+
error: err,
|
|
201
|
+
type: cls,
|
|
202
|
+
attempt,
|
|
203
|
+
willRetry,
|
|
204
|
+
willFallThrough: !willRetry && shouldFallThroughClass(cls),
|
|
205
|
+
});
|
|
206
|
+
if (willRetry) {
|
|
169
207
|
await sleep(baseDelayMs * (2 ** attempt));
|
|
170
208
|
continue;
|
|
171
209
|
}
|
|
@@ -177,6 +215,11 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
177
215
|
|
|
178
216
|
async function callWithFallback(opts, invoke) {
|
|
179
217
|
const cands = activeChain(opts);
|
|
218
|
+
if (!cands.length) {
|
|
219
|
+
const err = noEligibleProvidersError(chain);
|
|
220
|
+
onExhausted({ chain: cands, skippedChain: chain, error: err, noEligible: true });
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
180
223
|
let lastErr;
|
|
181
224
|
for (let i = 0; i < cands.length; i++) {
|
|
182
225
|
const cand = cands[i];
|
|
@@ -197,6 +240,11 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
197
240
|
|
|
198
241
|
async function* streamWithFallback(opts) {
|
|
199
242
|
const cands = activeChain(opts);
|
|
243
|
+
if (!cands.length) {
|
|
244
|
+
const err = noEligibleProvidersError(chain);
|
|
245
|
+
onExhausted({ chain: cands, skippedChain: chain, error: err, noEligible: true });
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
200
248
|
let lastErr;
|
|
201
249
|
for (let i = 0; i < cands.length; i++) {
|
|
202
250
|
const cand = cands[i];
|
|
@@ -208,6 +256,7 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
208
256
|
if (!started) {
|
|
209
257
|
started = true;
|
|
210
258
|
track(cand.id, true);
|
|
259
|
+
onCandidateOk({ provider: cand, model: callOpts?.model || cand.model || null });
|
|
211
260
|
if (cand.id !== primary.id) onFallback({ from: primary, to: cand, error: lastErr });
|
|
212
261
|
else onPrimaryOk({ provider: cand });
|
|
213
262
|
}
|
|
@@ -219,10 +268,20 @@ function makeResilientClient(chain, deps = {}) {
|
|
|
219
268
|
if (isAbortError(err, callOpts)) throw err;
|
|
220
269
|
const cls = classify(err);
|
|
221
270
|
track(cand.id, false, err);
|
|
271
|
+
const willRetry = isTransientClass(cls) && attempt < maxRetries;
|
|
272
|
+
onCandidateFailure({
|
|
273
|
+
provider: cand,
|
|
274
|
+
model: callOpts?.model || cand.model || null,
|
|
275
|
+
error: err,
|
|
276
|
+
type: cls,
|
|
277
|
+
attempt,
|
|
278
|
+
willRetry,
|
|
279
|
+
willFallThrough: !willRetry && shouldFallThroughClass(cls),
|
|
280
|
+
});
|
|
222
281
|
// Mid-stream we can't switch providers without re-emitting — propagate.
|
|
223
282
|
if (started) throw err;
|
|
224
283
|
lastErr = err;
|
|
225
|
-
if (
|
|
284
|
+
if (willRetry) {
|
|
226
285
|
await sleep(baseDelayMs * (2 ** attempt));
|
|
227
286
|
continue; // retry same candidate
|
|
228
287
|
}
|
|
@@ -425,6 +484,7 @@ module.exports = {
|
|
|
425
484
|
_internals: {
|
|
426
485
|
classify,
|
|
427
486
|
isTransientClass,
|
|
487
|
+
noEligibleProvidersError,
|
|
428
488
|
shouldFallThroughClass,
|
|
429
489
|
_configFromRow,
|
|
430
490
|
_hasEnvKey,
|
|
Binary file
|
|
@@ -4,6 +4,15 @@ const { execFile } = require('child_process');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const fs = require('fs');
|
|
7
|
+
const { workerKey, sharedPool } = require('./mlx-worker');
|
|
8
|
+
|
|
9
|
+
// Tear down all persistent MLX workers. Long-running hosts rely on the
|
|
10
|
+
// process-exit handler, but short-lived CLIs (the eval harness) must call this
|
|
11
|
+
// explicitly — idle worker stdio pipes keep node's event loop alive, so without
|
|
12
|
+
// it the process hangs after the run completes.
|
|
13
|
+
function shutdownMlxWorkers() {
|
|
14
|
+
try { sharedPool().shutdownAll(); } catch { /* nothing to tear down */ }
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
const MLX_VENV = path.join(os.homedir(), '.mlx-env');
|
|
9
18
|
const MLX_PYTHON = path.join(MLX_VENV, 'bin', 'python3');
|
|
@@ -139,6 +148,15 @@ function resolveInferenceTimeoutMs(config = {}) {
|
|
|
139
148
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
// Whether to route inference through the persistent warm-worker pool. Default
|
|
152
|
+
// OFF so the primary Wall-E runtime keeps the conservative one-shot subprocess
|
|
153
|
+
// behavior; opt in via config.persistentWorker or MLX_PERSISTENT_WORKER=1
|
|
154
|
+
// (used by the eval harness to avoid reloading the model every turn).
|
|
155
|
+
function resolveUsePersistentWorker(config = {}) {
|
|
156
|
+
if (config.persistentWorker !== undefined) return !!config.persistentWorker;
|
|
157
|
+
return process.env.MLX_PERSISTENT_WORKER === '1';
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
function buildInferenceExecOptions(config = {}, signal) {
|
|
143
161
|
return {
|
|
144
162
|
timeout: resolveInferenceTimeoutMs(config),
|
|
@@ -589,20 +607,34 @@ print(json.dumps({
|
|
|
589
607
|
|
|
590
608
|
const start = Date.now();
|
|
591
609
|
const maxTok = maxTokens || 1024;
|
|
610
|
+
const python = resolveMlxPython(config);
|
|
592
611
|
|
|
593
612
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
613
|
+
let data;
|
|
614
|
+
if (resolveUsePersistentWorker(config)) {
|
|
615
|
+
// Warm path: reuse a long-lived worker so the model loads once instead
|
|
616
|
+
// of once per turn. Falls back to the cold subprocess on worker failure.
|
|
617
|
+
const pool = config.mlxWorkerPool || sharedPool();
|
|
618
|
+
const key = workerKey(python, mlxModel, adapterPath);
|
|
619
|
+
data = await pool.request(
|
|
620
|
+
key,
|
|
621
|
+
{ python, model: mlxModel, adapterPath: adapterPath || '' },
|
|
622
|
+
{ messages: compactedChatMessages, max_tokens: maxTok, forced_generation_prefix: forcedGenerationPrefix },
|
|
623
|
+
{ timeoutMs: resolveInferenceTimeoutMs(config), signal }
|
|
624
|
+
);
|
|
625
|
+
} else {
|
|
626
|
+
const { stdout, stderr } = await execAsync(
|
|
627
|
+
python,
|
|
628
|
+
['-c', pyScript, JSON.stringify(compactedChatMessages), String(maxTok), mlxModel, adapterPath || '', forcedGenerationPrefix],
|
|
629
|
+
buildInferenceExecOptions(config, signal)
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// Find JSON line in output
|
|
633
|
+
const lines = stdout.trim().split('\n');
|
|
634
|
+
const jsonLine = lines.find(l => l.startsWith('{'));
|
|
635
|
+
if (!jsonLine) throw new Error(`MLX: no JSON in output. stderr: ${stderr?.slice(0, 200)}`);
|
|
636
|
+
data = JSON.parse(jsonLine);
|
|
637
|
+
}
|
|
606
638
|
const latencyMs = Date.now() - start;
|
|
607
639
|
|
|
608
640
|
// Parse tool calls from output if tools were provided
|
|
@@ -691,6 +723,8 @@ module.exports = {
|
|
|
691
723
|
resolveModelSpec,
|
|
692
724
|
resolveMlxPython,
|
|
693
725
|
resolveInferenceTimeoutMs,
|
|
726
|
+
resolveUsePersistentWorker,
|
|
727
|
+
shutdownMlxWorkers,
|
|
694
728
|
buildInferenceExecOptions,
|
|
695
729
|
normalizeToolCallFormat,
|
|
696
730
|
normalizeToolSpec,
|
|
@@ -112,7 +112,12 @@ function classifyType(status, lower) {
|
|
|
112
112
|
|| status === 403
|
|
113
113
|
|| /invalid[_ -]?(api[_ -]?)?key|unauthori[sz]ed|forbidden|authentication|auth token|authtoken|api[_ -]?key|could not resolve authentication method|expected either apikey or authtoken|oauth token not found|claude auth login|anthropic_api_key|token refresh failed|permission denied/i.test(lower)
|
|
114
114
|
) return 'auth_error';
|
|
115
|
-
if (
|
|
115
|
+
if (
|
|
116
|
+
status === 404
|
|
117
|
+
|| /model .*not found|not_found_error|model_not_found|does not exist|unknown model/i.test(lower)
|
|
118
|
+
|| /(?:model|mode)[^.\n]*(?:not available|not supported|unavailable|unsupported|invalid|unknown|does not exist)/i.test(lower)
|
|
119
|
+
|| /invalid_request_error[^.\n]*(?:model|mode)|(?:model|mode)[^.\n]*invalid_request_error/i.test(lower)
|
|
120
|
+
) return 'model_unavailable';
|
|
116
121
|
if (/context length exceeded|context_length_exceeded|maximum context|token limit/i.test(lower)) return 'context_window';
|
|
117
122
|
// Connect-phase failures mean the endpoint was never reached (DNS, routing,
|
|
118
123
|
// VPN-only gateways). They must classify as 'network' even though undici
|
|
@@ -260,13 +265,14 @@ function toApiPayload(err, context = {}) {
|
|
|
260
265
|
};
|
|
261
266
|
}
|
|
262
267
|
|
|
263
|
-
function recordProviderFailureAlert(providerError, brain) {
|
|
268
|
+
function recordProviderFailureAlert(providerError, brain, opts = {}) {
|
|
264
269
|
if (!providerError) return { alerted: false };
|
|
265
270
|
try {
|
|
266
|
-
try {
|
|
271
|
+
if (opts.recordHealth !== false) try {
|
|
267
272
|
const { recordProviderHealthStatus } = require('./provider-health-state');
|
|
268
273
|
recordProviderHealthStatus({
|
|
269
274
|
provider: providerError.provider || 'default',
|
|
275
|
+
provider_id: providerError.providerId || null,
|
|
270
276
|
model: providerError.model || '',
|
|
271
277
|
ok: false,
|
|
272
278
|
source: 'provider-error',
|