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
@@ -0,0 +1,429 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Wall-E Skill Self-Heal — Design Review</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0d1117; --panel:#161b22; --panel2:#1c2230; --border:#2a3240;
10
+ --ink:#e6edf3; --muted:#8b97a7; --faint:#5c6675;
11
+ --blue:#4c8dff; --green:#3fb950; --amber:#d29922; --red:#f85149;
12
+ --purple:#a371f7; --teal:#39c5cf;
13
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
14
+ }
15
+ *{box-sizing:border-box}
16
+ body{margin:0;background:var(--bg);color:var(--ink);
17
+ font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
18
+ .wrap{max-width:960px;margin:0 auto;padding:40px 28px 96px;}
19
+ h1{font-size:30px;font-weight:700;letter-spacing:-.02em;margin:0 0 6px;}
20
+ h2{font-size:21px;font-weight:650;margin:46px 0 14px;padding-bottom:8px;
21
+ border-bottom:1px solid var(--border);letter-spacing:-.01em;}
22
+ h3{font-size:16px;font-weight:650;margin:26px 0 8px;color:var(--ink);}
23
+ p{margin:10px 0;color:#cdd6e0;}
24
+ a{color:var(--blue);text-decoration:none;} a:hover{text-decoration:underline;}
25
+ .sub{color:var(--muted);font-size:15px;margin:0 0 4px;}
26
+ .meta{color:var(--faint);font-size:13px;margin-top:8px;}
27
+ code{font-family:var(--mono);font-size:.86em;background:#222b38;color:#c8d3e0;
28
+ padding:1.5px 5px;border-radius:5px;border:1px solid #2c3645;}
29
+ pre{background:#0a0e14;border:1px solid var(--border);border-radius:10px;
30
+ padding:14px 16px;overflow:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#c8d3e0;}
31
+ pre .c{color:var(--faint);} pre .k{color:var(--purple);} pre .s{color:var(--green);} pre .f{color:var(--teal);}
32
+ .panel{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0;}
33
+ .grid{display:grid;gap:14px;}
34
+ .cols-2{grid-template-columns:1fr 1fr;} .cols-3{grid-template-columns:1fr 1fr 1fr;}
35
+ @media(max-width:720px){.cols-2,.cols-3{grid-template-columns:1fr;}}
36
+ table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px;}
37
+ th,td{text-align:left;padding:9px 11px;border-bottom:1px solid var(--border);}
38
+ th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em;}
39
+ td code{font-size:.92em;}
40
+ .tag{display:inline-block;font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
41
+ border:1px solid;vertical-align:middle;}
42
+ .t-red{color:var(--red);border-color:#5a2027;background:#2a1418;}
43
+ .t-amber{color:var(--amber);border-color:#5a4416;background:#241d0c;}
44
+ .t-green{color:var(--green);border-color:#1d4427;background:#0e2415;}
45
+ .t-blue{color:var(--blue);border-color:#1d3a66;background:#0d1d36;}
46
+ .t-purple{color:var(--purple);border-color:#3d2a66;background:#1a1230;}
47
+ .t-gray{color:var(--muted);border-color:#333b48;background:#1a212c;}
48
+ .callout{border-left:3px solid var(--blue);background:var(--panel2);border-radius:0 10px 10px 0;
49
+ padding:12px 16px;margin:16px 0;}
50
+ .callout.warn{border-color:var(--amber);} .callout.bad{border-color:var(--red);}
51
+ .callout.good{border-color:var(--green);}
52
+ .callout b{color:var(--ink);}
53
+ .lead{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;}
54
+ ul{margin:8px 0;padding-left:22px;} li{margin:5px 0;color:#cdd6e0;}
55
+ .kpill{font-family:var(--mono);font-size:12px;color:var(--amber);}
56
+ .num{font-variant-numeric:tabular-nums;}
57
+ .step{display:flex;gap:12px;align-items:flex-start;margin:10px 0;}
58
+ .step .n{flex:none;width:26px;height:26px;border-radius:50%;background:var(--panel2);
59
+ border:1px solid var(--border);display:grid;place-items:center;font-size:13px;font-weight:700;color:var(--blue);}
60
+ .mock{background:#10151d;border:1px solid var(--border);border-radius:10px;padding:14px 16px;font-size:13px;}
61
+ .mock .row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid #1d2430;}
62
+ .mock .row:last-child{border-bottom:none;}
63
+ .mock .nm{font-weight:650;} .mock .desc{color:var(--muted);font-size:12px;}
64
+ .dot{width:8px;height:8px;border-radius:50%;flex:none;}
65
+ .legend{display:flex;flex-wrap:wrap;gap:14px;margin:10px 0;font-size:12.5px;color:var(--muted);}
66
+ .legend span{display:inline-flex;align-items:center;gap:6px;}
67
+ footer{margin-top:60px;padding-top:20px;border-top:1px solid var(--border);color:var(--faint);font-size:13px;}
68
+ .toc{columns:2;font-size:13.5px;color:var(--muted);} .toc a{display:block;margin:3px 0;}
69
+ @media(max-width:720px){.toc{columns:1;}}
70
+ svg{max-width:100%;height:auto;display:block;margin:8px auto;}
71
+ </style>
72
+ </head>
73
+ <body>
74
+ <div class="wrap">
75
+
76
+ <p class="sub">Wall-E · Background Skills · Reliability Design</p>
77
+ <h1>Self-Healing Skills</h1>
78
+ <p class="sub">A half-open circuit breaker so auto-disabled background skills recover on their own
79
+ — instead of dying silently and staying dead for months.</p>
80
+ <p class="meta">Design review · 2026-06-24 · status: <span class="tag t-amber">awaiting your sign-off</span>
81
+ · scope: <code>wall-e/skills</code>, <code>brain.js</code>, <code>walle.js</code></p>
82
+
83
+ <div class="panel">
84
+ <div class="lead">On this page</div>
85
+ <div class="toc">
86
+ <a href="#problem">1 · The problem (live data)</a>
87
+ <a href="#root">2 · Root cause</a>
88
+ <a href="#goal">3 · What "self-heal" should mean</a>
89
+ <a href="#design">4 · Design: half-open breaker</a>
90
+ <a href="#machine">5 · State machine</a>
91
+ <a href="#code">6 · Where it plugs in</a>
92
+ <a href="#diag">7 · Fixing blind failures</a>
93
+ <a href="#setup">8 · "Needs setup" vs "failure"</a>
94
+ <a href="#retire">9 · Retiring google-calendar</a>
95
+ <a href="#ui">10 · UI surfacing</a>
96
+ <a href="#safety">11 · Safety, flags, tests</a>
97
+ <a href="#sources">12 · Research basis</a>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- ───────────────────────── 1 ───────────────────────── -->
102
+ <h2 id="problem">1 · The problem, in live numbers</h2>
103
+ <p>You flagged two skills showing <span class="tag t-red">skipped: auto disabled</span> in the Skills tab.
104
+ A read-only query of the live brain DB (<code>~/.walle/live-data/wall-e-brain.db</code>) shows it's not
105
+ two — it's <b>four</b>, all tripped by the same <code>consecutive_failures</code> breaker:</p>
106
+
107
+ <table>
108
+ <thead><tr><th>Skill</th><th>Auto-disabled</th><th>Dead for</th><th class="num">ok / fail</th><th>Last captured error</th></tr></thead>
109
+ <tbody>
110
+ <tr><td><code>google-calendar</code></td><td>2026-06-17</td><td>7 days</td><td class="num">11678 / 156</td><td><code>exited code 1</code> · ~10.15s</td></tr>
111
+ <tr><td><code>gws-workspace</code></td><td>2026-04-27</td><td><b style="color:var(--red)">58 days</b></td><td class="num">1674 / 10</td><td><span class="meta">history pruned</span></td></tr>
112
+ <tr><td><code>slack-sync</code></td><td>2026-04-27</td><td>58 days</td><td class="num">0 / 12</td><td><span class="meta">never once succeeded</span></td></tr>
113
+ <tr><td><code>mcp-scan</code></td><td>2026-04-27</td><td>58 days</td><td class="num">2350 / 12</td><td><span class="meta">history pruned</span></td></tr>
114
+ </tbody>
115
+ </table>
116
+
117
+ <div class="callout bad">
118
+ <b>The headline:</b> three of these have been dead for <b>58 days</b> — across many Wall-E restarts —
119
+ and never came back on their own. The breaker only ever <b>opens</b>. It has no path to ever <b>close</b>.
120
+ </div>
121
+
122
+ <div class="grid cols-2">
123
+ <div class="panel">
124
+ <div class="lead">Why "99% success" is a lie here</div>
125
+ <p style="margin:6px 0">The UI's success rate is a <b>lifetime</b> average:
126
+ <code>success / (success + failure)</code>. <code>google-calendar</code> shows 99% because 11.6k old
127
+ successes drown out a <b>recent unbroken streak of failures</b>. The breaker fired correctly; the
128
+ headline metric just can't see it.</p>
129
+ </div>
130
+ <div class="panel">
131
+ <div class="lead">Why a human can't debug it either</div>
132
+ <p style="margin:6px 0">The only stored error is <code>bundled skill "google-calendar" exited
133
+ code 1</code>. The skill's real stderr — token expired? <code>gws</code> CLI gone? Calendar
134
+ permission revoked? — is <b>thrown away</b>. The flat ~10.15s runtime even hints at a silent
135
+ timeout. You can't fix what you can't see.</p>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- ───────────────────────── 2 ───────────────────────── -->
140
+ <h2 id="root">2 · Root cause — the recovery path is hardcoded to one skill</h2>
141
+ <p>Wall-E <i>does</i> have an auto-recovery path. It just isn't general. On startup,
142
+ <code>syncBundledSkills()</code> clears the auto-disabled flag — but only inside an
143
+ <code>if</code> that names a single skill:</p>
144
+ <pre><span class="c">// agent.js:276 — the recovery gate</span>
145
+ <span class="k">if</span> (skill.name === <span class="s">'scan-ctm-sessions'</span> &amp;&amp; skill.execution === <span class="s">'script'</span> &amp;&amp; existing) {
146
+ <span class="c">// ...</span>
147
+ <span class="k">if</span> (existing.auto_disabled_at) { <span class="c">// agent.js:282-286</span>
148
+ updates.enabled = <span class="f">1</span>;
149
+ updates.auto_disabled_at = <span class="k">null</span>; <span class="c">// ← only scan-ctm-sessions ever reaches this</span>
150
+ }
151
+ }</pre>
152
+ <p>So <code>scan-ctm-sessions</code> resurrects every boot; everyone else stays disabled until you
153
+ manually flip the toggle (<code>PUT /api/wall-e/skills/:id {enabled:1}</code>, which clears
154
+ <code>auto_disabled_at</code> at <code>api-walle.js:4084-4092</code>). That's the entire reason three
155
+ skills sat dead for two months. <b>There is no time-based, general recovery anywhere.</b></p>
156
+
157
+ <!-- ───────────────────────── 3 ───────────────────────── -->
158
+ <h2 id="goal">3 · What "self-heal" should actually mean</h2>
159
+ <p>The failures here split into two kinds, and a good design must treat them differently:</p>
160
+ <div class="grid cols-2">
161
+ <div class="panel"><div class="lead" style="color:var(--green)">Transient — should auto-recover</div>
162
+ <ul><li>OAuth access token expired (refreshes fine next run)</li>
163
+ <li>Network blip / Google API 5xx</li><li>Brain DB briefly locked</li>
164
+ <li>Rate-limit cooldown</li></ul>
165
+ <p style="font-size:13px;color:var(--muted)">→ A retry minutes-to-hours later just works. No human needed.</p>
166
+ </div>
167
+ <div class="panel"><div class="lead" style="color:var(--red)">Permanent — must escalate, not thrash</div>
168
+ <ul><li>macOS Calendar permission revoked</li><li><code>gws</code> CLI uninstalled / not on PATH</li>
169
+ <li>Refresh token revoked — re-auth required</li><li><code>swiftc</code> / toolchain missing</li></ul>
170
+ <p style="font-size:13px;color:var(--muted)">→ Retrying forever is pointless &amp; noisy. Tell the human, with the real error.</p>
171
+ </div>
172
+ </div>
173
+ <div class="callout good"><b>The textbook answer</b> (and your chosen direction): a
174
+ <b>half-open circuit breaker</b>. Probe cheaply after a cooldown; close on success; back off and
175
+ finally <b>escalate to a human</b> after a few failed probes. Transient breakage heals with zero
176
+ clicks; permanent breakage stops thrashing and surfaces a real, actionable error.</div>
177
+
178
+ <!-- ───────────────────────── 4 ───────────────────────── -->
179
+ <h2 id="design">4 · The design — a half-open breaker, expressed as data</h2>
180
+ <p>Wall-E's architecture has a hard rule (<code>docs/architecture.md</code> → Non-goals): loops
181
+ coordinate <b>via the brain DB and scheduler — never a manager-of-managers</b>. This design honors
182
+ that completely: <b>no new loop, no supervisor.</b> The half-open state is just two columns that the
183
+ existing pure decision function already reads on every tick.</p>
184
+
185
+ <div class="grid cols-3">
186
+ <div class="panel"><div class="lead">Add: 2 columns</div>
187
+ <p style="font-size:13.5px"><code>auto_disabled_attempts INTEGER DEFAULT 0</code><br><br>
188
+ <code>last_probe_at TEXT</code></p>
189
+ <p style="font-size:12.5px;color:var(--muted)"><code>auto_disabled_at</code> is preserved untouched
190
+ for display ("disabled 7d ago"); the new columns drive scheduling.</p>
191
+ </div>
192
+ <div class="panel"><div class="lead">Probe schedule</div>
193
+ <p style="font-size:13.5px"><span class="kpill">[ 1h, 6h, 24h ]</span> exponential, <b>+ jitter</b><br><br>
194
+ <code>MAX_PROBE_ATTEMPTS = 3</code></p>
195
+ <p style="font-size:12.5px;color:var(--muted)">Mirrors the <i>existing</i> pre-disable
196
+ <code>DEFAULT_BACKOFF_SCHEDULE_MS</code> table — same idea, applied on the post-disable side.</p>
197
+ </div>
198
+ <div class="panel"><div class="lead">Outcome</div>
199
+ <p style="font-size:13.5px"><span class="tag t-green">probe ok</span> → re-enable + reset<br><br>
200
+ <span class="tag t-amber">probe fail</span> → back off<br><br>
201
+ <span class="tag t-red">3× fail</span> → escalate &amp; stop</p>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- ───────────────────────── 5 ───────────────────────── -->
206
+ <h2 id="machine">5 · State machine</h2>
207
+ <div class="panel">
208
+ <svg viewBox="0 0 820 300" role="img" aria-label="Half-open circuit breaker state machine">
209
+ <defs>
210
+ <marker id="arr" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
211
+ <path d="M0,0 L7,3 L0,6 Z" fill="#5c6675"/>
212
+ </marker>
213
+ <marker id="arrG" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
214
+ <path d="M0,0 L7,3 L0,6 Z" fill="#3fb950"/>
215
+ </marker>
216
+ <marker id="arrR" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
217
+ <path d="M0,0 L7,3 L0,6 Z" fill="#f85149"/>
218
+ </marker>
219
+ </defs>
220
+ <!-- CLOSED -->
221
+ <rect x="40" y="120" width="150" height="58" rx="11" fill="#0e2415" stroke="#3fb950" stroke-width="1.5"/>
222
+ <text x="115" y="146" text-anchor="middle" fill="#3fb950" font-size="15" font-weight="700">CLOSED</text>
223
+ <text x="115" y="164" text-anchor="middle" fill="#8b97a7" font-size="11">enabled · running</text>
224
+ <!-- OPEN -->
225
+ <rect x="335" y="20" width="150" height="58" rx="11" fill="#2a1418" stroke="#f85149" stroke-width="1.5"/>
226
+ <text x="410" y="46" text-anchor="middle" fill="#f85149" font-size="15" font-weight="700">OPEN</text>
227
+ <text x="410" y="64" text-anchor="middle" fill="#8b97a7" font-size="11">auto_disabled · waiting</text>
228
+ <!-- HALF-OPEN -->
229
+ <rect x="335" y="120" width="150" height="58" rx="11" fill="#241d0c" stroke="#d29922" stroke-width="1.5"/>
230
+ <text x="410" y="146" text-anchor="middle" fill="#d29922" font-size="15" font-weight="700">HALF-OPEN</text>
231
+ <text x="410" y="164" text-anchor="middle" fill="#8b97a7" font-size="11">one silent probe</text>
232
+ <!-- ESCALATED -->
233
+ <rect x="630" y="120" width="150" height="58" rx="11" fill="#1a1230" stroke="#a371f7" stroke-width="1.5"/>
234
+ <text x="705" y="146" text-anchor="middle" fill="#a371f7" font-size="15" font-weight="700">ESCALATED</text>
235
+ <text x="705" y="164" text-anchor="middle" fill="#8b97a7" font-size="11">human alert · stop</text>
236
+
237
+ <!-- CLOSED -> OPEN -->
238
+ <path d="M180,128 C250,90 280,60 333,52" fill="none" stroke="#f85149" stroke-width="1.6" marker-end="url(#arrR)"/>
239
+ <text x="232" y="78" fill="#f85149" font-size="11">10 consec. fails</text>
240
+ <!-- OPEN -> HALF-OPEN -->
241
+ <path d="M410,80 L410,118" fill="none" stroke="#5c6675" stroke-width="1.6" marker-end="url(#arr)"/>
242
+ <text x="420" y="103" fill="#8b97a7" font-size="11">cooldown elapsed</text>
243
+ <!-- HALF-OPEN -> CLOSED (success) -->
244
+ <path d="M333,158 C260,196 240,180 190,162" fill="none" stroke="#3fb950" stroke-width="1.6" marker-end="url(#arrG)"/>
245
+ <text x="208" y="200" fill="#3fb950" font-size="11">probe ok → re-enable</text>
246
+ <!-- HALF-OPEN -> OPEN (fail, attempts left) -->
247
+ <path d="M428,118 C455,100 455,80 440,80" fill="none" stroke="#d29922" stroke-width="1.6" marker-end="url(#arr)"/>
248
+ <text x="470" y="100" fill="#d29922" font-size="11">probe fail → back off</text>
249
+ <!-- HALF-OPEN -> ESCALATED -->
250
+ <path d="M485,149 L628,149" fill="none" stroke="#a371f7" stroke-width="1.6" marker-end="url(#arr)"/>
251
+ <text x="500" y="140" fill="#a371f7" font-size="11">3rd probe fails</text>
252
+ <!-- ESCALATED -> CLOSED (manual) -->
253
+ <path d="M630,176 C500,250 250,250 115,180" fill="none" stroke="#5c6675" stroke-width="1.4" stroke-dasharray="5 4" marker-end="url(#arr)"/>
254
+ <text x="360" y="262" fill="#8b97a7" font-size="11">manual re-enable (toggle) — unchanged escape hatch</text>
255
+ </svg>
256
+ </div>
257
+ <div class="legend">
258
+ <span><span class="dot" style="background:#3fb950"></span>healthy / recovered</span>
259
+ <span><span class="dot" style="background:#d29922"></span>recovering (transient)</span>
260
+ <span><span class="dot" style="background:#f85149"></span>open / failing</span>
261
+ <span><span class="dot" style="background:#a371f7"></span>needs a human</span>
262
+ </div>
263
+
264
+ <!-- ───────────────────────── 6 ───────────────────────── -->
265
+ <h2 id="code">6 · Where it plugs in (two surgical edits)</h2>
266
+ <p>The decision logic is already a <b>pure function</b> that sees every skill — including disabled
267
+ ones — on every scheduler tick. Today its auto-disabled branch is a dead end. We make it bloom a probe.</p>
268
+
269
+ <h3>a · <code>skills/skill-dispatch-decision.js</code> — the disabled branch (currently lines 114-121)</h3>
270
+ <pre><span class="c">// BEFORE — skips forever</span>
271
+ <span class="k">if</span> (!skill.enabled) {
272
+ <span class="k">if</span> (skill.auto_disabled_at) {
273
+ <span class="k">return</span> { decision:<span class="s">'skip'</span>, reason:<span class="s">'auto_disabled'</span>, details:{ since:skill.auto_disabled_at } };
274
+ }
275
+ <span class="k">return</span> { decision:<span class="s">'skip'</span>, reason:<span class="s">'disabled'</span> };
276
+ }
277
+
278
+ <span class="c">// AFTER — half-open probe (only for auto_disabled, never user/deprecated-disabled)</span>
279
+ <span class="k">if</span> (!skill.enabled) {
280
+ <span class="k">if</span> (skill.auto_disabled_at) {
281
+ <span class="k">const</span> attempts = skill.auto_disabled_attempts || <span class="f">0</span>;
282
+ <span class="k">if</span> (attempts &gt;= MAX_PROBE_ATTEMPTS)
283
+ <span class="k">return</span> { decision:<span class="s">'skip'</span>, reason:<span class="s">'auto_disabled_exhausted'</span>, details:{ attempts } };
284
+ <span class="k">const</span> anchor = <span class="k">new</span> Date(skill.last_probe_at || skill.auto_disabled_at).getTime();
285
+ <span class="k">const</span> nextProbeAt = anchor + probeScheduleFor(attempts); <span class="c">// 1h,6h,24h +jitter</span>
286
+ <span class="k">if</span> (now &gt;= nextProbeAt)
287
+ <span class="k">return</span> { decision:<span class="s">'run'</span>, reason:<span class="s">'probe'</span>, details:{ attempt: attempts + <span class="f">1</span> } };
288
+ <span class="k">return</span> { decision:<span class="s">'skip'</span>, reason:<span class="s">'auto_disabled'</span>,
289
+ details:{ since:skill.auto_disabled_at, next_probe_at:<span class="k">new</span> Date(nextProbeAt).toISOString(), attempts } };
290
+ }
291
+ <span class="k">return</span> { decision:<span class="s">'skip'</span>, reason:<span class="s">'disabled'</span> }; <span class="c">// user/deprecated — untouched</span>
292
+ }</pre>
293
+
294
+ <h3>b · <code>skills/skill-planner.js</code> — handle the probe outcome (next to the existing 733-748 block)</h3>
295
+ <pre><span class="c">// after a `reason:'probe'` dispatch finishes, branch on the result:</span>
296
+ <span class="k">if</span> (decision.reason === <span class="s">'probe'</span>) {
297
+ <span class="k">if</span> (result.ok) { <span class="c">// CLOSE the breaker</span>
298
+ brain.updateSkill(skill.id, { enabled:<span class="f">1</span>, auto_disabled_at:<span class="k">null</span>,
299
+ auto_disabled_reason:<span class="k">null</span>, auto_disabled_attempts:<span class="f">0</span>, last_probe_at:<span class="k">null</span> });
300
+ clearServiceAlerts(skill.name);
301
+ recoveryLog.info(<span class="s">'skill-self-heal'</span>, <span class="s">'recovered'</span>, { skill: skill.name });
302
+ } <span class="k">else</span> { <span class="c">// stay OPEN, back off</span>
303
+ <span class="k">const</span> attempts = (skill.auto_disabled_attempts || <span class="f">0</span>) + <span class="f">1</span>;
304
+ brain.updateSkill(skill.id, { auto_disabled_attempts: attempts, last_probe_at: nowIso });
305
+ <span class="k">if</span> (attempts &gt;= MAX_PROBE_ATTEMPTS)
306
+ addServiceAlert({ service:skill.name, type:<span class="s">'skill_needs_attention'</span>,
307
+ message:<span class="s">`"${skill.name}" failed ${attempts} recovery attempts: ${result.errorTail}`</span>,
308
+ action_url:<span class="s">'/setup.html'</span> });
309
+ }
310
+ <span class="k">continue</span>;
311
+ }</pre>
312
+ <div class="callout"><b>That's the whole engine.</b> Everything else — the scheduler tick, the
313
+ per-skill decision recording (<code>recordSkillLastDecision</code>), the existing Service-Alert
314
+ plumbing — is reused as-is.</div>
315
+
316
+ <!-- ───────────────────────── 7 ───────────────────────── -->
317
+ <h2 id="diag">7 · Fixing blind failures (so probes are meaningful)</h2>
318
+ <p>A probe is only useful if a <i>failed</i> probe tells us <i>why</i>. Today the script runner
319
+ collapses everything to <code>exited code 1</code>. We capture the tail of the skill's stderr/stdout
320
+ into <code>skill_executions.error</code> and <code>skills.last_result</code>, so:</p>
321
+ <div class="grid cols-2">
322
+ <div class="panel"><div class="lead t-red" style="color:var(--red)">Today</div>
323
+ <pre style="margin:6px 0"><span class="c">error:</span> bundled skill "google-calendar" exited code 1</pre></div>
324
+ <div class="panel"><div class="lead" style="color:var(--green)">After</div>
325
+ <pre style="margin:6px 0"><span class="c">error:</span> Calendar access denied (status=2).
326
+ Grant access in System Settings ›
327
+ Privacy &amp; Security › Calendars.</pre></div>
328
+ </div>
329
+ <p style="font-size:13.5px;color:var(--muted)">This also lets the escalation alert quote the real cause,
330
+ and lets us confirm the suspicious ~10.15s flat runtime (a hidden timeout vs the documented 30s binary timeout).</p>
331
+
332
+ <!-- ───────────────────────── 8 ───────────────────────── -->
333
+ <h2 id="setup">8 · "Needs setup" is not "failure" (secondary)</h2>
334
+ <p>Some skills aren't <i>broken</i> — they're <i>unconfigured</i>. <code>gws-workspace</code> returns
335
+ "no accounts configured"; that currently exits non-zero and burns the failure budget until the breaker
336
+ trips. Wall-E already has a <code>requires</code> preflight (<code>skill-planner.js:757-779</code>) that
337
+ raises actionable alerts. We extend it: an unconfigured/missing-dependency outcome reports
338
+ <span class="tag t-blue">needs_setup</span> — it does <b>not</b> count as a failure and does <b>not</b>
339
+ auto-disable; it surfaces a one-click setup prompt instead of dying silently.</p>
340
+
341
+ <!-- ───────────────────────── 9 ───────────────────────── -->
342
+ <h2 id="retire">9 · Retiring <code>google-calendar</code></h2>
343
+ <p>Per your call — <i>"just use gws-workspace, I don't think you need google-calendar anymore."</i>
344
+ <code>gws-workspace</code>'s own SKILL.md says it <b>"replaces separate google-calendar, email-sync, and
345
+ gdrive skills"</b> with no macOS-only dependencies. Running both also double-writes <code>calendar</code>
346
+ memories under the same dedup key.</p>
347
+ <div class="step"><div class="n">1</div><div>One-time migration (#28): set <code>enabled=0</code>,
348
+ <code>auto_disabled_reason='deprecated'</code>, and crucially <code>auto_disabled_at=NULL</code>.</div></div>
349
+ <div class="step"><div class="n">2</div><div>Because the prober only probes rows with a non-null
350
+ <code>auto_disabled_at</code>, the deprecated skill is <b>deliberately never resurrected</b> — it reads as a clean "disabled", not a recovering one.</div></div>
351
+ <div class="step"><div class="n">3</div><div><b>Keep the bundled dir.</b> The implementation deliberately does
352
+ <i>not</i> delete <code>skills/_bundled/google-calendar/</code>: <code>listening/calendar.js</code> reuses
353
+ its compiled <code>cal-reader</code> binary, so retirement is a DB-row deprecation, not a file removal.</div></div>
354
+ <div class="callout warn"><b>Note:</b> <code>gws-workspace</code> itself is currently auto-disabled.
355
+ Once self-heal ships, it gets probed automatically. If its OAuth / <code>gws</code> CLI is genuinely
356
+ broken, it will escalate after 3 probes with the <i>real</i> error (per §7) so you can fix the setup once — then it heals itself.</div>
357
+
358
+ <!-- ───────────────────────── 10 ───────────────────────── -->
359
+ <h2 id="ui">10 · What you'll see in the UI</h2>
360
+ <p>No new screens — just richer status on the existing skill cards and health panel.</p>
361
+ <div class="mock">
362
+ <div class="row"><span class="dot" style="background:#d29922"></span>
363
+ <span class="nm">gws-workspace</span><span class="tag t-amber">recovering</span>
364
+ <span class="desc">probe 2 / 3 · next attempt in ~6h</span></div>
365
+ <div class="row"><span class="dot" style="background:#3fb950"></span>
366
+ <span class="nm">slack-sync</span><span class="tag t-green">self-healed</span>
367
+ <span class="desc">recovered automatically 3m ago</span></div>
368
+ <div class="row"><span class="dot" style="background:#a371f7"></span>
369
+ <span class="nm">mcp-scan</span><span class="tag t-purple">needs attention</span>
370
+ <span class="desc">3 recovery attempts failed · "MCP endpoint refused connection"</span></div>
371
+ <div class="row"><span class="dot" style="background:#5c6675"></span>
372
+ <span class="nm">google-calendar</span><span class="tag t-gray">deprecated</span>
373
+ <span class="desc">superseded by gws-workspace</span></div>
374
+ </div>
375
+ <ul>
376
+ <li><b>Card chip</b> (<code>renderSkillCard</code>, walle.js:5582) gains <code>recovering</code> /
377
+ <code>needs attention</code> / <code>deprecated</code> states.</li>
378
+ <li><b>Recovery toast</b> when a skill self-heals — positive signal, not just silence.</li>
379
+ <li><b>Health panel</b> (<code>renderServiceHealthPanel</code>, walle.js:6946) splits "recovering"
380
+ (transient, no action) from "needs attention" (escalated, click to fix).</li>
381
+ </ul>
382
+
383
+ <!-- ───────────────────────── 11 ───────────────────────── -->
384
+ <h2 id="safety">11 · Safety, flags &amp; tests</h2>
385
+ <div class="grid cols-2">
386
+ <div class="panel"><div class="lead">Why this can't make things worse</div>
387
+ <ul>
388
+ <li>Probes are <b>rate-limited</b> (1h→6h→24h) and <b>capped</b> (3) — no thrash.</li>
389
+ <li>A probe is one normal run; it touches nothing a manual re-enable wouldn't.</li>
390
+ <li>Permanent breakage <b>stops &amp; escalates</b> instead of looping.</li>
391
+ <li>User-disabled &amp; <code>deprecated</code> skills are never probed (no <code>auto_disabled_at</code>).</li>
392
+ <li>It only ever <i>adds</i> recovery; worst case it does what you'd do by hand, slower.</li>
393
+ </ul>
394
+ </div>
395
+ <div class="panel"><div class="lead">Flag &amp; rollback</div>
396
+ <p style="font-size:13.5px"><code>WALLE_SKILL_SELF_HEAL</code> — default <b>ON</b>.<br>
397
+ Probe schedule &amp; <code>MAX_PROBE_ATTEMPTS</code> configurable.<br>
398
+ <code>=0</code> restores today's open-forever behavior exactly.</p>
399
+ <div class="lead" style="margin-top:14px">Tests</div>
400
+ <ul style="font-size:13.5px">
401
+ <li>decision fn: cooldown-elapsed → <code>probe</code>; pending → <code>auto_disabled</code> w/ <code>next_probe_at</code>; capped → <code>exhausted</code>.</li>
402
+ <li>planner: probe ok re-enables &amp; clears; probe fail increments &amp; escalates at 3.</li>
403
+ <li>migration: <code>google-calendar</code> → deprecated, never probed.</li>
404
+ </ul>
405
+ </div>
406
+ </div>
407
+ <div class="callout"><b>Verification</b> — on a <code>/ctm-dev</code> instance only (never primary
408
+ 3456/3457): seed an <code>auto_disabled</code> skill, advance the clock, confirm probe → recover, and
409
+ exhaustion → escalate. Then <code>cd wall-e &amp;&amp; npm test</code>. Server-side changes deploy on the
410
+ next primary restart, which you trigger.</div>
411
+
412
+ <!-- ───────────────────────── 12 ───────────────────────── -->
413
+ <h2 id="sources">12 · Research basis</h2>
414
+ <p style="font-size:13.5px;color:var(--muted)">The pattern is standard reliability engineering, adapted to Wall-E's data-driven, no-supervisor constraint: half-open probing, exponential backoff <i>with jitter</i>, a cheap health probe, and escalation-to-human after N attempts.</p>
415
+ <ul style="font-size:13.5px">
416
+ <li><a href="https://blog.meganova.ai/circuit-breakers-in-ai-agent-systems-reliability-at-scale/">Circuit Breakers in AI Agent Systems — Reliability at Scale</a> (half-open auto-recovery, cheap probe)</li>
417
+ <li><a href="https://planetscale.com/blog/how-we-made-planetscale-background-jobs-self-healing-with-sidekiq">PlanetScale — making background jobs self-healing</a> (data-driven re-queue, no supervisor)</li>
418
+ <li><a href="https://medium.com/@atulbanwar/smart-jenkins-management-auto-disabling-jobs-on-consecutive-failures-6dd80b15553b">Jenkins — auto-disabling jobs on consecutive failures</a> (the open side, and why it needs a close side)</li>
419
+ <li><a href="https://learn.microsoft.com/en-us/azure/well-architected/reliability/self-preservation">Azure Well-Architected — self-healing &amp; self-preservation</a> (escalate, don't thrash)</li>
420
+ </ul>
421
+
422
+ <footer>
423
+ Wall-E Skill Self-Heal · design review · 2026-06-24 · awaiting sign-off before any code is written.<br>
424
+ Next step on approval: implement §6 behind <code>WALLE_SKILL_SELF_HEAL</code>, validate on <code>/ctm-dev</code>, then <code>/tl-commit</code>.
425
+ </footer>
426
+
427
+ </div>
428
+ </body>
429
+ </html>
@@ -6,6 +6,15 @@ const { isPortkeyProviderConfig } = require('./portkey');
6
6
  const { withProviderMessageGuard } = require('./message-guard');
7
7
 
8
8
  const LOCAL_PROVIDER_TYPES = new Set(['ollama', 'mlx', 'lmstudio']);
9
+ const DEFAULT_PROVIDER_HEALTH_FAILURE_TYPES = new Set([
10
+ 'network',
11
+ 'timeout',
12
+ 'provider_unavailable',
13
+ 'rate_limited',
14
+ 'auth_error',
15
+ 'quota_exceeded',
16
+ 'model_unavailable',
17
+ ]);
9
18
 
10
19
  function normalizeProviderType(type) {
11
20
  const text = String(type || '').trim().toLowerCase().replace(/_/g, '-');
@@ -201,7 +210,7 @@ function _resolvePrimaryDefault() {
201
210
  // Anthropic auth_method=claude_cli routes through the user's `claude`
202
211
  // CLI (subscription billing) instead of the SDK against api.anthropic.com.
203
212
  if (providerType === 'anthropic' && authMethod === 'claude_cli') {
204
- return { id: 'claude-cli', type: 'claude-cli', client: createClient('claude-cli', {}) };
213
+ return { id: 'claude-cli', type: 'claude-cli', client: createClient('claude-cli', {}), model: getDefaultModel() };
205
214
  }
206
215
 
207
216
  // Anthropic auth_method=oauth_proxy points the SDK at the local proxy
@@ -213,13 +222,14 @@ function _resolvePrimaryDefault() {
213
222
  id: `${providerType}-oauth`,
214
223
  type: providerType,
215
224
  client: createClient('anthropic', { apiKey: 'oauth-proxy-placeholder', baseUrl: `http://127.0.0.1:${port}` }),
225
+ model: getDefaultModel(),
216
226
  };
217
227
  }
218
228
 
219
229
  // OpenAI auth_method=codex_cli routes through the user's `codex` CLI
220
230
  // (ChatGPT subscription billing) instead of api.openai.com.
221
231
  if (providerType === 'openai' && authMethod === 'codex_cli') {
222
- return { id: 'codex-cli', type: 'codex-cli', client: createClient('codex-cli', {}) };
232
+ return { id: 'codex-cli', type: 'codex-cli', client: createClient('codex-cli', {}), model: resolveCompatibleModel(getDefaultModel(), 'codex-cli') };
223
233
  }
224
234
 
225
235
  let client;
@@ -266,7 +276,7 @@ function _resolvePrimaryDefault() {
266
276
  const preferred = brain.getPreferredModelProviderForType?.(providerType);
267
277
  if (preferred?.id) id = preferred.id;
268
278
  } catch {}
269
- return { id, type: providerType, client };
279
+ return { id, type: providerType, client, model: getDefaultModel() };
270
280
  }
271
281
 
272
282
  // Surface a fallback to the user/operator: the live default was unreachable and a
@@ -295,6 +305,17 @@ function _notifyDefaultFallback(from, to, error) {
295
305
  } catch {}
296
306
  }
297
307
  try { console.warn(`[llm] ${message}${detail ? ' — ' + detail : ''}`); } catch {}
308
+ if (error) {
309
+ try {
310
+ const providerErrorMod = require('./provider-error');
311
+ const providerError = providerErrorMod.classifyProviderError(error, {
312
+ provider: from?.type || from?.id || null,
313
+ model: from?.model || null,
314
+ providerId: from?.id || null,
315
+ });
316
+ providerErrorMod.recordProviderFailureAlert(providerError, require('../brain'), { recordHealth: false });
317
+ } catch {}
318
+ }
298
319
  try {
299
320
  const health = require('./health').default;
300
321
  health.emit('default-provider-fallback', { from: { id: from?.id, type: from?.type }, to: { id: to?.id, type: to?.type }, detail });
@@ -307,8 +328,20 @@ function _notifyDefaultExhausted(error) {
307
328
  // Secret-redacted detail (replaces the old raw error.message, which could echo creds).
308
329
  let detail = '';
309
330
  try { detail = require('./provider-error').fallbackErrorDetail(error); } catch {}
310
- const message = `Every configured provider is unreachable. Check connectivity or Setup.${detail ? ' (' + detail + ')' : ''}`;
331
+ const localUnavailable = error?.suppressProviderHealthRecord === true
332
+ || error?.providerError?.type === 'provider_unavailable_local'
333
+ || error?.code === 'AI_PROVIDER_UNAVAILABLE_LOCAL';
334
+ const message = localUnavailable
335
+ ? `Every configured provider/model route is currently skipped by health policy. Wall-E will wait for recovery probes or Setup changes.${detail ? ' (' + detail + ')' : ''}`
336
+ : `Every configured provider is unreachable. Check connectivity or Setup.${detail ? ' (' + detail + ')' : ''}`;
311
337
  try { console.error(`[llm] ${message}`); } catch {}
338
+ if (error && !localUnavailable) {
339
+ try {
340
+ const providerErrorMod = require('./provider-error');
341
+ const providerError = providerErrorMod.classifyProviderError(error);
342
+ providerErrorMod.recordProviderFailureAlert(providerError, require('../brain'));
343
+ } catch {}
344
+ }
312
345
  try {
313
346
  const health = require('./health').default;
314
347
  health.emit('default-provider-unreachable', { error: detail || String(error?.message || error || '') });
@@ -320,6 +353,68 @@ function _clearDefaultRoutingNotice() {
320
353
  try { require('./health').default.clearRoutingNotice(); } catch {}
321
354
  }
322
355
 
356
+ function _recordDefaultProviderHealthFailure({ provider, model, error, type, willRetry }) {
357
+ if (willRetry) return;
358
+ const providerType = provider?.type || provider?.id || '';
359
+ if (!providerType) return;
360
+ let providerError = null;
361
+ try {
362
+ const providerErrorMod = require('./provider-error');
363
+ providerError = providerErrorMod.classifyProviderError(error, {
364
+ provider: providerType,
365
+ model: model || provider?.model || '',
366
+ providerId: provider?.id || null,
367
+ });
368
+ } catch {}
369
+ const errorType = type || providerError?.type || 'provider_error';
370
+ if (!DEFAULT_PROVIDER_HEALTH_FAILURE_TYPES.has(errorType)) return;
371
+ try {
372
+ require('./provider-health-state').recordProviderHealthStatus({
373
+ provider: providerType,
374
+ provider_id: provider?.id || null,
375
+ runtime_type: providerType,
376
+ provider_type: providerType,
377
+ model: model || provider?.model || '',
378
+ ok: false,
379
+ source: 'default-client',
380
+ error_type: errorType,
381
+ error: providerError?.rawMessage || error?.message || String(error || ''),
382
+ checked_at: providerError?.createdAt || new Date().toISOString(),
383
+ }, { brain: require('../brain'), clearTransientAlerts: false });
384
+ } catch {}
385
+ }
386
+
387
+ function _recordDefaultProviderHealthOk(provider, model) {
388
+ try {
389
+ require('./provider-health-state').recordProviderHealthStatus({
390
+ provider: provider?.type || provider?.id || 'default',
391
+ provider_id: provider?.id || null,
392
+ runtime_type: provider?.type || '',
393
+ provider_type: provider?.type || '',
394
+ model: model || provider?.model || '',
395
+ ok: true,
396
+ source: 'default-client',
397
+ }, { brain: require('../brain') });
398
+ } catch {}
399
+ }
400
+
401
+ function _defaultProviderModelCircuitBlocks(id, cand, callOpts, brain) {
402
+ try {
403
+ const model = callOpts?.model || cand?.model || '';
404
+ const providers = [cand?.type, id].filter(Boolean);
405
+ const healthState = require('./provider-health-state');
406
+ const circuit = healthState.getFirstProviderModelCircuit(providers, model, { brain });
407
+ if (!circuit) return false;
408
+ if (circuit.probe_due) {
409
+ healthState.reserveProviderModelCircuitProbe(circuit.provider, circuit.model, { brain });
410
+ return false;
411
+ }
412
+ return true;
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
323
418
  function getDefaultClient() {
324
419
  if (_defaultClient) return _defaultClient;
325
420
 
@@ -351,6 +446,8 @@ function getDefaultClient() {
351
446
  if (routingSettings.mode === 'strict' || routingSettings.fallback_enabled === false) {
352
447
  _defaultClient = makeResilientClient([primary], {
353
448
  health,
449
+ isDown: (id, cand, callOpts) => _defaultProviderModelCircuitBlocks(id, cand, callOpts, brain),
450
+ onCandidateFailure: _recordDefaultProviderHealthFailure,
354
451
  onExhausted: ({ error }) => _notifyDefaultExhausted(error),
355
452
  });
356
453
  return _defaultClient;
@@ -371,15 +468,24 @@ function getDefaultClient() {
371
468
  // Nothing to fall back to — still add transient retries around the primary.
372
469
  _defaultClient = makeResilientClient([primary], {
373
470
  health,
471
+ isDown: (id, cand, callOpts) => _defaultProviderModelCircuitBlocks(id, cand, callOpts, brain),
472
+ onCandidateFailure: _recordDefaultProviderHealthFailure,
374
473
  onExhausted: ({ error }) => _notifyDefaultExhausted(error),
375
474
  });
376
475
  return _defaultClient;
377
476
  }
378
477
  _defaultClient = makeResilientClient(chain, {
379
478
  health,
380
- isDown: (id) => {
381
- try { return health?.getProviderHealth?.(id)?.status === 'down'; } catch { return false; }
479
+ isDown: (id, cand, callOpts) => {
480
+ try {
481
+ if (health?.getProviderHealth?.(id)?.status === 'down') return true;
482
+ } catch {}
483
+ return _defaultProviderModelCircuitBlocks(id, cand, callOpts, brain);
484
+ },
485
+ onCandidateOk: ({ provider, model }) => {
486
+ _recordDefaultProviderHealthOk(provider, model);
382
487
  },
488
+ onCandidateFailure: _recordDefaultProviderHealthFailure,
383
489
  onFallback: ({ from, to, error }) => _notifyDefaultFallback(from, to, error),
384
490
  onPrimaryOk: () => _clearDefaultRoutingNotice(),
385
491
  onExhausted: ({ error }) => _notifyDefaultExhausted(error),
@@ -513,4 +619,7 @@ module.exports = {
513
619
  providerRegistry.ensureBootstrapped();
514
620
  return providerRegistry.modelMatchesProvider(model, provider);
515
621
  },
622
+ _internals: {
623
+ _resolvePrimaryDefault,
624
+ },
516
625
  };