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 +1 -1
- package/server/config/settings.json +2 -2
- package/server/index.js +81 -57
- package/src/agent/agent-engine.js +1 -1
- package/src/core/ai-client.js +2 -2
- package/src/core/runner.js +69 -11
- package/src/core/verifier.js +118 -16
- package/src/core/wolverine.js +54 -5
- package/src/monitor/error-monitor.js +17 -5
- package/src/security/rate-limiter.js +24 -0
- package/PLATFORM.md +0 -450
- package/SERVER_BEST_PRACTICES.md +0 -70
- package/TELEMETRY.md +0 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"
|
|
27
|
+
"enabled": false,
|
|
28
28
|
"workers": 0
|
|
29
29
|
},
|
|
30
30
|
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
|
|
50
50
|
"errorMonitor": {
|
|
51
|
-
"defaultThreshold":
|
|
51
|
+
"defaultThreshold": 1,
|
|
52
52
|
"windowMs": 30000,
|
|
53
53
|
"cooldownMs": 60000
|
|
54
54
|
},
|
package/server/index.js
CHANGED
|
@@ -1,61 +1,85 @@
|
|
|
1
|
-
const
|
|
1
|
+
const cluster = require("cluster");
|
|
2
|
+
const os = require("os");
|
|
2
3
|
const PORT = process.env.PORT || 3000;
|
|
3
4
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 ||
|
|
723
|
+
const timeout = Math.min(args.timeout || 30000, 60000);
|
|
724
724
|
try {
|
|
725
725
|
const output = execSync(args.command, {
|
|
726
726
|
cwd: this.cwd,
|
package/src/core/ai-client.js
CHANGED
|
@@ -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
|
package/src/core/runner.js
CHANGED
|
@@ -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) ||
|
|
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.
|
|
239
|
+
this._killProcessTree(oldChild.pid, "SIGTERM");
|
|
238
240
|
|
|
239
241
|
// Force kill if it doesn't exit in 3s
|
|
240
242
|
setTimeout(() => {
|
|
241
|
-
|
|
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.
|
|
285
|
+
this._killProcessTree(pid, "SIGTERM");
|
|
283
286
|
// Force kill after 3s if it doesn't respond
|
|
284
287
|
setTimeout(() => {
|
|
285
|
-
|
|
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: {
|
|
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
|
|
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.
|
|
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 {
|
package/src/core/verifier.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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 };
|
package/src/core/wolverine.js
CHANGED
|
@@ -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(
|
|
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
|
|