wolverine-ai 6.1.1 → 6.2.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.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Wolverine Claw Runner — Process manager for OpenClaw gateway.
3
+ *
4
+ * Extends wolverine's self-healing to the claw environment:
5
+ * - Spawns wolverine-claw/index.js as a child process
6
+ * - Monitors for crashes and gateway errors via IPC
7
+ * - Triggers AI-powered healing on failures
8
+ * - Manages workspace backups and rollbacks
9
+ * - Health monitoring via WebSocket probe (not HTTP)
10
+ *
11
+ * This is the claw equivalent of src/core/runner.js.
12
+ */
13
+
14
+ const { spawn } = require("child_process");
15
+ const path = require("path");
16
+ const fs = require("fs");
17
+ const net = require("net");
18
+ const chalk = require("chalk");
19
+ const { heal } = require("../core/wolverine");
20
+ const { BackupManager } = require("../backup/backup-manager");
21
+ const { EventLogger, EVENT_TYPES } = require("../logger/event-logger");
22
+ const { Brain } = require("../brain/brain");
23
+ const { initRedactor } = require("../security/secret-redactor");
24
+ const { RateLimiter } = require("../security/rate-limiter");
25
+ const { TokenTracker } = require("../logger/token-tracker");
26
+ const { setTokenTracker } = require("../core/ai-client");
27
+ const { LoopGuard, ensureSingleProcess } = require("../skills/loop-guard");
28
+ const { loadConfig } = require("../core/config");
29
+
30
+ class ClawRunner {
31
+ constructor(options = {}) {
32
+ this.cwd = options.cwd || process.cwd();
33
+ this.clawDir = path.join(this.cwd, "wolverine-claw");
34
+ this.scriptPath = path.join(this.clawDir, "index.js");
35
+ this.child = null;
36
+ this.running = false;
37
+ this.retryCount = 0;
38
+ this.maxRetries = options.maxRetries || 5;
39
+
40
+ // Healing state
41
+ this._healInProgress = false;
42
+ this._stderrBuffer = "";
43
+ this._lastStartTime = null;
44
+ this._lastBackupId = null;
45
+
46
+ // Load claw-specific config
47
+ this.clawConfig = this._loadClawConfig();
48
+
49
+ // Load wolverine core config for shared settings
50
+ this.config = loadConfig();
51
+
52
+ this._initSubsystems();
53
+ }
54
+
55
+ /**
56
+ * Load claw configuration from wolverine-claw/config/settings.json.
57
+ */
58
+ _loadClawConfig() {
59
+ const configPath = path.join(this.clawDir, "config", "settings.json");
60
+ try {
61
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
62
+ } catch (err) {
63
+ console.error(chalk.red(`[CLAW] Config not found at ${configPath}`));
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Initialize subsystems — backup, brain, logging, rate limiting.
70
+ */
71
+ _initSubsystems() {
72
+ this.redactor = initRedactor(this.cwd);
73
+ this.backupManager = new BackupManager(this.cwd);
74
+ this.logger = new EventLogger(this.cwd);
75
+ this.logger.setRedactor(this.redactor);
76
+ this.tokenTracker = new TokenTracker(this.cwd);
77
+ setTokenTracker(this.tokenTracker);
78
+ this.brain = new Brain(this.cwd);
79
+
80
+ const healCfg = this.clawConfig.healing || {};
81
+ this.rateLimiter = new RateLimiter({
82
+ maxCallsPerWindow: this.config.rateLimiting?.maxCallsPerWindow || 32,
83
+ windowMs: this.config.rateLimiting?.windowMs || 100000,
84
+ minGapMs: this.config.rateLimiting?.minGapMs || 5000,
85
+ maxTokensPerHour: this.config.rateLimiting?.maxTokensPerHour || 1000000,
86
+ maxGlobalHealsPerWindow: healCfg.maxHealsPerWindow || 5,
87
+ globalWindowMs: healCfg.windowMs || 300000,
88
+ });
89
+
90
+ this.loopGuard = new LoopGuard(this.cwd, {
91
+ maxAttempts: healCfg.loopMaxAttempts || 3,
92
+ windowMs: healCfg.loopWindowMs || 600000,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Start the claw process.
98
+ */
99
+ async start() {
100
+ // Validate claw directory exists
101
+ if (!fs.existsSync(this.scriptPath)) {
102
+ console.error(chalk.red(`\n [CLAW] Entry point not found: ${this.scriptPath}`));
103
+ console.log(chalk.gray(" Run wolverine --init-claw to scaffold the wolverine-claw directory.\n"));
104
+ process.exit(1);
105
+ }
106
+
107
+ this.running = true;
108
+ this.retryCount = 0;
109
+
110
+ console.log(chalk.blue.bold("\n 🐾 Wolverine Claw — Agentic Agent with Self-Healing\n"));
111
+
112
+ // Initialize brain
113
+ try {
114
+ await this.brain.init();
115
+ console.log(chalk.gray(" 🧠 Brain: initialized"));
116
+ } catch (err) {
117
+ console.log(chalk.yellow(` ⚠️ Brain init: ${err.message}`));
118
+ }
119
+
120
+ // Log config
121
+ const gw = this.clawConfig.gateway || {};
122
+ console.log(chalk.gray(` Gateway: ws://${gw.host || "127.0.0.1"}:${gw.port || 18789}`));
123
+ console.log(chalk.gray(` Agent: ${this.clawConfig.agent?.model || "claude-sonnet-4-6"}`));
124
+ console.log(chalk.gray(` Workspace: ${path.resolve(this.clawConfig.workspace?.path || "wolverine-claw/workspace")}`));
125
+ console.log(chalk.gray(` Healing: ${this.clawConfig.healing?.enabled !== false ? "enabled" : "disabled"}`));
126
+ console.log(chalk.gray(` Max heals: ${this.clawConfig.healing?.maxHealsPerWindow || 5} per ${Math.round((this.clawConfig.healing?.windowMs || 300000) / 60000)}min`));
127
+
128
+ // Enabled channels
129
+ const channels = this.clawConfig.channels || {};
130
+ const enabledChannels = Object.entries(channels)
131
+ .filter(([k, v]) => !k.startsWith("_") && v.enabled)
132
+ .map(([k]) => k);
133
+ console.log(chalk.gray(` Channels: ${enabledChannels.length > 0 ? enabledChannels.join(", ") : "terminal only"}`));
134
+ console.log("");
135
+
136
+ // Create startup backup
137
+ try {
138
+ this._lastBackupId = this.backupManager.createBackup("claw-pre-start");
139
+ console.log(chalk.gray(` 📸 Startup backup: ${this._lastBackupId}`));
140
+ } catch (err) {
141
+ console.log(chalk.yellow(` ⚠️ Backup failed: ${err.message}`));
142
+ }
143
+
144
+ console.log("");
145
+ this._spawn();
146
+ }
147
+
148
+ /**
149
+ * Spawn the claw child process.
150
+ */
151
+ _spawn() {
152
+ if (!this.running) return;
153
+
154
+ this._lastStartTime = Date.now();
155
+ this._stderrBuffer = "";
156
+
157
+ // Spawn wolverine-claw/index.js with IPC channel
158
+ const errorHookPath = path.join(this.cwd, "src", "core", "error-hook.js");
159
+ this.child = spawn(process.execPath, [this.scriptPath], {
160
+ cwd: this.cwd,
161
+ stdio: ["inherit", "inherit", "pipe", "ipc"],
162
+ env: {
163
+ ...process.env,
164
+ WOLVERINE_MANAGED: "1",
165
+ WOLVERINE_CLAW: "1",
166
+ NODE_ENV: process.env.NODE_ENV || "development",
167
+ },
168
+ });
169
+
170
+ console.log(chalk.green(` 🐾 Claw started (PID ${this.child.pid})`));
171
+
172
+ // Collect stderr
173
+ this.child.stderr.on("data", (chunk) => {
174
+ const text = chunk.toString();
175
+ this._stderrBuffer += text;
176
+ process.stderr.write(chunk);
177
+
178
+ // Cap buffer size
179
+ if (this._stderrBuffer.length > 10000) {
180
+ this._stderrBuffer = this._stderrBuffer.slice(-8000);
181
+ }
182
+ });
183
+
184
+ // IPC messages from claw process
185
+ this.child.on("message", (msg) => {
186
+ if (!msg || typeof msg !== "object") return;
187
+
188
+ if (msg.type === "route_error") {
189
+ this._handleError(msg);
190
+ } else if (msg.type === "claw_health") {
191
+ this._handleHealthReport(msg);
192
+ } else if (msg.type === "claw_heartbeat") {
193
+ // Healthy heartbeat — reset retry count if stable
194
+ if (Date.now() - this._lastStartTime > 60000) {
195
+ this.retryCount = 0;
196
+ }
197
+ }
198
+ });
199
+
200
+ // Process exit
201
+ this.child.on("exit", (code, signal) => {
202
+ this.child = null;
203
+
204
+ if (!this.running) {
205
+ console.log(chalk.gray("\n 🐾 Claw stopped."));
206
+ return;
207
+ }
208
+
209
+ console.log(chalk.red(`\n 💥 Claw crashed (code=${code}, signal=${signal})`));
210
+
211
+ this.logger.info("claw.crash", `Claw process exited`, {
212
+ code, signal,
213
+ uptime: Date.now() - this._lastStartTime,
214
+ retryCount: this.retryCount,
215
+ });
216
+
217
+ this._handleCrash(code, signal);
218
+ });
219
+
220
+ // Start gateway health probe
221
+ this._startGatewayProbe();
222
+ }
223
+
224
+ /**
225
+ * Handle a crash — heal and restart.
226
+ */
227
+ async _handleCrash(code, signal) {
228
+ this.retryCount++;
229
+
230
+ if (this.retryCount > this.maxRetries) {
231
+ console.log(chalk.red(`\n ❌ Max retries (${this.maxRetries}) reached. Stopping.`));
232
+
233
+ // Rollback to startup backup if available
234
+ if (this._lastBackupId) {
235
+ try {
236
+ this.backupManager.rollback(this._lastBackupId);
237
+ console.log(chalk.yellow(` ↩️ Rolled back to startup snapshot: ${this._lastBackupId}`));
238
+ } catch {}
239
+ }
240
+ return;
241
+ }
242
+
243
+ console.log(chalk.yellow(` 🔄 Retry ${this.retryCount}/${this.maxRetries}...`));
244
+
245
+ // If healing is enabled and we have an error, try to heal
246
+ const healingEnabled = this.clawConfig.healing?.enabled !== false;
247
+ if (healingEnabled && this._stderrBuffer.trim()) {
248
+ await this._tryHeal(this._stderrBuffer, null);
249
+ }
250
+
251
+ // Restart with delay
252
+ const delay = Math.min(1000 * this.retryCount, 5000);
253
+ setTimeout(() => this._spawn(), delay);
254
+ }
255
+
256
+ /**
257
+ * Handle an IPC error from the claw process.
258
+ */
259
+ async _handleError(errorMsg) {
260
+ console.log(chalk.red(` ⚡ Claw error: ${errorMsg.message?.slice(0, 100)}`));
261
+
262
+ const healingEnabled = this.clawConfig.healing?.enabled !== false;
263
+ if (!healingEnabled) return;
264
+
265
+ const errorText = `${errorMsg.message}\n${errorMsg.stack || ""}`;
266
+ await this._tryHeal(errorText, errorMsg.file);
267
+ }
268
+
269
+ /**
270
+ * Try to heal an error using wolverine's AI pipeline.
271
+ */
272
+ async _tryHeal(errorText, file) {
273
+ if (this._healInProgress) {
274
+ console.log(chalk.gray(" ⏳ Heal already in progress, skipping..."));
275
+ return;
276
+ }
277
+
278
+ // Empty stderr → just restart, no AI
279
+ if (!errorText.trim()) return;
280
+
281
+ // Rate limit check
282
+ if (!this.rateLimiter.canHeal()) {
283
+ console.log(chalk.yellow(" ⚠️ Heal rate limit reached"));
284
+ return;
285
+ }
286
+
287
+ // Loop guard check
288
+ const errorSig = errorText.slice(0, 200);
289
+ if (this.loopGuard.isLooping(errorSig)) {
290
+ console.log(chalk.red(" 🔁 Loop detected — same error failed too many times. Stopping."));
291
+ this.loopGuard.generateBugReport(errorSig, errorText);
292
+ return;
293
+ }
294
+
295
+ this._healInProgress = true;
296
+
297
+ try {
298
+ console.log(chalk.blue("\n 🩺 Healing claw..."));
299
+
300
+ // Create backup before healing
301
+ try {
302
+ this._lastBackupId = this.backupManager.createBackup("claw-pre-heal");
303
+ } catch {}
304
+
305
+ // Run wolverine heal pipeline
306
+ const healTimeoutMs = this.clawConfig.healing?.healTimeoutMs || 300000;
307
+ const result = await Promise.race([
308
+ heal({
309
+ stderr: errorText,
310
+ scriptPath: this.scriptPath,
311
+ cwd: this.cwd,
312
+ brain: this.brain,
313
+ rateLimiter: this.rateLimiter,
314
+ backupManager: this.backupManager,
315
+ logger: this.logger,
316
+ sandbox: { isAllowed: (p) => !p.includes("node_modules") && !p.includes("src/") },
317
+ redactor: this.redactor,
318
+ loopGuard: this.loopGuard,
319
+ }),
320
+ new Promise((_, reject) =>
321
+ setTimeout(() => reject(new Error("Heal timeout")), healTimeoutMs)
322
+ ),
323
+ ]);
324
+
325
+ if (result?.success) {
326
+ console.log(chalk.green(" ✅ Heal succeeded!"));
327
+ this.retryCount = 0;
328
+ } else {
329
+ console.log(chalk.yellow(` ⚠️ Heal did not succeed: ${result?.reason || "unknown"}`));
330
+
331
+ // Rollback
332
+ if (this._lastBackupId) {
333
+ try {
334
+ this.backupManager.rollback(this._lastBackupId);
335
+ console.log(chalk.yellow(` ↩️ Rolled back`));
336
+ } catch {}
337
+ }
338
+ }
339
+ } catch (err) {
340
+ console.log(chalk.red(` ❌ Heal failed: ${err.message}`));
341
+ } finally {
342
+ this._healInProgress = false;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Handle health status reports from the claw process.
348
+ */
349
+ _handleHealthReport(msg) {
350
+ if (msg.status === "error" || msg.status === "crashed") {
351
+ console.log(chalk.yellow(` ⚠️ Claw health: ${msg.status} — ${msg.detail || "unknown"}`));
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Probe the gateway WebSocket port to check if it's alive.
357
+ */
358
+ _startGatewayProbe() {
359
+ const port = this.clawConfig.gateway?.port || 18789;
360
+ const host = this.clawConfig.gateway?.host || "127.0.0.1";
361
+ const probeInterval = 30000; // every 30s
362
+
363
+ // Wait 10s for gateway to start before probing
364
+ this._gatewayProbeTimer = setTimeout(() => {
365
+ this._gatewayProbeTimer = setInterval(() => {
366
+ if (!this.running || !this.child) return;
367
+
368
+ const socket = new net.Socket();
369
+ socket.setTimeout(5000);
370
+
371
+ socket.on("connect", () => {
372
+ socket.destroy();
373
+ // Gateway is alive
374
+ });
375
+
376
+ socket.on("error", () => {
377
+ socket.destroy();
378
+ console.log(chalk.yellow(` ⚠️ Gateway probe failed (port ${port})`));
379
+ });
380
+
381
+ socket.on("timeout", () => {
382
+ socket.destroy();
383
+ console.log(chalk.yellow(` ⚠️ Gateway probe timeout (port ${port})`));
384
+ });
385
+
386
+ socket.connect(port, host);
387
+ }, probeInterval);
388
+ }, 10000);
389
+ }
390
+
391
+ /**
392
+ * Stop the claw process and all monitors.
393
+ */
394
+ stop() {
395
+ this.running = false;
396
+
397
+ if (this._gatewayProbeTimer) {
398
+ clearTimeout(this._gatewayProbeTimer);
399
+ clearInterval(this._gatewayProbeTimer);
400
+ }
401
+
402
+ if (this.child) {
403
+ try {
404
+ this.child.kill("SIGTERM");
405
+ } catch {}
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Restart the claw process.
411
+ */
412
+ restart() {
413
+ this.stop();
414
+ setTimeout(() => {
415
+ this.running = true;
416
+ this.retryCount = 0;
417
+ this._spawn();
418
+ }, 2000);
419
+ }
420
+ }
421
+
422
+ module.exports = { ClawRunner };