wolverine-ai 1.5.1 → 1.6.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -47,6 +47,12 @@
47
47
  "startDelayMs": 10000
48
48
  },
49
49
 
50
+ "errorMonitor": {
51
+ "defaultThreshold": 3,
52
+ "windowMs": 30000,
53
+ "cooldownMs": 60000
54
+ },
55
+
50
56
  "dashboard": {},
51
57
 
52
58
  "cors": {
@@ -720,7 +720,7 @@ Project root: ${this.cwd}${primaryFile ? `\nPrimary crash file: ${primaryFile}`
720
720
  }
721
721
  }
722
722
 
723
- const timeout = Math.min(args.timeout || 10000, 30000);
723
+ const timeout = Math.min(args.timeout || 30000, 60000);
724
724
  try {
725
725
  const output = execSync(args.command, {
726
726
  cwd: this.cwd,
@@ -335,7 +335,7 @@ async function _chatCallWithHistory(openai, { model, messages, tools, maxTokens
335
335
  * Send an error context to OpenAI and get a repair patch back.
336
336
  * Uses CODING_MODEL — routes to correct API automatically.
337
337
  */
338
- async function requestRepair({ filePath, sourceCode, errorMessage, stackTrace }) {
338
+ async function requestRepair({ filePath, sourceCode, backupSourceCode, errorMessage, stackTrace, extraContext }) {
339
339
  const model = getModel("coding");
340
340
 
341
341
  const systemPrompt = "You are a Node.js debugging expert. Respond with ONLY valid JSON, no markdown fences.";
@@ -357,7 +357,7 @@ ${errorMessage}
357
357
  ${stackTrace}
358
358
  \`\`\`
359
359
 
360
- ## Instructions
360
+ ${backupSourceCode ? `## Last Known Working Version\n\`\`\`javascript\n${backupSourceCode}\n\`\`\`\n\nCompare the current broken code with this working version. If the broken code added something that doesn't work, REVERT that addition rather than patching around it.\n` : ""}${extraContext || ""}## Instructions
361
361
  1. Identify the root cause of the error.
362
362
  2. Not all errors are code bugs. Choose the correct fix type:
363
363
  - "Cannot find module 'X'" (not starting with ./ or ../) = missing npm package → use "commands" to npm install
@@ -50,6 +50,8 @@ class WolverineRunner {
50
50
  windowMs: parseInt(process.env.WOLVERINE_RATE_WINDOW_MS, 10) || 600000,
51
51
  minGapMs: parseInt(process.env.WOLVERINE_RATE_MIN_GAP_MS, 10) || 5000,
52
52
  maxTokensPerHour: parseInt(process.env.WOLVERINE_RATE_MAX_TOKENS_HOUR, 10) || 100000,
53
+ maxGlobalHealsPerWindow: parseInt(process.env.WOLVERINE_RATE_MAX_GLOBAL_HEALS, 10) || 5,
54
+ globalWindowMs: parseInt(process.env.WOLVERINE_RATE_GLOBAL_WINDOW_MS, 10) || 300000,
53
55
  });
54
56
  this.backupManager = new BackupManager(this.cwd);
55
57
  this.logger = new EventLogger(this.cwd);
@@ -139,6 +141,7 @@ class WolverineRunner {
139
141
  this._stabilityTimer = null;
140
142
  this._stderrBuffer = "";
141
143
  this._healInProgress = false;
144
+ this._healStatus = null; // { active, file, error, phase, startedAt, iteration }
142
145
  }
143
146
 
144
147
  async start() {
@@ -336,13 +339,32 @@ class WolverineRunner {
336
339
  // Start health monitoring
337
340
  this.healthMonitor.stop();
338
341
  this.healthMonitor.reset();
339
- this.healthMonitor.start((reason) => {
340
- if (this._healInProgress) return;
341
- console.log(chalk.red(`\n🚨 Health check triggered restart (reason: ${reason})`));
342
+ this.healthMonitor.start(async (reason) => {
343
+ if (this._healInProgress || !this.running) return;
344
+ console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
342
345
  this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
346
+ this.healthMonitor.stop();
347
+
348
+ // Kill the hung process — remove exit listener to prevent double-heal
343
349
  if (this.child) {
350
+ this.child.removeAllListeners("exit");
344
351
  this.child.kill("SIGKILL");
352
+ this.child = null;
345
353
  }
354
+
355
+ // Synthesize error context for the heal pipeline
356
+ this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
357
+ `The server was running but stopped responding to HTTP requests.\n` +
358
+ `Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
359
+
360
+ this.retryCount++;
361
+ if (this.retryCount > this.maxRetries) {
362
+ console.log(chalk.red(`\n🛑 Max retries reached.`));
363
+ this._logRollbackHint();
364
+ this.running = false;
365
+ return;
366
+ }
367
+ await this._healAndRestart();
346
368
  });
347
369
 
348
370
  this.child.on("exit", async (code, signal) => {
@@ -410,6 +432,7 @@ class WolverineRunner {
410
432
  async _healAndRestart() {
411
433
  if (this._healInProgress) return;
412
434
  this._healInProgress = true;
435
+ this._healStatus = { active: true, error: this._stderrBuffer.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
413
436
 
414
437
  try {
415
438
  const result = await heal({
@@ -429,14 +452,23 @@ class WolverineRunner {
429
452
 
430
453
  if (result.healed) {
431
454
  this._lastBackupId = result.backupId;
432
- const mode = result.mode === "agent" ? "multi-file agent" : "fast path";
455
+ this.retryCount = 0; // Fresh start after successful heal
456
+ const mode = result.mode === "agent" ? "multi-file agent" : result.mode || "fast path";
433
457
  console.log(chalk.green(`\n🐺 Wolverine healed the error via ${mode}! Restarting...\n`));
434
458
 
435
459
  if (result.agentStats) {
436
460
  console.log(chalk.gray(` Agent stats: ${result.agentStats.turns} turns, ${result.agentStats.tokens} tokens, ${result.agentStats.filesModified.length} files modified`));
437
461
  }
438
462
 
463
+ // Broadcast heal success to dashboard SSE
464
+ if (this.logger) {
465
+ this.logger.info("heal.success", `Healed via ${mode}: ${result.explanation?.slice(0, 100)}`, {
466
+ mode, file: result.agentStats?.filesModified?.[0], duration: Date.now() - (this._healStatus?.startedAt || Date.now()),
467
+ });
468
+ }
469
+
439
470
  this._healInProgress = false;
471
+ this._healStatus = null;
440
472
  this._spawn();
441
473
  } else {
442
474
  console.log(chalk.red(`\n🐺 Wolverine could not heal: ${result.explanation}`));
@@ -479,6 +511,7 @@ class WolverineRunner {
479
511
  this._healInProgress = true;
480
512
 
481
513
  console.log(chalk.yellow(`\n🐺 Wolverine healing caught error on ${routePath}...`));
514
+ this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
482
515
  this.logger.info("heal.error_monitor", `Healing caught 500 on ${routePath}`, { route: routePath });
483
516
 
484
517
  // Build a synthetic stderr from the error details
@@ -502,20 +535,33 @@ class WolverineRunner {
502
535
  mcp: this.mcp,
503
536
  skills: this.skills,
504
537
  repairHistory: this.repairHistory,
538
+ routeContext: { path: routePath, method: errorDetails?.method },
505
539
  });
506
540
 
507
541
  if (result.healed) {
508
542
  console.log(chalk.green(`\n🐺 Wolverine healed ${routePath} via ${result.mode}! Restarting...\n`));
543
+ this.retryCount = 0; // Fresh start after successful heal
509
544
  this.errorMonitor.clearRoute(routePath);
545
+
546
+ // Broadcast heal success to dashboard SSE
547
+ if (this.logger) {
548
+ this.logger.info("heal.success", `Healed ${routePath} via ${result.mode}: ${result.explanation?.slice(0, 100)}`, {
549
+ mode: result.mode, route: routePath, duration: Date.now() - (this._healStatus?.startedAt || Date.now()),
550
+ });
551
+ }
552
+
510
553
  this._healInProgress = false;
554
+ this._healStatus = null;
511
555
  this.restart();
512
556
  } else {
513
557
  console.log(chalk.red(`\n🐺 Could not heal ${routePath}: ${result.explanation}`));
514
558
  this._healInProgress = false;
559
+ this._healStatus = null;
515
560
  }
516
561
  } catch (err) {
517
562
  console.log(chalk.red(`\n🐺 Error during heal: ${err.message}`));
518
563
  this._healInProgress = false;
564
+ this._healStatus = null;
519
565
  }
520
566
  }
521
567
 
@@ -1,5 +1,6 @@
1
1
  const { spawn } = require("child_process");
2
2
  const chalk = require("chalk");
3
+ const { parseError, classifyError } = require("./error-parser");
3
4
 
4
5
  /**
5
6
  * Fix Verifier — validates that a patch actually fixes the error
@@ -75,9 +76,18 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
75
76
  return;
76
77
  }
77
78
 
78
- // Check if it's the same error
79
- const sameError = originalErrorSignature &&
80
- stderr.includes(originalErrorSignature.split("::").pop().trim());
79
+ // Check if it's the same error — use classification, not string matching
80
+ let sameError = false;
81
+ if (originalErrorSignature) {
82
+ const newParsed = parseError(stderr);
83
+ const origParts = (originalErrorSignature || "").split("::");
84
+ const origMsg = origParts.slice(1).join("::").trim();
85
+ const origType = classifyError(origMsg, "");
86
+ const origClass = (origMsg.match(/^(\w*Error)/) || [])[1] || "";
87
+ const newClass = (newParsed.errorMessage?.match(/^(\w*Error)/) || [])[1] || "";
88
+ // Same error = same classification type AND same error class (TypeError vs ReferenceError)
89
+ sameError = newParsed.errorType === origType && origClass === newClass;
90
+ }
81
91
 
82
92
  resolve({
83
93
  status: "crashed",
@@ -112,11 +122,20 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
112
122
  * - { verified: false, status: "new-error" } — different error, fix broke something else → rollback
113
123
  * - { verified: false, status: "syntax-error" } — introduced syntax error → rollback
114
124
  */
115
- async function verifyFix(scriptPath, cwd, originalErrorSignature) {
125
+ /**
126
+ * Full verification pipeline.
127
+ *
128
+ * @param {string} scriptPath — entry point to verify
129
+ * @param {string} cwd — working directory
130
+ * @param {string} originalErrorSignature — error signature for same-error detection
131
+ * @param {object} routeContext — optional { path, method } for route-level testing
132
+ */
133
+ async function verifyFix(scriptPath, cwd, originalErrorSignature, routeContext) {
134
+ const steps = routeContext?.path ? 3 : 2;
116
135
  console.log(chalk.yellow("\n🔬 Verifying fix...\n"));
117
136
 
118
137
  // Step 1: Syntax check
119
- console.log(chalk.gray(" [1/2] Syntax check..."));
138
+ console.log(chalk.gray(` [1/${steps}] Syntax check...`));
120
139
  const syntax = await syntaxCheck(scriptPath);
121
140
  if (!syntax.valid) {
122
141
  console.log(chalk.red(` ❌ Syntax error introduced by fix:\n ${syntax.error}`));
@@ -125,22 +144,105 @@ async function verifyFix(scriptPath, cwd, originalErrorSignature) {
125
144
  console.log(chalk.green(" ✅ Syntax OK"));
126
145
 
127
146
  // Step 2: Boot probe
128
- console.log(chalk.gray(" [2/2] Boot probe (watching for crashes)..."));
147
+ console.log(chalk.gray(` [2/${steps}] Boot probe (watching for crashes)...`));
129
148
  const probe = await bootProbe(scriptPath, cwd, originalErrorSignature);
130
149
 
131
- if (probe.status === "alive") {
132
- console.log(chalk.green(" ✅ Process booted successfully and stayed alive."));
133
- return { verified: true, status: "fixed" };
150
+ if (probe.status !== "alive") {
151
+ if (probe.sameError) {
152
+ console.log(chalk.red(" ❌ Same error occurred fix did not resolve the issue."));
153
+ return { verified: false, status: "same-error", stderr: probe.stderr };
154
+ }
155
+ console.log(chalk.red(" ❌ A different error occurred — fix may have introduced a new bug."));
156
+ return { verified: false, status: "new-error", stderr: probe.stderr };
134
157
  }
135
-
136
- // It crashed
137
- if (probe.sameError) {
138
- console.log(chalk.red(" Same error occurred — fix did not resolve the issue."));
139
- return { verified: false, status: "same-error", stderr: probe.stderr };
158
+ console.log(chalk.green(" ✅ Process booted successfully"));
159
+
160
+ // Step 3: Route probe (if we know which route was failing)
161
+ if (routeContext?.path) {
162
+ console.log(chalk.gray(` [3/${steps}] Route probe: ${routeContext.method || "GET"} ${routeContext.path}...`));
163
+ const routeResult = await routeProbe(scriptPath, cwd, routeContext);
164
+ if (routeResult.status === "failed") {
165
+ console.log(chalk.red(` ❌ Route ${routeContext.path} still fails (HTTP ${routeResult.statusCode}): ${routeResult.body?.slice(0, 80)}`));
166
+ return { verified: false, status: "route-still-broken", stderr: routeResult.body };
167
+ }
168
+ if (routeResult.status === "passed") {
169
+ console.log(chalk.green(` ✅ Route ${routeContext.path} responds OK (HTTP ${routeResult.statusCode})`));
170
+ } else {
171
+ console.log(chalk.gray(` ⚠️ Route probe skipped: ${routeResult.reason || "unknown"}`));
172
+ }
140
173
  }
141
174
 
142
- console.log(chalk.red(" ❌ A different error occurred fix may have introduced a new bug."));
143
- return { verified: false, status: "new-error", stderr: probe.stderr };
175
+ return { verified: true, status: "fixed" };
176
+ }
177
+
178
+ /**
179
+ * Route probe — boot the server on PORT=0, detect the actual port from stdout,
180
+ * then HTTP-test the failing route.
181
+ */
182
+ function routeProbe(scriptPath, cwd, routeContext) {
183
+ const http = require("http");
184
+ return new Promise((resolve) => {
185
+ let stdout = "";
186
+ let stderr = "";
187
+ let settled = false;
188
+
189
+ const probeEnv = { ...process.env, PORT: "0", WOLVERINE_PROBE: "1" };
190
+ const child = spawn("node", [scriptPath], {
191
+ cwd, env: probeEnv,
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ });
194
+
195
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
196
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
197
+
198
+ child.on("exit", () => {
199
+ if (settled) return;
200
+ settled = true;
201
+ resolve({ status: "failed", statusCode: 0, body: stderr || "Process exited before route test" });
202
+ });
203
+
204
+ // Poll stdout for port announcement
205
+ const checkPort = setInterval(() => {
206
+ if (settled) { clearInterval(checkPort); return; }
207
+ const m = stdout.match(/(?:listening|running|started|on)\s+(?:on\s+)?(?:(?:https?:\/\/)?[\w.]+:)?(\d{4,5})/i)
208
+ || stdout.match(/:(\d{4,5})/);
209
+ if (m) {
210
+ clearInterval(checkPort);
211
+ const port = parseInt(m[1], 10);
212
+ // Test the route
213
+ const req = http.request({
214
+ hostname: "127.0.0.1", port,
215
+ path: routeContext.path,
216
+ method: routeContext.method || "GET",
217
+ timeout: 5000,
218
+ }, (res) => {
219
+ let body = "";
220
+ res.on("data", (c) => { body += c; });
221
+ res.on("end", () => {
222
+ settled = true;
223
+ child.kill("SIGTERM");
224
+ if (res.statusCode < 500) {
225
+ resolve({ status: "passed", statusCode: res.statusCode });
226
+ } else {
227
+ resolve({ status: "failed", statusCode: res.statusCode, body: body.slice(0, 500) });
228
+ }
229
+ });
230
+ });
231
+ req.on("error", (e) => { settled = true; child.kill("SIGTERM"); resolve({ status: "failed", statusCode: 0, body: e.message }); });
232
+ req.on("timeout", () => { req.destroy(); settled = true; child.kill("SIGTERM"); resolve({ status: "failed", statusCode: 0, body: "timeout" }); });
233
+ req.end();
234
+ }
235
+ }, 300);
236
+
237
+ // Overall timeout
238
+ setTimeout(() => {
239
+ clearInterval(checkPort);
240
+ if (settled) return;
241
+ settled = true;
242
+ child.kill("SIGTERM");
243
+ resolve({ status: "skipped", reason: "Could not detect server port from stdout" });
244
+ }, BOOT_PROBE_TIMEOUT_MS + 5000);
245
+ });
144
246
  }
145
247
 
146
248
  module.exports = { verifyFix, syntaxCheck, bootProbe, BOOT_PROBE_TIMEOUT_MS };
@@ -23,7 +23,24 @@ const { EVENT_TYPES } = require("../logger/event-logger");
23
23
  *
24
24
  * The engine tries fast path first. If that fails verification, it escalates to the agent.
25
25
  */
26
- async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager, logger, brain, mcp, skills, repairHistory }) {
26
+ async function heal(opts) {
27
+ const HEAL_TIMEOUT_MS = parseInt(process.env.WOLVERINE_HEAL_TIMEOUT_MS, 10) || 300000; // 5 min
28
+ try {
29
+ return await Promise.race([
30
+ _healImpl(opts),
31
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), HEAL_TIMEOUT_MS)),
32
+ ]);
33
+ } catch (err) {
34
+ if (err.message === "timeout") {
35
+ console.log(chalk.red(`\n🐺 Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`));
36
+ if (opts.logger) opts.logger.error(EVENT_TYPES.HEAL_FAILED, `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`);
37
+ return { healed: false, explanation: `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s` };
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager, logger, brain, mcp, skills, repairHistory, routeContext }) {
27
44
  const healStartTime = Date.now();
28
45
  const { redact, hasSecrets } = require("../security/secret-redactor");
29
46
 
@@ -70,6 +87,16 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
70
87
  console.log(chalk.cyan(` Error: ${parsed.errorMessage}`));
71
88
  console.log(chalk.cyan(` Type: ${parsed.errorType || "unknown"}`));
72
89
 
90
+ // 2c. If error mentions env vars, collect env context for AI
91
+ let envContext = "";
92
+ if (/process\.env|\.env|missing.*(?:key|token|secret|api|url|host|port|password|database)|undefined.*(?:config|setting)/i.test(parsed.errorMessage + " " + (parsed.stackTrace || ""))) {
93
+ const envKeys = Object.keys(process.env)
94
+ .filter(k => !k.startsWith("npm_") && !k.startsWith("WOLVERINE_") && !k.startsWith("__"))
95
+ .sort();
96
+ envContext = `\nAvailable environment variables (names only, values redacted): ${envKeys.join(", ")}\nIf the error is about a missing env var, suggest setting it rather than working around it in code.\n`;
97
+ console.log(chalk.gray(` 🔑 Env context: ${envKeys.length} vars available`));
98
+ }
99
+
73
100
  // 3. Rate limit check
74
101
  const rateCheck = rateLimiter.check(errorSignature);
75
102
  if (!rateCheck.allowed) {
@@ -83,16 +110,22 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
83
110
  let openaiClient = null;
84
111
  try { openaiClient = getClient(); } catch { /* will fail later */ }
85
112
 
86
- const injectionResult = await detectInjection(parsed.errorMessage, parsed.stackTrace, { openaiClient });
113
+ // Skip injection scan on empty/trivial stderr (prevents false positives on clean restarts)
114
+ const stderrContent = (parsed.errorMessage || "").trim() + (parsed.stackTrace || "").trim();
115
+ if (stderrContent.length < 20) {
116
+ console.log(chalk.gray(" 🛡️ Stderr too short for injection scan — skipping"));
117
+ } else {
118
+ const injectionResult = await detectInjection(parsed.errorMessage, parsed.stackTrace, { openaiClient });
87
119
 
88
- if (logger) logger.info(EVENT_TYPES.HEAL_INJECTION_SCAN, `Injection scan: ${injectionResult.safe ? "clean" : "BLOCKED"}`, injectionResult);
120
+ if (logger) logger.info(EVENT_TYPES.HEAL_INJECTION_SCAN, `Injection scan: ${injectionResult.safe ? "clean" : "BLOCKED"}`, injectionResult);
89
121
 
90
- if (!injectionResult.safe) {
91
- console.log(chalk.red(" 🚨 BLOCKED: Potential prompt injection detected."));
92
- if (logger) logger.critical(EVENT_TYPES.SECURITY_INJECTION_DETECTED, "Injection detected — repair blocked", injectionResult);
93
- return { healed: false, explanation: "Prompt injection detected — repair blocked" };
122
+ if (!injectionResult.safe) {
123
+ console.log(chalk.red(" 🚨 BLOCKED: Potential prompt injection detected."));
124
+ if (logger) logger.critical(EVENT_TYPES.SECURITY_INJECTION_DETECTED, "Injection detected — repair blocked", injectionResult);
125
+ return { healed: false, explanation: "Prompt injection detected — repair blocked" };
126
+ }
127
+ console.log(chalk.green(" ✅ Clean — no injection detected."));
94
128
  }
95
- console.log(chalk.green(" ✅ Clean — no injection detected."));
96
129
 
97
130
  // 4b. Check if this is a human-required issue (expired keys, billing, etc.)
98
131
  if (notifier) {
@@ -128,6 +161,26 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
128
161
  // 5. Read the source file (if available) + get brain context
129
162
  const sourceCode = hasFile ? sandbox.readFile(parsed.filePath) : "";
130
163
 
164
+ // 5b. Get last known good version from backup (helps AI revert vs patch)
165
+ let backupSourceCode = "";
166
+ if (hasFile && backupManager) {
167
+ try {
168
+ const fs = require("fs");
169
+ const path = require("path");
170
+ const stableBackups = backupManager.getAll().filter(b => b.status === "stable" || b.status === "verified");
171
+ if (stableBackups.length > 0) {
172
+ const latest = stableBackups[stableBackups.length - 1];
173
+ const relPath = path.relative(cwd, parsed.filePath).replace(/[/\\]/g, "__");
174
+ const backupFile = path.join(cwd, ".wolverine", "backups", latest.id, relPath);
175
+ if (fs.existsSync(backupFile)) {
176
+ backupSourceCode = fs.readFileSync(backupFile, "utf-8");
177
+ if (backupSourceCode === sourceCode) backupSourceCode = ""; // Same — no useful diff
178
+ else console.log(chalk.gray(` 📋 Found last known good version (backup ${latest.id})`));
179
+ }
180
+ }
181
+ } catch { /* non-fatal */ }
182
+ }
183
+
131
184
  let brainContext = "";
132
185
  // Inject relevant skill context (claw-code: pre-enrich prompt with matched tools)
133
186
  if (skills) {
@@ -174,7 +227,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
174
227
  backupManager.setErrorSignature(bid, errorSignature);
175
228
  if (logger) logger.info(EVENT_TYPES.BACKUP_CREATED, `Backup ${bid} (iteration ${iteration})`, { backupId: bid });
176
229
 
177
- const fullContext = [brainContext, researchContext, researchCtx].filter(Boolean).join("\n");
230
+ const fullContext = [brainContext, researchContext, researchCtx, envContext].filter(Boolean).join("\n");
178
231
 
179
232
  let result;
180
233
  if (iteration === 1 && hasFile) {
@@ -183,8 +236,9 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
183
236
  console.log(chalk.yellow(` 🧠 Fast path (${getModel("coding")})...`));
184
237
  try {
185
238
  const repair = await requestRepair({
186
- filePath: parsed.filePath, sourceCode,
239
+ filePath: parsed.filePath, sourceCode, backupSourceCode,
187
240
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
241
+ extraContext: envContext,
188
242
  });
189
243
  rateLimiter.record(errorSignature);
190
244
 
@@ -218,7 +272,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
218
272
  for (const r of patchResults) console.log(chalk.green(` ✅ Patched: ${r.file}`));
219
273
  }
220
274
 
221
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
275
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
222
276
  if (verification.verified) {
223
277
  backupManager.markVerified(bid);
224
278
  rateLimiter.clearSignature(errorSignature);
@@ -243,14 +297,14 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
243
297
  const agentResult = await agent.run({
244
298
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
245
299
  primaryFile: parsed.filePath, sourceCode,
246
- brainContext: fullContext,
300
+ brainContext: fullContext + (backupSourceCode ? `\n\nLAST KNOWN WORKING VERSION of ${parsed.filePath}:\n${backupSourceCode}\nIf the broken code added something that doesn't work, revert it rather than patching around it.` : ""),
247
301
  });
248
302
  rateLimiter.record(errorSignature, agentResult.totalTokens);
249
303
 
250
304
  if (agentResult.success) {
251
305
  // Verify: if we have a file, do syntax + boot check. Otherwise just boot probe.
252
306
  if (hasFile) {
253
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
307
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
254
308
  if (verification.verified) {
255
309
  backupManager.markVerified(bid);
256
310
  rateLimiter.clearSignature(errorSignature);
@@ -278,7 +332,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
278
332
 
279
333
  if (subResult.success) {
280
334
  if (hasFile) {
281
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
335
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
282
336
  if (verification.verified) {
283
337
  backupManager.markVerified(bid);
284
338
  rateLimiter.clearSignature(errorSignature);
@@ -413,6 +467,27 @@ async function tryOperationalFix(parsed, cwd, logger) {
413
467
  }
414
468
  }
415
469
 
470
+ // Pattern 4: EADDRINUSE — port taken by stale process
471
+ if (/EADDRINUSE/.test(msg)) {
472
+ const portMatch = msg.match(/:(\d{2,5})/) || msg.match(/port\s+(\d{2,5})/i);
473
+ if (portMatch) {
474
+ const port = parseInt(portMatch[1], 10);
475
+ try {
476
+ if (process.platform === "win32") {
477
+ const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { encoding: "utf-8", timeout: 3000 }).trim();
478
+ const pids = [...new Set(out.split("\n").map(l => parseInt(l.trim().split(/\s+/).pop(), 10)).filter(p => p && p !== process.pid))];
479
+ for (const pid of pids) { try { execSync(`taskkill /PID ${pid} /F`, { timeout: 3000 }); } catch {} }
480
+ if (pids.length > 0) return { fixed: true, action: `Killed stale process(es) on port ${port}: PIDs ${pids.join(", ")}` };
481
+ } else {
482
+ const out = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8", timeout: 3000 }).trim();
483
+ const pids = out.split("\n").map(p => parseInt(p, 10)).filter(p => p && p !== process.pid);
484
+ for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch {} }
485
+ if (pids.length > 0) return { fixed: true, action: `Killed stale process(es) on port ${port}: PIDs ${pids.join(", ")}` };
486
+ }
487
+ } catch { /* no stale process found */ }
488
+ }
489
+ }
490
+
416
491
  return { fixed: false };
417
492
  }
418
493
 
@@ -871,6 +871,7 @@ ${context ? "\nBrain:\n" + context : ""}`,
871
871
  backups: this.backupManager ? this.backupManager.getStats() : {},
872
872
  health: this.healthMonitor ? this.healthMonitor.getStats() : {},
873
873
  errorMonitor: this.errorMonitor ? this.errorMonitor.getStats() : {},
874
+ heal: this.runner ? this.runner._healStatus : null,
874
875
  }));
875
876
  }
876
877
 
@@ -1,5 +1,18 @@
1
1
  const chalk = require("chalk");
2
2
 
3
+ /**
4
+ * Normalize a route path by replacing dynamic segments with :id placeholders.
5
+ * /api/users/123 → /api/users/:id
6
+ * /api/orders/abc-def-ghi → /api/orders/:id
7
+ */
8
+ function normalizeRoute(routePath) {
9
+ if (!routePath) return routePath;
10
+ const pathOnly = routePath.split("?")[0];
11
+ return pathOnly
12
+ .replace(/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{24}|[0-9]+)/gi, "/:id")
13
+ .replace(/\/+$/, "") || "/";
14
+ }
15
+
3
16
  /**
4
17
  * Error Monitor — detects caught 500 errors that don't crash the process.
5
18
  *
@@ -34,11 +47,9 @@ class ErrorMonitor {
34
47
  * @param {object} errorDetails — { message, stack, file, line }
35
48
  */
36
49
  record(routePath, statusCode, errorDetails) {
50
+ routePath = normalizeRoute(routePath);
37
51
  if (statusCode < 500) {
38
- // Success — reset the error counter for this route
39
- if (this.routes.has(routePath)) {
40
- this.routes.delete(routePath);
41
- }
52
+ if (this.routes.has(routePath)) this.routes.delete(routePath);
42
53
  return;
43
54
  }
44
55
 
@@ -89,6 +100,7 @@ class ErrorMonitor {
89
100
  * Clear a route's error state (e.g., after a successful heal).
90
101
  */
91
102
  clearRoute(routePath) {
103
+ routePath = normalizeRoute(routePath);
92
104
  this.routes.delete(routePath);
93
105
  this._cooldowns.delete(routePath);
94
106
  }
@@ -118,4 +130,4 @@ class ErrorMonitor {
118
130
  }
119
131
  }
120
132
 
121
- module.exports = { ErrorMonitor };
133
+ module.exports = { ErrorMonitor, normalizeRoute };
@@ -18,11 +18,17 @@ class RateLimiter {
18
18
  // Max cost per hour in estimated tokens (rough budget protection)
19
19
  this.maxTokensPerHour = options.maxTokensPerHour || 100000;
20
20
 
21
+ // Global heal cap — stops infinite heal loops regardless of error signature
22
+ this.maxGlobalHealsPerWindow = options.maxGlobalHealsPerWindow || 5;
23
+ this.globalWindowMs = options.globalWindowMs || 300000; // 5 minutes
24
+
21
25
  // Internal state
22
26
  this._callLog = []; // timestamps of recent calls
23
27
  this._tokenLog = []; // { timestamp, tokens } for budget tracking
24
28
  this._errorSignatures = {}; // signature -> { count, lastSeen, backoffMs }
25
29
  this._lastCallTime = 0;
30
+ this._globalHealCount = 0;
31
+ this._globalHealWindowStart = Date.now();
26
32
  }
27
33
 
28
34
  /**
@@ -33,6 +39,21 @@ class RateLimiter {
33
39
  const now = Date.now();
34
40
  this._pruneOldEntries(now);
35
41
 
42
+ // 0. Global heal cap — regardless of error signature
43
+ const globalElapsed = now - this._globalHealWindowStart;
44
+ if (globalElapsed > this.globalWindowMs) {
45
+ this._globalHealCount = 0;
46
+ this._globalHealWindowStart = now;
47
+ }
48
+ if (this._globalHealCount >= this.maxGlobalHealsPerWindow) {
49
+ const waitMs = this.globalWindowMs - globalElapsed;
50
+ return {
51
+ allowed: false,
52
+ reason: `Global heal limit: ${this.maxGlobalHealsPerWindow} heals in ${Math.round(this.globalWindowMs / 1000)}s. Wait ${Math.ceil(waitMs / 1000)}s.`,
53
+ waitMs: Math.max(waitMs, 1000),
54
+ };
55
+ }
56
+
36
57
  // 1. Minimum gap between calls
37
58
  const sinceLast = now - this._lastCallTime;
38
59
  if (sinceLast < this.minGapMs) {
@@ -83,6 +104,7 @@ class RateLimiter {
83
104
  this._callLog.push(now);
84
105
  this._tokenLog.push({ timestamp: now, tokens: estimatedTokens });
85
106
  this._lastCallTime = now;
107
+ this._globalHealCount++;
86
108
 
87
109
  // Track error signature for loop detection
88
110
  if (errorSignature) {
@@ -125,6 +147,8 @@ class RateLimiter {
125
147
  return {
126
148
  callsInWindow: this._callLog.length,
127
149
  maxCallsPerWindow: this.maxCallsPerWindow,
150
+ globalHealsInWindow: this._globalHealCount,
151
+ maxGlobalHealsPerWindow: this.maxGlobalHealsPerWindow,
128
152
  trackedErrorSignatures: Object.keys(this._errorSignatures).length,
129
153
  estimatedTokensLastHour: this._tokenLog
130
154
  .filter(e => e.timestamp > now - 3600000)