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.
Files changed (112) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +410 -5
  3. package/template/claude-task-manager/api-reviews.js +251 -3
  4. package/template/claude-task-manager/approval-agent.js +322 -24
  5. package/template/claude-task-manager/db.js +77 -13
  6. package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
  7. package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
  8. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
  9. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  10. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  11. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  12. package/template/claude-task-manager/git-utils.js +164 -26
  13. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  14. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  15. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  16. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  17. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  18. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  19. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  20. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  21. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  22. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  23. package/template/claude-task-manager/lib/document-review.js +75 -1
  24. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  25. package/template/claude-task-manager/lib/headless-term-service.js +54 -9
  26. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  27. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  28. package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
  29. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  30. package/template/claude-task-manager/lib/permission-match.js +77 -5
  31. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  32. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  33. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  34. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  35. package/template/claude-task-manager/lib/session-capture.js +33 -6
  36. package/template/claude-task-manager/lib/session-history.js +60 -3
  37. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  38. package/template/claude-task-manager/lib/session-restore.js +5 -4
  39. package/template/claude-task-manager/lib/session-standup.js +2 -0
  40. package/template/claude-task-manager/lib/session-stream.js +9 -0
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  42. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  43. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  44. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  45. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  46. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  47. package/template/claude-task-manager/package.json +1 -1
  48. package/template/claude-task-manager/providers/claude-code.js +20 -1
  49. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  50. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  51. package/template/claude-task-manager/providers/codex.js +65 -1
  52. package/template/claude-task-manager/public/css/prompts.css +345 -0
  53. package/template/claude-task-manager/public/css/reviews.css +248 -40
  54. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  55. package/template/claude-task-manager/public/index.html +2031 -428
  56. package/template/claude-task-manager/public/js/document-review-links.js +255 -13
  57. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  58. package/template/claude-task-manager/public/js/message-renderer.js +46 -1
  59. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  60. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  61. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  62. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  63. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  64. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  65. package/template/claude-task-manager/public/js/prompts.js +683 -215
  66. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  67. package/template/claude-task-manager/public/js/reviews.js +402 -121
  68. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  69. package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
  70. package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
  71. package/template/claude-task-manager/public/js/stream-view.js +93 -20
  72. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  73. package/template/claude-task-manager/public/js/walle.js +21 -9
  74. package/template/claude-task-manager/public/m/app.css +117 -0
  75. package/template/claude-task-manager/public/m/app.js +309 -3
  76. package/template/claude-task-manager/public/m/index.html +56 -1
  77. package/template/claude-task-manager/server.js +724 -305
  78. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  79. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  80. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  81. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  82. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  83. package/template/package.json +1 -1
  84. package/template/wall-e/api-walle.js +21 -3
  85. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  86. package/template/wall-e/brain.js +50 -5
  87. package/template/wall-e/chat/force-compact.js +73 -0
  88. package/template/wall-e/chat.js +137 -9
  89. package/template/wall-e/coding/action-memory-policy.js +120 -1
  90. package/template/wall-e/coding/compaction-service.js +10 -3
  91. package/template/wall-e/coding/model-router.js +116 -0
  92. package/template/wall-e/coding-orchestrator.js +303 -11
  93. package/template/wall-e/coding-prompts.js +1 -1
  94. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  95. package/template/wall-e/llm/client.js +115 -6
  96. package/template/wall-e/llm/codex-cli.js +71 -23
  97. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  98. package/template/wall-e/llm/default-fallback.js +71 -11
  99. package/template/wall-e/llm/mlx-worker.js +0 -0
  100. package/template/wall-e/llm/mlx.js +46 -12
  101. package/template/wall-e/llm/provider-error.js +9 -3
  102. package/template/wall-e/llm/provider-health-state.js +561 -11
  103. package/template/wall-e/llm/registry.js +6 -0
  104. package/template/wall-e/llm/routing-policy.js +255 -25
  105. package/template/wall-e/llm/tool-call-validator.js +156 -0
  106. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  107. package/template/wall-e/skills/skill-planner.js +72 -5
  108. package/template/wall-e/tools/command-registry.js +34 -0
  109. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  110. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  111. package/template/wall-e/training/training-coexistence.sh +87 -0
  112. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -2,7 +2,13 @@
2
2
 
3
3
  const providerRegistry = require('./registry');
4
4
  const { isCliBinaryAvailable, _internals: fallbackInternals } = require('./default-fallback');
5
- const { getProviderHealthStatus } = require('./provider-health-state');
5
+ const {
6
+ DEFAULT_PROVIDER_STATUS_MAX_AGE_MS,
7
+ getFirstProviderModelCircuit,
8
+ getFirstProviderModelCircuitStats,
9
+ getProviderHealthStatus,
10
+ listProviderModelCircuits,
11
+ } = require('./provider-health-state');
6
12
 
7
13
  const POLICY_KEYS = {
8
14
  mode: 'model_routing_policy_mode',
@@ -11,6 +17,7 @@ const POLICY_KEYS = {
11
17
  lastRefreshAt: 'model_routing_policy_last_refresh_at',
12
18
  };
13
19
 
20
+ const ROUTING_POLICY_DISPLAY_HEALTH_MAX_AGE_MS = DEFAULT_PROVIDER_STATUS_MAX_AGE_MS;
14
21
  const VALID_MODES = new Set(['smart', 'strict']);
15
22
  const LOCAL_PROVIDER_TYPES = new Set(['ollama', 'mlx', 'lmstudio']);
16
23
  const GATEWAY_ONLY_TYPES = new Set(['portkey_gateway']);
@@ -164,17 +171,126 @@ function providerRuntimeAccess(row = {}, env = process.env) {
164
171
  return { ok: false, kind: 'missing_credential', reason: 'No stored credential or environment key.' };
165
172
  }
166
173
 
167
- function publicRuntimeHealth(id, type, model, health, brain) {
168
- const runtime = id && health?.getProviderHealth ? health.getProviderHealth(id) : null;
169
- const persisted = getProviderHealthStatus(type, model, { brain });
174
+ function uniqueTruthy(values = []) {
175
+ const out = [];
176
+ const seen = new Set();
177
+ for (const value of values) {
178
+ const text = String(value || '').trim();
179
+ if (!text) continue;
180
+ const key = text.toLowerCase();
181
+ if (seen.has(key)) continue;
182
+ seen.add(key);
183
+ out.push(text);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function firstRuntimeHealthWithEvidence(ids = [], health) {
189
+ if (!health || typeof health.getProviderHealth !== 'function') return null;
190
+ for (const id of uniqueTruthy(ids)) {
191
+ let runtime = null;
192
+ try { runtime = health.getProviderHealth(id); } catch {}
193
+ if (runtime && Number(runtime.totalCalls || 0) > 0) return runtime;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function firstPersistedHealthStatus(providers = [], model, brain, options = {}) {
199
+ for (const provider of uniqueTruthy(providers)) {
200
+ const persisted = getProviderHealthStatus(provider, model, {
201
+ brain,
202
+ maxAgeMs: options.persistedMaxAgeMs,
203
+ });
204
+ if (persisted) return persisted;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ function failureRateSummary({ circuit, circuitStats, runtime, persisted, runtimeHasEvidence } = {}) {
210
+ const fromCircuit = circuit || circuitStats;
211
+ if (fromCircuit && Number(fromCircuit.total || 0) > 0) {
212
+ return {
213
+ failures: Number(fromCircuit.failures || 0),
214
+ total: Number(fromCircuit.total || 0),
215
+ failure_rate: Number(fromCircuit.failure_rate || 0),
216
+ failure_rate_pct: Number(fromCircuit.failure_rate_pct || Math.round(Number(fromCircuit.failure_rate || 0) * 100)),
217
+ source: fromCircuit.state === 'open' ? 'circuit_open' : 'recent_window',
218
+ window_ms: Number(fromCircuit.window_ms || 0) || null,
219
+ threshold: Number(fromCircuit.threshold || 0),
220
+ min_calls: Number(fromCircuit.min_calls || 0),
221
+ min_failures: Number(fromCircuit.min_failures || 0),
222
+ low_sample: fromCircuit.low_sample === true,
223
+ };
224
+ }
225
+ if (runtimeHasEvidence) {
226
+ const failureRate = Number(runtime?.failureRate || 0);
227
+ const total = Number(runtime?.totalCalls || 0);
228
+ return {
229
+ failures: Number.isFinite(failureRate) ? Math.round(failureRate * total) : null,
230
+ total,
231
+ failure_rate: Number.isFinite(failureRate) ? failureRate : null,
232
+ failure_rate_pct: Number.isFinite(failureRate) ? Math.round(failureRate * 100) : null,
233
+ source: 'runtime',
234
+ window_ms: null,
235
+ threshold: null,
236
+ min_calls: null,
237
+ min_failures: null,
238
+ low_sample: false,
239
+ };
240
+ }
241
+ if (persisted) {
242
+ const ok = persisted.ok === true;
243
+ return {
244
+ failures: ok ? 0 : 1,
245
+ total: 1,
246
+ failure_rate: ok ? 0 : 1,
247
+ failure_rate_pct: ok ? 0 : 100,
248
+ source: 'last_check',
249
+ window_ms: null,
250
+ threshold: null,
251
+ min_calls: null,
252
+ min_failures: null,
253
+ low_sample: true,
254
+ };
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function publicRuntimeHealth(id, type, model, health, brain, options = {}) {
260
+ const runtimeType = options.runtimeType || type;
261
+ const providerType = options.providerType || type;
262
+ const nowMs = options.nowMs || Date.now();
263
+ const runtime = firstRuntimeHealthWithEvidence([id, runtimeType, providerType], health);
264
+ const persisted = firstPersistedHealthStatus([runtimeType, providerType, id], model, brain, options);
265
+ const circuit = getFirstProviderModelCircuit([runtimeType, providerType, id], model, {
266
+ brain,
267
+ nowMs,
268
+ });
269
+ const circuitStats = circuit || getFirstProviderModelCircuitStats([runtimeType, providerType, id], model, {
270
+ brain,
271
+ nowMs,
272
+ });
170
273
  const runtimeHasEvidence = runtime && Number(runtime.totalCalls || 0) > 0;
171
- const status = runtimeHasEvidence ? runtime.status : (persisted ? persisted.status : 'unknown');
274
+ const rate = failureRateSummary({ circuit, circuitStats, runtime, persisted, runtimeHasEvidence });
275
+ const status = circuit ? 'error' : (runtimeHasEvidence ? runtime.status : (persisted ? persisted.status : 'unknown'));
172
276
  return {
173
277
  status,
174
278
  consecutive_failures: runtimeHasEvidence ? (runtime?.consecutiveFailures || 0) : 0,
175
- last_error: runtime?.lastError?.message || persisted?.error || '',
279
+ failures: rate?.failures ?? null,
280
+ total: rate?.total ?? 0,
281
+ failure_rate: rate?.failure_rate ?? null,
282
+ failure_rate_pct: rate?.failure_rate_pct ?? null,
283
+ failure_rate_source: rate?.source || '',
284
+ failure_rate_window_ms: rate?.window_ms ?? null,
285
+ failure_rate_threshold: rate?.threshold ?? null,
286
+ min_calls: rate?.min_calls ?? null,
287
+ min_failures: rate?.min_failures ?? null,
288
+ low_sample: rate?.low_sample === true,
289
+ last_error: circuit?.error || runtime?.lastError?.message || persisted?.error || '',
176
290
  last_success_at: runtimeHasEvidence && runtime?.lastSuccess ? runtime.lastSuccess.toISOString?.() || String(runtime.lastSuccess) : null,
177
291
  persisted_status: persisted || null,
292
+ circuit_breaker: circuit || null,
293
+ circuit_stats: circuitStats || null,
178
294
  };
179
295
  }
180
296
 
@@ -207,11 +323,16 @@ function rowCandidate(row, context) {
207
323
  const runtimeType = providerRuntimeType(row);
208
324
  const access = providerRuntimeAccess(row, context.env);
209
325
  const firstModel = firstEnabledModelForProvider(context.brain, row.id);
210
- const model = runtimeType.endsWith('-cli')
211
- ? defaultModelForProvider(runtimeType)
212
- : modelForProvider(context.brain, type, firstModel);
213
- const health = publicRuntimeHealth(row.id, runtimeType, model, context.health, context.brain);
214
- const isDown = health.status === 'down' || health.status === 'error';
326
+ const providerModel = modelForProvider(context.brain, type, firstModel);
327
+ const model = runtimeType !== type && runtimeType.endsWith('-cli')
328
+ ? (modelMatchesProvider(providerModel, runtimeType) ? providerModel : defaultModelForProvider(runtimeType))
329
+ : providerModel;
330
+ const health = publicRuntimeHealth(row.id, runtimeType, model, context.health, context.brain, {
331
+ providerType: type,
332
+ runtimeType,
333
+ });
334
+ const circuitOpen = !!health.circuit_breaker;
335
+ const isDown = circuitOpen || health.status === 'down';
215
336
  const routePolicy = typeof context.brain?.getProviderRoutePolicy === 'function'
216
337
  ? context.brain.getProviderRoutePolicy(type)
217
338
  : 'auto';
@@ -229,7 +350,7 @@ function rowCandidate(row, context) {
229
350
  route_policy: routePolicy,
230
351
  runtime_access: access.kind,
231
352
  included,
232
- excluded_reason: included ? '' : (isDown ? 'Provider is marked down by runtime health.' : access.reason),
353
+ excluded_reason: included ? '' : (circuitOpen ? 'Rolling error rate crossed the fallback threshold; auto-fallback is active until recovery probes succeed.' : (isDown ? 'Provider is marked down by runtime health.' : access.reason)),
233
354
  health,
234
355
  updated_at: row.updated_at || '',
235
356
  };
@@ -240,6 +361,9 @@ function envCandidate(type, context) {
240
361
  if (!fallbackInternals._hasEnvKey(providerType, context.env)) return null;
241
362
  const model = modelForProvider(context.brain, providerType, '');
242
363
  const id = `${providerType}-env`;
364
+ const health = publicRuntimeHealth(id, providerType, model, context.health, context.brain);
365
+ const circuitOpen = !!health.circuit_breaker;
366
+ const isDown = circuitOpen || health.status === 'down';
243
367
  return {
244
368
  id,
245
369
  provider_id: null,
@@ -252,9 +376,9 @@ function envCandidate(type, context) {
252
376
  connection_kind: 'environment',
253
377
  route_policy: 'auto',
254
378
  runtime_access: 'environment',
255
- included: true,
256
- excluded_reason: '',
257
- health: publicRuntimeHealth(id, providerType, model, context.health, context.brain),
379
+ included: !isDown,
380
+ excluded_reason: isDown ? (circuitOpen ? 'Rolling error rate crossed the fallback threshold; auto-fallback is active until recovery probes succeed.' : 'Provider is marked down by runtime health.') : '',
381
+ health,
258
382
  updated_at: '',
259
383
  };
260
384
  }
@@ -262,6 +386,9 @@ function envCandidate(type, context) {
262
386
  function cliCandidate(id, bin, context) {
263
387
  const available = context.settings.cli_tail && context.isCliAvailable(bin);
264
388
  const model = defaultModelForProvider(id);
389
+ const health = publicRuntimeHealth(id, id, model, context.health, context.brain);
390
+ const circuitOpen = !!health.circuit_breaker;
391
+ const isDown = circuitOpen || health.status === 'down';
265
392
  return {
266
393
  id,
267
394
  provider_id: null,
@@ -274,9 +401,11 @@ function cliCandidate(id, bin, context) {
274
401
  connection_kind: 'cli',
275
402
  route_policy: 'auto',
276
403
  runtime_access: available ? 'cli' : 'missing_cli',
277
- included: available,
278
- excluded_reason: available ? '' : `${bin} CLI not found or CLI fallback is disabled.`,
279
- health: publicRuntimeHealth(id, id, model, context.health, context.brain),
404
+ included: available && !isDown,
405
+ excluded_reason: available
406
+ ? (isDown ? (circuitOpen ? 'Rolling error rate crossed the fallback threshold; auto-fallback is active until recovery probes succeed.' : 'Provider is marked down by runtime health.') : '')
407
+ : `${bin} CLI not found or CLI fallback is disabled.`,
408
+ health,
280
409
  updated_at: '',
281
410
  };
282
411
  }
@@ -455,6 +584,7 @@ function buildRoutingPolicy(options = {}) {
455
584
  chain,
456
585
  candidates: ordered,
457
586
  excluded: ordered.filter((candidate) => !candidate.included),
587
+ circuit_breakers: listProviderModelCircuits({ brain }),
458
588
  recommendations: [],
459
589
  actions: [],
460
590
  };
@@ -585,6 +715,49 @@ function _defaultFromPersisted(brain, chain = [], settings = {}) {
585
715
  };
586
716
  }
587
717
 
718
+ function _hydratePersistedRouteHealth(brain, route = {}, options = {}) {
719
+ const runtimeType = route.runtime_type || route.runtimeType || route.provider_type || route.providerType;
720
+ const providerType = route.provider_type || route.providerType || route.type;
721
+ const model = route.model || route.model_id || route.modelId || '';
722
+ const health = publicRuntimeHealth(
723
+ route.provider_id || route.providerId || route.id,
724
+ runtimeType || providerType,
725
+ model,
726
+ options.health,
727
+ brain,
728
+ {
729
+ providerType,
730
+ runtimeType,
731
+ persistedMaxAgeMs: options.persistedMaxAgeMs ?? ROUTING_POLICY_DISPLAY_HEALTH_MAX_AGE_MS,
732
+ }
733
+ );
734
+ return {
735
+ ...route,
736
+ health_status: health.status,
737
+ health,
738
+ };
739
+ }
740
+
741
+ function _routeCircuitOpen(route = {}) {
742
+ return route?.health?.circuit_breaker?.state === 'open';
743
+ }
744
+
745
+ function _circuitExcludedRoute(route = {}) {
746
+ const circuit = route?.health?.circuit_breaker || {};
747
+ const failures = Number(circuit.failures || 0);
748
+ const total = Number(circuit.total || 0);
749
+ const rate = circuit.failure_rate_pct != null
750
+ ? Number(circuit.failure_rate_pct)
751
+ : Math.round(Number(circuit.failure_rate || 0) * 100);
752
+ const hours = Number(circuit.window_ms || 0) / (60 * 60 * 1000);
753
+ const windowLabel = hours >= 1 ? `${Math.round(hours)}h` : 'rolling window';
754
+ return {
755
+ ...route,
756
+ included: false,
757
+ excluded_reason: `Auto-fallback active: ${rate}% errors over ${windowLabel} (${failures}/${total}); route stays skipped until recovery probes succeed.`,
758
+ };
759
+ }
760
+
588
761
  function _publicPolicyFromPersisted(brain, snapshot = {}, extras = {}) {
589
762
  const policyRow = snapshot.policy || {};
590
763
  const routes = snapshot.routes || {};
@@ -593,20 +766,28 @@ function _publicPolicyFromPersisted(brain, snapshot = {}, extras = {}) {
593
766
  const generated = Array.isArray(routes.generated) ? routes.generated : [];
594
767
  const user = Array.isArray(routes.user) ? routes.user : [];
595
768
  const chainSource = effective.length ? 'effective' : (generated.length ? 'generated' : 'empty');
596
- const chain = (effective.length ? effective : generated).map((route, index) => ({
597
- ...route,
769
+ const hydratedEffective = (effective.length ? effective : generated).map((route, index) => ({
770
+ ..._hydratePersistedRouteHealth(brain, route, extras),
598
771
  route_key: _routeKey(route),
599
772
  order: index + 1,
600
773
  role: index === 0 ? 'primary' : 'fallback',
601
774
  }));
775
+ const circuitExcluded = hydratedEffective.filter(_routeCircuitOpen).map(_circuitExcludedRoute);
776
+ const chain = hydratedEffective
777
+ .filter((route) => !_routeCircuitOpen(route))
778
+ .map((route, index) => ({
779
+ ...route,
780
+ order: index + 1,
781
+ role: index === 0 ? 'primary' : 'fallback',
782
+ }));
602
783
  const publicUserRoutes = user.map((route, index) => ({
603
- ...route,
784
+ ..._hydratePersistedRouteHealth(brain, route, extras),
604
785
  route_key: _routeKey(route),
605
786
  order: index + 1,
606
787
  role: route.role || 'fallback',
607
788
  }));
608
789
  const publicGeneratedRoutes = generated.map((route, index) => ({
609
- ...route,
790
+ ..._hydratePersistedRouteHealth(brain, route, extras),
610
791
  route_key: _routeKey(route),
611
792
  order: index + 1,
612
793
  role: index === 0 ? 'primary' : 'fallback',
@@ -620,8 +801,9 @@ function _publicPolicyFromPersisted(brain, snapshot = {}, extras = {}) {
620
801
  trigger_classes: Array.from(fallbackInternals.FALLTHROUGH_CLASSES || []),
621
802
  default: _defaultFromPersisted(brain, chain, settings),
622
803
  chain,
623
- candidates: chain,
624
- excluded: [],
804
+ candidates: chain.concat(circuitExcluded),
805
+ excluded: circuitExcluded,
806
+ circuit_breakers: listProviderModelCircuits({ brain }),
625
807
  recommendations: extras.recommendations || [],
626
808
  actions: extras.actions || [],
627
809
  sources: {
@@ -634,6 +816,44 @@ function _publicPolicyFromPersisted(brain, snapshot = {}, extras = {}) {
634
816
  };
635
817
  }
636
818
 
819
+ function _configuredDefaultCircuit(brain) {
820
+ const configuredType = normalizeProviderType(getKv(brain, 'walle_provider') || process.env.WALLE_PROVIDER || '');
821
+ if (!configuredType) return null;
822
+ const rows = loadProviderRows(brain);
823
+ const row = rows.find((candidate) => normalizeProviderType(candidate.type || candidate.provider_type) === configuredType) || null;
824
+ const runtimeType = row ? providerRuntimeType(row) : configuredType;
825
+ const configuredModel = getKv(brain, 'walle_model') || process.env.WALLE_MODEL || '';
826
+ const firstModel = row?.id ? firstEnabledModelForProvider(brain, row.id) : '';
827
+ let model = configuredModel || modelForProvider(brain, configuredType, firstModel);
828
+ if (runtimeType !== configuredType && runtimeType.endsWith('-cli')) {
829
+ model = modelMatchesProvider(model, runtimeType) ? model : defaultModelForProvider(runtimeType);
830
+ }
831
+ return getFirstProviderModelCircuit([runtimeType, configuredType, row?.id], model, { brain });
832
+ }
833
+
834
+ function _persistedPolicyIsStale(brain, snapshot = {}) {
835
+ const effective = Array.isArray(snapshot.routes?.effective) ? snapshot.routes.effective : [];
836
+ const generated = Array.isArray(snapshot.routes?.generated) ? snapshot.routes.generated : [];
837
+ const primary = effective[0] || generated[0] || null;
838
+ const configuredType = normalizeProviderType(getKv(brain, 'walle_provider') || process.env.WALLE_PROVIDER || '');
839
+ if (!primary || !configuredType) return false;
840
+ const primaryProvider = normalizeProviderType(primary.provider_type || primary.providerType || primary.type || '');
841
+ const primaryRuntime = normalizeProviderType(primary.runtime_type || primary.runtimeType || primaryProvider || '');
842
+ const configuredCircuit = _configuredDefaultCircuit(brain);
843
+ if (primaryProvider !== configuredType && primaryRuntime !== configuredType) return !configuredCircuit;
844
+ const configuredModel = getKv(brain, 'walle_model') || process.env.WALLE_MODEL || '';
845
+ if (!configuredModel) return false;
846
+ const primaryModel = primary.model || primary.model_id || primary.modelId || '';
847
+ let effectiveConfiguredModel = configuredModel;
848
+ if (primaryRuntime !== configuredType && primaryRuntime.endsWith('-cli')) {
849
+ effectiveConfiguredModel = modelMatchesProvider(configuredModel, primaryRuntime)
850
+ ? configuredModel
851
+ : defaultModelForProvider(primaryRuntime);
852
+ }
853
+ if (!modelMatchesProvider(effectiveConfiguredModel, primaryRuntime || configuredType)) return false;
854
+ return !configuredCircuit && !!primaryModel && primaryModel !== effectiveConfiguredModel;
855
+ }
856
+
637
857
  function getPersistedRoutingPolicy(options = {}) {
638
858
  const brain = options.brain;
639
859
  if (!brain || typeof brain.getModelRoutingPolicy !== 'function') {
@@ -645,7 +865,16 @@ function getPersistedRoutingPolicy(options = {}) {
645
865
  if (!hasEffective && !hasGenerated && options.generateIfMissing !== false) {
646
866
  return materializeRoutingPolicy(options);
647
867
  }
648
- return _publicPolicyFromPersisted(brain, snapshot);
868
+ if (
869
+ options.generateIfMissing !== false
870
+ && options.repairStale !== false
871
+ && _persistedPolicyIsStale(brain, snapshot)
872
+ ) {
873
+ return materializeRoutingPolicy({ ...options, repairDefault: false });
874
+ }
875
+ return _publicPolicyFromPersisted(brain, snapshot, {
876
+ health: options.health || safeDefaultHealth(),
877
+ });
649
878
  }
650
879
 
651
880
  function materializeRoutingPolicy(options = {}) {
@@ -790,6 +1019,7 @@ module.exports = {
790
1019
  providerRuntimeAccess,
791
1020
  providerRuntimeType,
792
1021
  defaultModelForProvider,
1022
+ _persistedPolicyIsStale,
793
1023
  _mergeEffectiveRoutes,
794
1024
  _routeKey,
795
1025
  },
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Schema-driven tool-call validation + repair prompts.
5
+ *
6
+ * Small local models (e.g. Gemma E4B) fail tool-use in two avoidable ways the
7
+ * training log documented repeatedly:
8
+ * - they emit a tool call with empty `{}` args (v1: "six run_shell with empty
9
+ * arguments"), or missing required fields;
10
+ * - they emit a no-op edit where old_string === new_string (v6).
11
+ *
12
+ * Both are fixable at the harness level — no fine-tuning — by validating the
13
+ * call against the tool's own JSON schema (`input_schema.required`) and, when a
14
+ * turn produced only invalid calls, feeding back a corrective prompt that names
15
+ * the tool and the exact fields it must supply. This is provider/tool-agnostic:
16
+ * it reads whatever tool catalog the run was given.
17
+ */
18
+
19
+ const EDIT_TOOLS = new Set(['edit_file', 'multi_edit', 'apply_patch']);
20
+
21
+ function toolByName(tools = [], name = '') {
22
+ return (Array.isArray(tools) ? tools : []).find((tool) => (tool?.name || tool?.tool) === name) || null;
23
+ }
24
+
25
+ /** Required field names for a tool, or null if the tool is unknown. */
26
+ function toolRequiredFields(tools = [], name = '') {
27
+ const tool = toolByName(tools, name);
28
+ if (!tool) return null;
29
+ const schema = tool.input_schema || tool.inputSchema || tool.parameters || {};
30
+ const required = Array.isArray(schema.required) ? schema.required : [];
31
+ return required.map(String);
32
+ }
33
+
34
+ function isBlank(value) {
35
+ if (value === undefined || value === null) return true;
36
+ if (typeof value === 'string') return value.trim() === '';
37
+ if (Array.isArray(value)) return value.length === 0;
38
+ if (typeof value === 'object') return Object.keys(value).length === 0;
39
+ return false;
40
+ }
41
+
42
+ function callInput(call = {}) {
43
+ const input = call.input || call.args || call.arguments || {};
44
+ return input && typeof input === 'object' && !Array.isArray(input) ? input : {};
45
+ }
46
+
47
+ /**
48
+ * Validate one tool call against the catalog.
49
+ * @returns {{ ok: boolean, problem: string|null, missing: string[], name: string }}
50
+ */
51
+ function validateToolCall(call = {}, tools = []) {
52
+ const name = String(call.name || call.tool || '').trim();
53
+ const input = callInput(call);
54
+
55
+ if (!name) return { ok: false, problem: 'unknown_tool', missing: [], name };
56
+ const required = toolRequiredFields(tools, name);
57
+ if (required === null) return { ok: false, problem: 'unknown_tool', missing: [], name };
58
+
59
+ const missing = required.filter((field) => isBlank(input[field]));
60
+ if (missing.length) {
61
+ const allMissing = missing.length === required.length;
62
+ return { ok: false, problem: allMissing ? 'empty_args' : 'missing_required', missing, name };
63
+ }
64
+
65
+ if (EDIT_TOOLS.has(name) && typeof input.old_string === 'string' && input.old_string === input.new_string) {
66
+ return { ok: false, problem: 'noop_edit', missing: [], name };
67
+ }
68
+
69
+ return { ok: true, problem: null, missing: [], name };
70
+ }
71
+
72
+ /**
73
+ * Validate a batch of calls from one turn.
74
+ * @returns {{ valid, invalid, proseOnly, allInvalid, malformedCount, problems }}
75
+ */
76
+ function validateToolCalls(calls = [], tools = []) {
77
+ const list = Array.isArray(calls) ? calls : [];
78
+ const valid = [];
79
+ const invalid = [];
80
+ for (const call of list) {
81
+ const verdict = validateToolCall(call, tools);
82
+ if (verdict.ok) valid.push({ call, verdict });
83
+ else invalid.push({ call, verdict });
84
+ }
85
+ return {
86
+ valid,
87
+ invalid,
88
+ proseOnly: list.length === 0,
89
+ allInvalid: list.length > 0 && valid.length === 0,
90
+ malformedCount: invalid.length,
91
+ problems: invalid.map((entry) => entry.verdict),
92
+ };
93
+ }
94
+
95
+ function describeRequired(tools, name) {
96
+ const required = toolRequiredFields(tools, name);
97
+ if (!required || !required.length) return '';
98
+ return ` Required fields for ${name}: ${required.join(', ')}.`;
99
+ }
100
+
101
+ /** A corrective message to feed back to the model after a bad turn. */
102
+ function buildToolRepairMessage(validation = {}, tools = []) {
103
+ if (validation.proseOnly) {
104
+ return '[SYSTEM] You did not call any tool. Respond with exactly one tool call (not prose) '
105
+ + 'to make progress, using a tool from the provided catalog with all required fields filled in.';
106
+ }
107
+ const lines = ['[SYSTEM] Your previous tool call(s) were invalid and were not executed. Fix and retry:'];
108
+ for (const problem of validation.problems || []) {
109
+ if (problem.problem === 'unknown_tool') {
110
+ lines.push(`- "${problem.name || 'unknown'}" is not a known tool. Use a tool from the provided catalog.`);
111
+ } else if (problem.problem === 'empty_args') {
112
+ lines.push(`- ${problem.name} was called with empty arguments.${describeRequired(tools, problem.name)}`);
113
+ } else if (problem.problem === 'missing_required') {
114
+ lines.push(`- ${problem.name} is missing required field(s): ${problem.missing.join(', ')}.${describeRequired(tools, problem.name)}`);
115
+ } else if (problem.problem === 'noop_edit') {
116
+ lines.push(`- ${problem.name} had old_string identical to new_string (no-op). Provide a real change.`);
117
+ }
118
+ }
119
+ return lines.join('\n');
120
+ }
121
+
122
+ /**
123
+ * Decide whether a turn should trigger a corrective re-prompt. Pure so the
124
+ * orchestrator's wiring is unit-testable without a live model.
125
+ * @returns {{ shouldRepair: boolean, reason: string, message: string|null, problems: object[] }}
126
+ */
127
+ function planToolCallRepair({ toolCalls = [], tools = [], repairs = 0, maxRepairs = 2, enabled = true } = {}) {
128
+ if (!enabled) return { shouldRepair: false, reason: 'disabled', message: null, problems: [] };
129
+ // Prose-only (no calls) is handled by the no-action guard, not here.
130
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
131
+ return { shouldRepair: false, reason: 'no_calls', message: null, problems: [] };
132
+ }
133
+ const validation = validateToolCalls(toolCalls, tools);
134
+ if (!validation.allInvalid) {
135
+ return { shouldRepair: false, reason: 'has_valid_call', message: null, problems: validation.problems };
136
+ }
137
+ if (repairs >= maxRepairs) {
138
+ return { shouldRepair: false, reason: 'budget_exhausted', message: null, problems: validation.problems };
139
+ }
140
+ return {
141
+ shouldRepair: true,
142
+ reason: 'all_invalid',
143
+ message: buildToolRepairMessage(validation, tools),
144
+ problems: validation.problems,
145
+ };
146
+ }
147
+
148
+ module.exports = {
149
+ EDIT_TOOLS,
150
+ toolByName,
151
+ toolRequiredFields,
152
+ validateToolCall,
153
+ validateToolCalls,
154
+ buildToolRepairMessage,
155
+ planToolCallRepair,
156
+ };
@@ -33,6 +33,58 @@ function backoffForFailureCount(consecutiveFailures, schedule = DEFAULT_BACKOFF_
33
33
  return schedule[Math.max(0, idx)] ?? schedule[schedule.length - 1];
34
34
  }
35
35
 
36
+ /**
37
+ * Self-heal (half-open circuit breaker) defaults. Once a skill is
38
+ * auto-disabled (10 consecutive failures), the dispatcher periodically runs a
39
+ * single "probe" — one real execution — to test whether the underlying fault
40
+ * has cleared. The planner closes the breaker on success (re-enable + reset)
41
+ * and, after MAX_PROBE_ATTEMPTS failed probes, escalates to a human and stops
42
+ * probing. From skill-self-heal design 2026-06-24.
43
+ *
44
+ * The schedule grows so transient faults (expired token, network blip) heal
45
+ * within the hour while permanent faults (revoked permission, missing binary)
46
+ * don't thrash: 1h → 6h → 24h.
47
+ */
48
+ const DEFAULT_PROBE_SCHEDULE_MS = [
49
+ 60 * 60_000, // probe 1: 1h after auto-disable
50
+ 6 * 60 * 60_000, // probe 2: 6h after probe 1
51
+ 24 * 60 * 60_000, // probe 3: 24h after probe 2
52
+ ];
53
+ const DEFAULT_MAX_PROBE_ATTEMPTS = 3;
54
+ const PROBE_JITTER_FRACTION = 0.1; // ±10% spread to avoid a thundering herd
55
+
56
+ // Small deterministic string hash (FNV-1a, 32-bit) used to derive STABLE
57
+ // per-(skill, attempt) jitter. Stable matters: if jitter used Math.random the
58
+ // computed next-probe time would jump every tick and the "is it due yet" gate
59
+ // would flap. Deterministic jitter still de-synchronizes different skills.
60
+ function _hashString(str) {
61
+ let h = 0x811c9dc5;
62
+ for (let i = 0; i < str.length; i++) {
63
+ h ^= str.charCodeAt(i);
64
+ h = Math.imul(h, 0x01000193);
65
+ }
66
+ return h >>> 0;
67
+ }
68
+
69
+ /**
70
+ * Delay (ms) before the next recovery probe, with deterministic jitter.
71
+ * @param {number} attempts - probes already made (0 = first probe)
72
+ * @param {Object} [opts]
73
+ * @param {string} [opts.seed] - stable per-skill seed (id/name); omit for no jitter
74
+ * @param {Array<number>} [opts.schedule]
75
+ * @returns {number}
76
+ */
77
+ function probeDelayFor(attempts, opts = {}) {
78
+ const schedule = Array.isArray(opts.schedule) && opts.schedule.length
79
+ ? opts.schedule
80
+ : DEFAULT_PROBE_SCHEDULE_MS;
81
+ const base = schedule[Math.min(Math.max(0, attempts), schedule.length - 1)];
82
+ if (!opts.seed) return base;
83
+ const frac = (_hashString(`${opts.seed}:${attempts}`) % 1000) / 1000; // 0..1
84
+ const delta = base * PROBE_JITTER_FRACTION * (frac * 2 - 1); // ±10%
85
+ return Math.round(base + delta);
86
+ }
87
+
36
88
  /**
37
89
  * Pure decision function for skill dispatch.
38
90
  *
@@ -113,10 +165,35 @@ function decideSkillDispatch(skill, ctx) {
113
165
  // one was an explicit choice.
114
166
  if (!skill.enabled) {
115
167
  if (skill.auto_disabled_at) {
168
+ // Half-open self-heal: after a growing cooldown, allow ONE probe run to
169
+ // test whether the fault cleared. Gated by ctx.selfHealEnabled (default
170
+ // ON). Only auto_disabled rows are probed; user-disabled / deprecated
171
+ // rows (no auto_disabled_at) are never resurrected. From skill-self-heal
172
+ // design 2026-06-24.
173
+ const selfHeal = ctx?.selfHealEnabled !== false;
174
+ const maxAttempts = ctx?.maxProbeAttempts ?? DEFAULT_MAX_PROBE_ATTEMPTS;
175
+ const attempts = skill.auto_disabled_attempts || 0;
176
+ const base = { since: skill.auto_disabled_at, why: skill.auto_disabled_reason || 'consecutive_failures' };
177
+ if (selfHeal && attempts < maxAttempts) {
178
+ const anchorRaw = new Date(skill.last_probe_at || skill.auto_disabled_at).getTime();
179
+ const anchor = Number.isFinite(anchorRaw) ? anchorRaw : now;
180
+ const delay = probeDelayFor(attempts, { seed: skill.id || skill.name, schedule: ctx?.probeSchedule });
181
+ const nextProbeAt = anchor + delay;
182
+ if (now >= nextProbeAt) {
183
+ return { decision: 'run', reason: 'probe', details: { attempt: attempts + 1, max: maxAttempts } };
184
+ }
185
+ return {
186
+ decision: 'skip',
187
+ reason: 'auto_disabled',
188
+ details: { ...base, attempts, next_probe_at: new Date(nextProbeAt).toISOString() },
189
+ };
190
+ }
191
+ // Probes exhausted (or self-heal disabled) → stay disabled. The
192
+ // exhausted reason lets the UI show "needs attention" vs "recovering".
116
193
  return {
117
194
  decision: 'skip',
118
- reason: 'auto_disabled',
119
- details: { since: skill.auto_disabled_at, why: skill.auto_disabled_reason || 'consecutive_failures' },
195
+ reason: (selfHeal && attempts >= maxAttempts) ? 'auto_disabled_exhausted' : 'auto_disabled',
196
+ details: { ...base, attempts },
120
197
  };
121
198
  }
122
199
  return { decision: 'skip', reason: 'disabled' };
@@ -189,4 +266,7 @@ module.exports = {
189
266
  decideSkillDispatch,
190
267
  DEFAULT_BACKOFF_SCHEDULE_MS,
191
268
  backoffForFailureCount,
269
+ DEFAULT_PROBE_SCHEDULE_MS,
270
+ DEFAULT_MAX_PROBE_ATTEMPTS,
271
+ probeDelayFor,
192
272
  };