wolverine-ai 1.5.2 → 1.6.1

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.2",
3
+ "version": "1.6.1",
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": {
@@ -24,7 +24,7 @@
24
24
  },
25
25
 
26
26
  "cluster": {
27
- "mode": "auto",
27
+ "enabled": false,
28
28
  "workers": 0
29
29
  },
30
30
 
@@ -48,7 +48,7 @@
48
48
  },
49
49
 
50
50
  "errorMonitor": {
51
- "defaultThreshold": 3,
51
+ "defaultThreshold": 1,
52
52
  "windowMs": 30000,
53
53
  "cooldownMs": 60000
54
54
  },
package/server/index.js CHANGED
@@ -1,61 +1,85 @@
1
- const fastify = require("fastify")({ logger: false });
1
+ const cluster = require("cluster");
2
+ const os = require("os");
2
3
  const PORT = process.env.PORT || 3000;
3
4
 
4
- // Routes
5
- fastify.register(require("./routes/health"), { prefix: "/health" });
6
- fastify.register(require("./routes/api"), { prefix: "/api" });
7
- fastify.register(require("./routes/time"), { prefix: "/time" });
8
-
9
- // Root
10
- fastify.get("/", async () => ({
11
- name: "Wolverine Server",
12
- version: "1.0.0",
13
- status: "running",
14
- uptime: process.uptime(),
15
- }));
16
-
17
- // 404
18
- fastify.setNotFoundHandler((req, reply) => {
19
- reply.code(404).send({ error: "Not found", path: req.url });
20
- });
21
-
22
- // Error handler reports to Wolverine parent via IPC for auto-healing
23
- fastify.setErrorHandler((err, req, reply) => {
24
- console.error(`[ERROR] ${err.message}`);
25
- reply.code(500).send({ error: err.message });
26
-
27
- // Report to Wolverine via IPC (if running under wolverine)
28
- if (typeof process.send === "function") {
29
- try {
30
- // Extract file/line from stack trace
31
- let file = null, line = null;
32
- if (err.stack) {
33
- const frames = err.stack.split("\n");
34
- for (const frame of frames) {
35
- const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
36
- if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
37
- file = m[1]; line = parseInt(m[2], 10); break;
5
+ // Cluster mode: master forks workers, workers run the server.
6
+ // Wolverine sets WOLVERINE_RECOMMENDED_WORKERS based on system detection.
7
+ // Set cluster.enabled=true in settings.json or WOLVERINE_CLUSTER=true to enable.
8
+ const clusterEnabled = process.env.WOLVERINE_CLUSTER === "true";
9
+ const workerCount = parseInt(process.env.WOLVERINE_RECOMMENDED_WORKERS, 10) || os.cpus().length;
10
+
11
+ if (clusterEnabled && cluster.isPrimary && workerCount > 1) {
12
+ console.log(`[CLUSTER] Primary ${process.pid} forking ${workerCount} workers`);
13
+ for (let i = 0; i < workerCount; i++) cluster.fork();
14
+
15
+ cluster.on("exit", (worker, code) => {
16
+ if (code !== 0) {
17
+ console.log(`[CLUSTER] Worker ${worker.process.pid} died (code ${code}), respawning...`);
18
+ cluster.fork();
19
+ }
20
+ });
21
+ } else {
22
+ // Single worker or cluster worker — run the server
23
+ const fastify = require("fastify")({ logger: false });
24
+
25
+ // Routes
26
+ fastify.register(require("./routes/health"), { prefix: "/health" });
27
+ fastify.register(require("./routes/api"), { prefix: "/api" });
28
+ fastify.register(require("./routes/time"), { prefix: "/time" });
29
+
30
+ // Root
31
+ fastify.get("/", async () => ({
32
+ name: "Wolverine Server",
33
+ version: "1.0.0",
34
+ status: "running",
35
+ uptime: process.uptime(),
36
+ pid: process.pid,
37
+ worker: cluster.isWorker ? cluster.worker.id : "primary",
38
+ }));
39
+
40
+ // 404
41
+ fastify.setNotFoundHandler((req, reply) => {
42
+ reply.code(404).send({ error: "Not found", path: req.url });
43
+ });
44
+
45
+ // Error handler — reports to Wolverine parent via IPC for auto-healing
46
+ fastify.setErrorHandler((err, req, reply) => {
47
+ console.error(`[ERROR] ${err.message}`);
48
+ reply.code(500).send({ error: err.message });
49
+
50
+ // Report to Wolverine via IPC (if running under wolverine)
51
+ if (typeof process.send === "function") {
52
+ try {
53
+ let file = null, line = null;
54
+ if (err.stack) {
55
+ const frames = err.stack.split("\n");
56
+ for (const frame of frames) {
57
+ const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+([^\s(]+):(\d+):(\d+)/);
58
+ if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
59
+ file = m[1]; line = parseInt(m[2], 10); break;
60
+ }
38
61
  }
39
62
  }
40
- }
41
- process.send({
42
- type: "route_error",
43
- path: req.url,
44
- method: req.method,
45
- statusCode: 500,
46
- message: err.message,
47
- stack: err.stack,
48
- file,
49
- line,
50
- timestamp: Date.now(),
51
- });
52
- } catch (_) { /* IPC send failed — non-fatal */ }
53
- }
54
- });
55
-
56
- fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
57
- if (err) { console.error(err); process.exit(1); }
58
- console.log(`Server running on http://localhost:${PORT}`);
59
- console.log(`Health: http://localhost:${PORT}/health`);
60
- console.log(`API: http://localhost:${PORT}/api`);
61
- });
63
+ process.send({
64
+ type: "route_error",
65
+ path: req.url,
66
+ method: req.method,
67
+ statusCode: 500,
68
+ message: err.message,
69
+ stack: err.stack,
70
+ file,
71
+ line,
72
+ timestamp: Date.now(),
73
+ });
74
+ } catch (_) { /* IPC send failed — non-fatal */ }
75
+ }
76
+ });
77
+
78
+ fastify.listen({ port: PORT, host: "0.0.0.0", reusePort: clusterEnabled }, (err) => {
79
+ if (err) { console.error(err); process.exit(1); }
80
+ const label = cluster.isWorker ? ` (worker ${cluster.worker.id})` : "";
81
+ console.log(`Server running on http://localhost:${PORT}${label}`);
82
+ console.log(`Health: http://localhost:${PORT}/health`);
83
+ console.log(`API: http://localhost:${PORT}/api`);
84
+ });
85
+ }
@@ -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, backupSourceCode, 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
- ${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` : ""}## 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);
@@ -93,7 +95,7 @@ class WolverineRunner {
93
95
 
94
96
  // Error monitor — detects caught 500 errors without process crash
95
97
  this.errorMonitor = new ErrorMonitor({
96
- threshold: parseInt(process.env.WOLVERINE_ERROR_THRESHOLD, 10) || 3,
98
+ threshold: parseInt(process.env.WOLVERINE_ERROR_THRESHOLD, 10) || 1,
97
99
  windowMs: parseInt(process.env.WOLVERINE_ERROR_WINDOW_MS, 10) || 30000,
98
100
  cooldownMs: parseInt(process.env.WOLVERINE_ERROR_COOLDOWN_MS, 10) || 60000,
99
101
  logger: this.logger,
@@ -234,11 +236,11 @@ class WolverineRunner {
234
236
 
235
237
  oldChild.removeAllListeners("exit");
236
238
  oldChild.once("exit", onExit);
237
- oldChild.kill("SIGTERM");
239
+ this._killProcessTree(oldChild.pid, "SIGTERM");
238
240
 
239
241
  // Force kill if it doesn't exit in 3s
240
242
  setTimeout(() => {
241
- try { oldChild.kill("SIGKILL"); } catch {}
243
+ this._killProcessTree(oldChild.pid, "SIGKILL");
242
244
  onExit();
243
245
  }, 3000);
244
246
  } else {
@@ -276,13 +278,14 @@ class WolverineRunner {
276
278
 
277
279
  this.logger.info(EVENT_TYPES.PROCESS_STOP, "Wolverine stopped (graceful shutdown)");
278
280
 
279
- // Kill child — remove exit listener first so it doesn't trigger heal
281
+ // Kill child + all its descendants — remove exit listener first so it doesn't trigger heal
280
282
  if (this.child) {
283
+ const pid = this.child.pid;
281
284
  this.child.removeAllListeners("exit");
282
- this.child.kill("SIGTERM");
285
+ this._killProcessTree(pid, "SIGTERM");
283
286
  // Force kill after 3s if it doesn't respond
284
287
  setTimeout(() => {
285
- try { if (this.child) this.child.kill("SIGKILL"); } catch {}
288
+ this._killProcessTree(pid, "SIGKILL");
286
289
  }, 3000);
287
290
  this.child = null;
288
291
  }
@@ -302,9 +305,15 @@ class WolverineRunner {
302
305
  // Spawn with --require error-hook.js for IPC error reporting
303
306
  // The error hook auto-patches Fastify/Express to report caught 500s
304
307
  const errorHookPath = path.join(__dirname, "error-hook.js");
308
+ const sysInfo = require("./system-info").detect();
305
309
  this.child = spawn("node", ["--require", errorHookPath, this.scriptPath], {
306
310
  cwd: this.cwd,
307
- env: { ...process.env },
311
+ env: {
312
+ ...process.env,
313
+ // Tell the user's server how many workers to fork (if it uses clustering)
314
+ WOLVERINE_RECOMMENDED_WORKERS: String(sysInfo.recommended?.workers || 1),
315
+ WOLVERINE_MANAGED: "1", // Signal that wolverine is managing this process
316
+ },
308
317
  stdio: ["inherit", "inherit", "pipe", "ipc"],
309
318
  });
310
319
 
@@ -337,13 +346,33 @@ class WolverineRunner {
337
346
  // Start health monitoring
338
347
  this.healthMonitor.stop();
339
348
  this.healthMonitor.reset();
340
- this.healthMonitor.start((reason) => {
341
- if (this._healInProgress) return;
342
- console.log(chalk.red(`\n🚨 Health check triggered restart (reason: ${reason})`));
349
+ this.healthMonitor.start(async (reason) => {
350
+ if (this._healInProgress || !this.running) return;
351
+ console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
343
352
  this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
353
+ this.healthMonitor.stop();
354
+
355
+ // Kill the hung process — remove exit listener to prevent double-heal
344
356
  if (this.child) {
345
- this.child.kill("SIGKILL");
357
+ const pid = this.child.pid;
358
+ this.child.removeAllListeners("exit");
359
+ this._killProcessTree(pid, "SIGKILL");
360
+ this.child = null;
361
+ }
362
+
363
+ // Synthesize error context for the heal pipeline
364
+ this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
365
+ `The server was running but stopped responding to HTTP requests.\n` +
366
+ `Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
367
+
368
+ this.retryCount++;
369
+ if (this.retryCount > this.maxRetries) {
370
+ console.log(chalk.red(`\n🛑 Max retries reached.`));
371
+ this._logRollbackHint();
372
+ this.running = false;
373
+ return;
346
374
  }
375
+ await this._healAndRestart();
347
376
  });
348
377
 
349
378
  this.child.on("exit", async (code, signal) => {
@@ -514,6 +543,7 @@ class WolverineRunner {
514
543
  mcp: this.mcp,
515
544
  skills: this.skills,
516
545
  repairHistory: this.repairHistory,
546
+ routeContext: { path: routePath, method: errorDetails?.method },
517
547
  });
518
548
 
519
549
  if (result.healed) {
@@ -565,6 +595,34 @@ class WolverineRunner {
565
595
  }
566
596
  }
567
597
 
598
+ /**
599
+ * Kill a process and all its children (process tree kill).
600
+ * Handles servers that fork workers internally — prevents orphaned processes.
601
+ */
602
+ _killProcessTree(pid, signal = "SIGTERM") {
603
+ if (!pid) return;
604
+ try {
605
+ if (process.platform === "win32") {
606
+ // taskkill /T kills the process tree
607
+ execSync(`taskkill /PID ${pid} /T /F`, { timeout: 3000, stdio: "ignore" });
608
+ } else {
609
+ // Kill the process group (negative PID)
610
+ try { process.kill(-pid, signal); } catch {}
611
+ // Also kill individual PID in case it's not a group leader
612
+ try { process.kill(pid, signal); } catch {}
613
+ // Find and kill children via pgrep
614
+ try {
615
+ const children = execSync(`pgrep -P ${pid} 2>/dev/null`, { encoding: "utf-8", timeout: 3000 }).trim();
616
+ if (children) {
617
+ for (const cpid of children.split("\n").map(p => parseInt(p, 10)).filter(Boolean)) {
618
+ try { process.kill(cpid, signal); } catch {}
619
+ }
620
+ }
621
+ } catch { /* no children or pgrep not available */ }
622
+ }
623
+ } catch { /* process already dead */ }
624
+ }
625
+
568
626
  _ensurePortFree() {
569
627
  const port = parseInt(process.env.PORT, 10) || 3000;
570
628
  try {
@@ -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) {
@@ -200,7 +227,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
200
227
  backupManager.setErrorSignature(bid, errorSignature);
201
228
  if (logger) logger.info(EVENT_TYPES.BACKUP_CREATED, `Backup ${bid} (iteration ${iteration})`, { backupId: bid });
202
229
 
203
- const fullContext = [brainContext, researchContext, researchCtx].filter(Boolean).join("\n");
230
+ const fullContext = [brainContext, researchContext, researchCtx, envContext].filter(Boolean).join("\n");
204
231
 
205
232
  let result;
206
233
  if (iteration === 1 && hasFile) {
@@ -211,6 +238,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
211
238
  const repair = await requestRepair({
212
239
  filePath: parsed.filePath, sourceCode, backupSourceCode,
213
240
  errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
241
+ extraContext: envContext,
214
242
  });
215
243
  rateLimiter.record(errorSignature);
216
244
 
@@ -244,7 +272,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
244
272
  for (const r of patchResults) console.log(chalk.green(` ✅ Patched: ${r.file}`));
245
273
  }
246
274
 
247
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
275
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
248
276
  if (verification.verified) {
249
277
  backupManager.markVerified(bid);
250
278
  rateLimiter.clearSignature(errorSignature);
@@ -276,7 +304,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
276
304
  if (agentResult.success) {
277
305
  // Verify: if we have a file, do syntax + boot check. Otherwise just boot probe.
278
306
  if (hasFile) {
279
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
307
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
280
308
  if (verification.verified) {
281
309
  backupManager.markVerified(bid);
282
310
  rateLimiter.clearSignature(errorSignature);
@@ -304,7 +332,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
304
332
 
305
333
  if (subResult.success) {
306
334
  if (hasFile) {
307
- const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
335
+ const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
308
336
  if (verification.verified) {
309
337
  backupManager.markVerified(bid);
310
338
  rateLimiter.clearSignature(errorSignature);
@@ -439,6 +467,27 @@ async function tryOperationalFix(parsed, cwd, logger) {
439
467
  }
440
468
  }
441
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
+
442
491
  return { fixed: false };
443
492
  }
444
493