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 +1 -1
- package/server/config/settings.json +6 -0
- package/src/agent/agent-engine.js +1 -1
- package/src/core/ai-client.js +2 -2
- package/src/core/runner.js +50 -4
- package/src/core/verifier.js +118 -16
- package/src/core/wolverine.js +89 -14
- package/src/dashboard/server.js +1 -0
- package/src/monitor/error-monitor.js +17 -5
- package/src/security/rate-limiter.js +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "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": {
|
|
@@ -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, 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
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
120
|
+
if (logger) logger.info(EVENT_TYPES.HEAL_INJECTION_SCAN, `Injection scan: ${injectionResult.safe ? "clean" : "BLOCKED"}`, injectionResult);
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
package/src/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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)
|