@elvatis_com/openclaw-self-healing-elvatis 0.2.4

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,1403 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ nowSec,
7
+ loadState,
8
+ saveState,
9
+ type State,
10
+ } from "../index.js";
11
+ import register from "../index.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function tmpDir(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), "self-heal-integ-"));
19
+ }
20
+
21
+ function emptyState(): State {
22
+ return {
23
+ limited: {},
24
+ pendingBackups: {},
25
+ whatsapp: {},
26
+ cron: { failCounts: {}, lastIssueCreatedAt: {} },
27
+ plugins: { lastDisableAt: {} },
28
+ };
29
+ }
30
+
31
+ function mockApi(overrides: Record<string, any> = {}) {
32
+ const handlers: Record<string, Function[]> = {};
33
+ const services: any[] = [];
34
+ const emitted: { event: string; payload: any }[] = [];
35
+
36
+ return {
37
+ pluginConfig: overrides.pluginConfig ?? {},
38
+ logger: {
39
+ info: vi.fn(),
40
+ warn: vi.fn(),
41
+ error: vi.fn(),
42
+ },
43
+ on(event: string, handler: Function) {
44
+ handlers[event] = handlers[event] || [];
45
+ handlers[event].push(handler);
46
+ },
47
+ emit(event: string, payload: any) {
48
+ emitted.push({ event, payload });
49
+ },
50
+ registerService(svc: any) {
51
+ services.push(svc);
52
+ },
53
+ runtime: {
54
+ system: {
55
+ runCommandWithTimeout: vi.fn().mockResolvedValue({
56
+ exitCode: 1,
57
+ stdout: "",
58
+ stderr: "not available",
59
+ }),
60
+ },
61
+ },
62
+ // test helpers
63
+ _handlers: handlers,
64
+ _services: services,
65
+ _emitted: emitted,
66
+ _emit(event: string, ...args: any[]) {
67
+ for (const h of handlers[event] ?? []) h(...args);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Start the monitor service, wait for the initial tick, then stop. */
73
+ async function runOneTick(api: ReturnType<typeof mockApi>) {
74
+ const svc = api._services[0];
75
+ await svc.start();
76
+ await new Promise((r) => setTimeout(r, 50));
77
+ await svc.stop();
78
+ }
79
+
80
+ /** Build a command-matching predicate for runCommandWithTimeout call args. */
81
+ function cmdContains(call: any[], fragment: string): boolean {
82
+ const cmd = call[0]?.command?.join(" ") ?? "";
83
+ return cmd.includes(fragment);
84
+ }
85
+
86
+ /** Filter all runCommandWithTimeout calls by command fragment. */
87
+ function filterCmdCalls(api: ReturnType<typeof mockApi>, fragment: string): any[][] {
88
+ return api.runtime.system.runCommandWithTimeout.mock.calls.filter(
89
+ (c: any[]) => cmdContains(c, fragment)
90
+ );
91
+ }
92
+
93
+ /** Find emitted events by name. */
94
+ function findEmitted(api: ReturnType<typeof mockApi>, eventName: string) {
95
+ return api._emitted.filter((e) => e.event === eventName);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Monitor Integration Tests - Full Tick Cycle
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("monitor service - integration tick flows", () => {
103
+ let dir: string;
104
+ let stateFile: string;
105
+ let sessionsFile: string;
106
+ let configFile: string;
107
+ let backupsDir: string;
108
+
109
+ beforeEach(() => {
110
+ dir = tmpDir();
111
+ stateFile = path.join(dir, "state.json");
112
+ sessionsFile = path.join(dir, "sessions.json");
113
+ configFile = path.join(dir, "openclaw.json");
114
+ backupsDir = path.join(dir, "backups");
115
+ fs.writeFileSync(configFile, JSON.stringify({ valid: true }));
116
+ });
117
+
118
+ afterEach(() => {
119
+ fs.rmSync(dir, { recursive: true, force: true });
120
+ });
121
+
122
+ // -------------------------------------------------------------------------
123
+ // WhatsApp disconnect streak -> restart path
124
+ // -------------------------------------------------------------------------
125
+
126
+ describe("WhatsApp disconnect streak -> restart path", () => {
127
+ it("increments disconnect streak on each tick with disconnected status", async () => {
128
+ const api = mockApi({
129
+ pluginConfig: {
130
+ stateFile,
131
+ sessionsFile,
132
+ configFile,
133
+ configBackupsDir: backupsDir,
134
+ modelOrder: ["model-a"],
135
+ autoFix: { restartWhatsappOnDisconnect: true, whatsappDisconnectThreshold: 5 },
136
+ },
137
+ });
138
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
139
+ exitCode: 0,
140
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
141
+ stderr: "",
142
+ });
143
+ register(api);
144
+
145
+ await runOneTick(api);
146
+
147
+ const state = loadState(stateFile);
148
+ expect(state.whatsapp!.disconnectStreak).toBe(1);
149
+ });
150
+
151
+ it("resets disconnect streak when WhatsApp is connected", async () => {
152
+ saveState(stateFile, {
153
+ ...emptyState(),
154
+ whatsapp: { disconnectStreak: 3, lastRestartAt: nowSec() - 1000 },
155
+ });
156
+
157
+ const api = mockApi({
158
+ pluginConfig: {
159
+ stateFile,
160
+ sessionsFile,
161
+ configFile,
162
+ configBackupsDir: backupsDir,
163
+ modelOrder: ["model-a"],
164
+ },
165
+ });
166
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
167
+ exitCode: 0,
168
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "connected" } } }),
169
+ stderr: "",
170
+ });
171
+ register(api);
172
+
173
+ await runOneTick(api);
174
+
175
+ const state = loadState(stateFile);
176
+ expect(state.whatsapp!.disconnectStreak).toBe(0);
177
+ expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
178
+ });
179
+
180
+ it("triggers gateway restart when disconnect streak reaches threshold", async () => {
181
+ // Pre-seed state with streak at threshold - 1 so next tick triggers restart
182
+ saveState(stateFile, {
183
+ ...emptyState(),
184
+ whatsapp: { disconnectStreak: 1, lastRestartAt: 0 },
185
+ });
186
+
187
+ const api = mockApi({
188
+ pluginConfig: {
189
+ stateFile,
190
+ sessionsFile,
191
+ configFile,
192
+ configBackupsDir: backupsDir,
193
+ modelOrder: ["model-a"],
194
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
195
+ },
196
+ });
197
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
198
+ const cmd = opts?.command?.join(" ") ?? "";
199
+ if (cmd.includes("channels status")) {
200
+ return {
201
+ exitCode: 0,
202
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
203
+ stderr: "",
204
+ };
205
+ }
206
+ if (cmd.includes("gateway restart")) {
207
+ return { exitCode: 0, stdout: "restarted", stderr: "" };
208
+ }
209
+ if (cmd.includes("gateway status")) {
210
+ return { exitCode: 0, stdout: "ok", stderr: "" };
211
+ }
212
+ return { exitCode: 1, stdout: "", stderr: "unknown" };
213
+ });
214
+ register(api);
215
+
216
+ await runOneTick(api);
217
+
218
+ // Verify gateway restart was called
219
+ const restartCalls = filterCmdCalls(api, "gateway restart");
220
+ expect(restartCalls.length).toBeGreaterThanOrEqual(1);
221
+
222
+ // Verify state was reset
223
+ const state = loadState(stateFile);
224
+ expect(state.whatsapp!.disconnectStreak).toBe(0);
225
+ expect(state.whatsapp!.lastRestartAt).toBeGreaterThan(0);
226
+
227
+ // Verify event emitted
228
+ const events = findEmitted(api, "self-heal:whatsapp-restart");
229
+ expect(events).toHaveLength(1);
230
+ expect(events[0].payload.dryRun).toBe(false);
231
+ });
232
+
233
+ it("does not restart if minimum restart interval has not elapsed", async () => {
234
+ saveState(stateFile, {
235
+ ...emptyState(),
236
+ whatsapp: { disconnectStreak: 5, lastRestartAt: nowSec() - 10 }, // restarted 10s ago
237
+ });
238
+
239
+ const api = mockApi({
240
+ pluginConfig: {
241
+ stateFile,
242
+ sessionsFile,
243
+ configFile,
244
+ configBackupsDir: backupsDir,
245
+ modelOrder: ["model-a"],
246
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 300 },
247
+ },
248
+ });
249
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
250
+ exitCode: 0,
251
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
252
+ stderr: "",
253
+ });
254
+ register(api);
255
+
256
+ await runOneTick(api);
257
+
258
+ // Should NOT have restarted - interval not elapsed
259
+ const restartCalls = filterCmdCalls(api, "gateway restart");
260
+ expect(restartCalls).toHaveLength(0);
261
+
262
+ // Streak should still be incremented
263
+ const state = loadState(stateFile);
264
+ expect(state.whatsapp!.disconnectStreak).toBe(6);
265
+ });
266
+
267
+ it("does not restart if openclaw.json config is invalid", async () => {
268
+ // Make the config file invalid
269
+ fs.writeFileSync(configFile, "NOT VALID JSON{{{");
270
+ saveState(stateFile, {
271
+ ...emptyState(),
272
+ whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
273
+ });
274
+
275
+ const api = mockApi({
276
+ pluginConfig: {
277
+ stateFile,
278
+ sessionsFile,
279
+ configFile,
280
+ configBackupsDir: backupsDir,
281
+ modelOrder: ["model-a"],
282
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
283
+ },
284
+ });
285
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
286
+ exitCode: 0,
287
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
288
+ stderr: "",
289
+ });
290
+ register(api);
291
+
292
+ await runOneTick(api);
293
+
294
+ // Should NOT have restarted
295
+ const restartCalls = filterCmdCalls(api, "gateway restart");
296
+ expect(restartCalls).toHaveLength(0);
297
+
298
+ // Should log error about invalid config
299
+ expect(api.logger.error).toHaveBeenCalledWith(
300
+ expect.stringContaining("NOT restarting gateway: openclaw.json invalid")
301
+ );
302
+ });
303
+
304
+ it("backs up config before gateway restart and cleans up after", async () => {
305
+ saveState(stateFile, {
306
+ ...emptyState(),
307
+ whatsapp: { disconnectStreak: 1, lastRestartAt: 0 },
308
+ });
309
+
310
+ const commandLog: string[] = [];
311
+ const api = mockApi({
312
+ pluginConfig: {
313
+ stateFile,
314
+ sessionsFile,
315
+ configFile,
316
+ configBackupsDir: backupsDir,
317
+ modelOrder: ["model-a"],
318
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
319
+ },
320
+ });
321
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
322
+ const cmd = opts?.command?.join(" ") ?? "";
323
+ commandLog.push(cmd);
324
+ if (cmd.includes("channels status")) {
325
+ return {
326
+ exitCode: 0,
327
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
328
+ stderr: "",
329
+ };
330
+ }
331
+ if (cmd.includes("gateway restart")) {
332
+ return { exitCode: 0, stdout: "restarted", stderr: "" };
333
+ }
334
+ if (cmd.includes("gateway status")) {
335
+ return { exitCode: 0, stdout: "ok", stderr: "" };
336
+ }
337
+ return { exitCode: 1, stdout: "", stderr: "" };
338
+ });
339
+ register(api);
340
+
341
+ await runOneTick(api);
342
+
343
+ // Verify backup was created
344
+ expect(api.logger.info).toHaveBeenCalledWith(
345
+ expect.stringContaining("backed up openclaw.json (pre-gateway-restart)")
346
+ );
347
+
348
+ // Verify restart happened after backup
349
+ const restartIdx = commandLog.findIndex((c) => c.includes("gateway restart"));
350
+ expect(restartIdx).toBeGreaterThan(-1);
351
+ });
352
+ });
353
+
354
+ // -------------------------------------------------------------------------
355
+ // Cron failure accumulation -> disable + issue create path
356
+ // -------------------------------------------------------------------------
357
+
358
+ describe("cron failure accumulation -> disable + issue create path", () => {
359
+ it("accumulates cron failure counts across ticks", async () => {
360
+ const api = mockApi({
361
+ pluginConfig: {
362
+ stateFile,
363
+ sessionsFile,
364
+ configFile,
365
+ configBackupsDir: backupsDir,
366
+ modelOrder: ["model-a"],
367
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 5 },
368
+ },
369
+ });
370
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
371
+ exitCode: 0,
372
+ stdout: JSON.stringify({
373
+ jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "error", lastError: "timeout" } }],
374
+ }),
375
+ stderr: "",
376
+ });
377
+ register(api);
378
+
379
+ // Run two ticks
380
+ await runOneTick(api);
381
+ let state = loadState(stateFile);
382
+ expect(state.cron!.failCounts!["cron-1"]).toBe(1);
383
+
384
+ // Second tick: re-register to get fresh service registration
385
+ const api2 = mockApi({
386
+ pluginConfig: {
387
+ stateFile,
388
+ sessionsFile,
389
+ configFile,
390
+ configBackupsDir: backupsDir,
391
+ modelOrder: ["model-a"],
392
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 5 },
393
+ },
394
+ });
395
+ api2.runtime.system.runCommandWithTimeout.mockResolvedValue({
396
+ exitCode: 0,
397
+ stdout: JSON.stringify({
398
+ jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "error", lastError: "timeout" } }],
399
+ }),
400
+ stderr: "",
401
+ });
402
+ register(api2);
403
+
404
+ await runOneTick(api2);
405
+ state = loadState(stateFile);
406
+ expect(state.cron!.failCounts!["cron-1"]).toBe(2);
407
+ });
408
+
409
+ it("resets fail count when cron job succeeds", async () => {
410
+ saveState(stateFile, {
411
+ ...emptyState(),
412
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
413
+ });
414
+
415
+ const api = mockApi({
416
+ pluginConfig: {
417
+ stateFile,
418
+ sessionsFile,
419
+ configFile,
420
+ configBackupsDir: backupsDir,
421
+ modelOrder: ["model-a"],
422
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
423
+ },
424
+ });
425
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
426
+ exitCode: 0,
427
+ stdout: JSON.stringify({
428
+ jobs: [{ id: "cron-1", name: "daily-report", state: { lastStatus: "ok" } }],
429
+ }),
430
+ stderr: "",
431
+ });
432
+ register(api);
433
+
434
+ await runOneTick(api);
435
+
436
+ const state = loadState(stateFile);
437
+ expect(state.cron!.failCounts!["cron-1"]).toBe(0);
438
+ });
439
+
440
+ it("disables cron and creates issue when failure threshold is reached", async () => {
441
+ saveState(stateFile, {
442
+ ...emptyState(),
443
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
444
+ });
445
+
446
+ const api = mockApi({
447
+ pluginConfig: {
448
+ stateFile,
449
+ sessionsFile,
450
+ configFile,
451
+ configBackupsDir: backupsDir,
452
+ modelOrder: ["model-a"],
453
+ autoFix: {
454
+ disableFailingCrons: true,
455
+ cronFailThreshold: 3,
456
+ issueCooldownSec: 0,
457
+ issueRepo: "elvatis/test-repo",
458
+ },
459
+ },
460
+ });
461
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
462
+ const cmd = opts?.command?.join(" ") ?? "";
463
+ if (cmd.includes("cron list")) {
464
+ return {
465
+ exitCode: 0,
466
+ stdout: JSON.stringify({
467
+ jobs: [{
468
+ id: "cron-1",
469
+ name: "daily-report",
470
+ state: { lastStatus: "error", lastError: "Connection refused" },
471
+ }],
472
+ }),
473
+ stderr: "",
474
+ };
475
+ }
476
+ if (cmd.includes("gateway status")) {
477
+ return { exitCode: 0, stdout: "ok", stderr: "" };
478
+ }
479
+ return { exitCode: 0, stdout: "", stderr: "" };
480
+ });
481
+ register(api);
482
+
483
+ await runOneTick(api);
484
+
485
+ // Verify cron was disabled
486
+ const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
487
+ expect(disableCalls).toHaveLength(1);
488
+
489
+ // Verify issue was created
490
+ const issueCalls = filterCmdCalls(api, "gh issue create");
491
+ expect(issueCalls).toHaveLength(1);
492
+
493
+ // Verify event emitted
494
+ const events = findEmitted(api, "self-heal:cron-disabled");
495
+ expect(events).toHaveLength(1);
496
+ expect(events[0].payload.cronId).toBe("cron-1");
497
+ expect(events[0].payload.cronName).toBe("daily-report");
498
+ expect(events[0].payload.consecutiveFailures).toBe(3);
499
+
500
+ // Verify fail count reset after disable
501
+ const state = loadState(stateFile);
502
+ expect(state.cron!.failCounts!["cron-1"]).toBe(0);
503
+ });
504
+
505
+ it("rate-limits issue creation via issueCooldownSec", async () => {
506
+ saveState(stateFile, {
507
+ ...emptyState(),
508
+ cron: {
509
+ failCounts: { "cron-1": 2 },
510
+ lastIssueCreatedAt: { "cron-1": nowSec() - 100 }, // created 100s ago
511
+ },
512
+ });
513
+
514
+ const api = mockApi({
515
+ pluginConfig: {
516
+ stateFile,
517
+ sessionsFile,
518
+ configFile,
519
+ configBackupsDir: backupsDir,
520
+ modelOrder: ["model-a"],
521
+ autoFix: {
522
+ disableFailingCrons: true,
523
+ cronFailThreshold: 3,
524
+ issueCooldownSec: 3600, // 1 hour cooldown
525
+ issueRepo: "elvatis/test-repo",
526
+ },
527
+ },
528
+ });
529
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
530
+ const cmd = opts?.command?.join(" ") ?? "";
531
+ if (cmd.includes("cron list")) {
532
+ return {
533
+ exitCode: 0,
534
+ stdout: JSON.stringify({
535
+ jobs: [{
536
+ id: "cron-1",
537
+ name: "daily-report",
538
+ state: { lastStatus: "error", lastError: "timeout" },
539
+ }],
540
+ }),
541
+ stderr: "",
542
+ };
543
+ }
544
+ if (cmd.includes("gateway status")) {
545
+ return { exitCode: 0, stdout: "ok", stderr: "" };
546
+ }
547
+ return { exitCode: 0, stdout: "", stderr: "" };
548
+ });
549
+ register(api);
550
+
551
+ await runOneTick(api);
552
+
553
+ // Cron should still be disabled
554
+ const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
555
+ expect(disableCalls).toHaveLength(1);
556
+
557
+ // But issue should NOT be created (cooldown not elapsed)
558
+ const issueCalls = filterCmdCalls(api, "gh issue create");
559
+ expect(issueCalls).toHaveLength(0);
560
+ });
561
+
562
+ it("does not disable cron if config file is invalid", async () => {
563
+ fs.writeFileSync(configFile, "INVALID JSON!!!");
564
+ saveState(stateFile, {
565
+ ...emptyState(),
566
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
567
+ });
568
+
569
+ const api = mockApi({
570
+ pluginConfig: {
571
+ stateFile,
572
+ sessionsFile,
573
+ configFile,
574
+ configBackupsDir: backupsDir,
575
+ modelOrder: ["model-a"],
576
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
577
+ },
578
+ });
579
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
580
+ exitCode: 0,
581
+ stdout: JSON.stringify({
582
+ jobs: [{
583
+ id: "cron-1",
584
+ name: "daily-report",
585
+ state: { lastStatus: "error", lastError: "fail" },
586
+ }],
587
+ }),
588
+ stderr: "",
589
+ });
590
+ register(api);
591
+
592
+ await runOneTick(api);
593
+
594
+ // Should NOT have disabled cron
595
+ const disableCalls = filterCmdCalls(api, "cron edit");
596
+ expect(disableCalls).toHaveLength(0);
597
+
598
+ // Should log error
599
+ expect(api.logger.error).toHaveBeenCalledWith(
600
+ expect.stringContaining("NOT disabling cron: openclaw.json invalid")
601
+ );
602
+ });
603
+
604
+ it("tracks multiple cron jobs independently", async () => {
605
+ const api = mockApi({
606
+ pluginConfig: {
607
+ stateFile,
608
+ sessionsFile,
609
+ configFile,
610
+ configBackupsDir: backupsDir,
611
+ modelOrder: ["model-a"],
612
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
613
+ },
614
+ });
615
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
616
+ exitCode: 0,
617
+ stdout: JSON.stringify({
618
+ jobs: [
619
+ { id: "cron-1", name: "report", state: { lastStatus: "error", lastError: "fail" } },
620
+ { id: "cron-2", name: "backup", state: { lastStatus: "ok" } },
621
+ { id: "cron-3", name: "cleanup", state: { lastStatus: "error", lastError: "disk full" } },
622
+ ],
623
+ }),
624
+ stderr: "",
625
+ });
626
+ register(api);
627
+
628
+ await runOneTick(api);
629
+
630
+ const state = loadState(stateFile);
631
+ expect(state.cron!.failCounts!["cron-1"]).toBe(1);
632
+ expect(state.cron!.failCounts!["cron-2"]).toBe(0);
633
+ expect(state.cron!.failCounts!["cron-3"]).toBe(1);
634
+ });
635
+ });
636
+
637
+ // -------------------------------------------------------------------------
638
+ // Active model recovery probe -> cooldown removal path
639
+ // -------------------------------------------------------------------------
640
+
641
+ describe("active model recovery probe -> cooldown removal path", () => {
642
+ it("removes model from cooldown when probe succeeds during tick", async () => {
643
+ const hitAt = nowSec() - 400;
644
+ saveState(stateFile, {
645
+ ...emptyState(),
646
+ limited: {
647
+ "model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
648
+ },
649
+ });
650
+
651
+ const api = mockApi({
652
+ pluginConfig: {
653
+ stateFile,
654
+ sessionsFile,
655
+ configFile,
656
+ configBackupsDir: backupsDir,
657
+ modelOrder: ["model-a", "model-b"],
658
+ probeEnabled: true,
659
+ probeIntervalSec: 300,
660
+ },
661
+ });
662
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
663
+ const cmd = opts?.command?.join(" ") ?? "";
664
+ if (cmd.includes("model probe")) {
665
+ return { exitCode: 0, stdout: "ok", stderr: "" };
666
+ }
667
+ return { exitCode: 1, stdout: "", stderr: "" };
668
+ });
669
+ register(api);
670
+
671
+ await runOneTick(api);
672
+
673
+ const state = loadState(stateFile);
674
+ expect(state.limited["model-a"]).toBeUndefined();
675
+
676
+ const events = findEmitted(api, "self-heal:model-recovered");
677
+ expect(events).toHaveLength(1);
678
+ expect(events[0].payload.model).toBe("model-a");
679
+ expect(events[0].payload.isPreferred).toBe(true);
680
+ });
681
+
682
+ it("updates lastProbeAt but keeps cooldown when probe fails", async () => {
683
+ const hitAt = nowSec() - 400;
684
+ saveState(stateFile, {
685
+ ...emptyState(),
686
+ limited: {
687
+ "model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
688
+ },
689
+ });
690
+
691
+ const api = mockApi({
692
+ pluginConfig: {
693
+ stateFile,
694
+ sessionsFile,
695
+ configFile,
696
+ configBackupsDir: backupsDir,
697
+ modelOrder: ["model-a", "model-b"],
698
+ probeEnabled: true,
699
+ probeIntervalSec: 300,
700
+ },
701
+ });
702
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
703
+ const cmd = opts?.command?.join(" ") ?? "";
704
+ if (cmd.includes("model probe")) {
705
+ return { exitCode: 1, stdout: "", stderr: "still limited" };
706
+ }
707
+ return { exitCode: 1, stdout: "", stderr: "" };
708
+ });
709
+ register(api);
710
+
711
+ await runOneTick(api);
712
+
713
+ const state = loadState(stateFile);
714
+ expect(state.limited["model-a"]).toBeDefined();
715
+ expect(state.limited["model-a"].lastProbeAt).toBeGreaterThan(0);
716
+
717
+ const events = findEmitted(api, "self-heal:model-recovered");
718
+ expect(events).toHaveLength(0);
719
+ });
720
+
721
+ it("sets isPreferred=false for non-primary model recovery", async () => {
722
+ const hitAt = nowSec() - 400;
723
+ saveState(stateFile, {
724
+ ...emptyState(),
725
+ limited: {
726
+ "model-b": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
727
+ },
728
+ });
729
+
730
+ const api = mockApi({
731
+ pluginConfig: {
732
+ stateFile,
733
+ sessionsFile,
734
+ configFile,
735
+ configBackupsDir: backupsDir,
736
+ modelOrder: ["model-a", "model-b"],
737
+ probeEnabled: true,
738
+ probeIntervalSec: 300,
739
+ },
740
+ });
741
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
742
+ const cmd = opts?.command?.join(" ") ?? "";
743
+ if (cmd.includes("model probe")) {
744
+ return { exitCode: 0, stdout: "ok", stderr: "" };
745
+ }
746
+ return { exitCode: 1, stdout: "", stderr: "" };
747
+ });
748
+ register(api);
749
+
750
+ await runOneTick(api);
751
+
752
+ const events = findEmitted(api, "self-heal:model-recovered");
753
+ expect(events).toHaveLength(1);
754
+ expect(events[0].payload.isPreferred).toBe(false);
755
+ });
756
+ });
757
+
758
+ // -------------------------------------------------------------------------
759
+ // Config hot-reload during tick
760
+ // -------------------------------------------------------------------------
761
+
762
+ describe("config hot-reload during tick", () => {
763
+ it("applies new whatsappDisconnectThreshold from reloaded config", async () => {
764
+ saveState(stateFile, {
765
+ ...emptyState(),
766
+ whatsapp: { disconnectStreak: 2, lastRestartAt: 0 },
767
+ });
768
+
769
+ const api = mockApi({
770
+ pluginConfig: {
771
+ stateFile,
772
+ sessionsFile,
773
+ configFile,
774
+ configBackupsDir: backupsDir,
775
+ modelOrder: ["model-a"],
776
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
777
+ },
778
+ });
779
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
780
+ exitCode: 0,
781
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
782
+ stderr: "",
783
+ });
784
+ register(api);
785
+
786
+ // Raise threshold before tick so restart should NOT happen
787
+ api.pluginConfig = {
788
+ stateFile,
789
+ sessionsFile,
790
+ configFile,
791
+ configBackupsDir: backupsDir,
792
+ modelOrder: ["model-a"],
793
+ autoFix: { whatsappDisconnectThreshold: 10, whatsappMinRestartIntervalSec: 60 },
794
+ };
795
+
796
+ await runOneTick(api);
797
+
798
+ // Should NOT restart since threshold increased to 10
799
+ const restartCalls = filterCmdCalls(api, "gateway restart");
800
+ expect(restartCalls).toHaveLength(0);
801
+ });
802
+
803
+ it("applies new cronFailThreshold from reloaded config", async () => {
804
+ saveState(stateFile, {
805
+ ...emptyState(),
806
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
807
+ });
808
+
809
+ const api = mockApi({
810
+ pluginConfig: {
811
+ stateFile,
812
+ sessionsFile,
813
+ configFile,
814
+ configBackupsDir: backupsDir,
815
+ modelOrder: ["model-a"],
816
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 3 },
817
+ },
818
+ });
819
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
820
+ exitCode: 0,
821
+ stdout: JSON.stringify({
822
+ jobs: [{ id: "cron-1", name: "report", state: { lastStatus: "error", lastError: "fail" } }],
823
+ }),
824
+ stderr: "",
825
+ });
826
+ register(api);
827
+
828
+ // Raise threshold so cron should NOT be disabled
829
+ api.pluginConfig = {
830
+ stateFile,
831
+ sessionsFile,
832
+ configFile,
833
+ configBackupsDir: backupsDir,
834
+ modelOrder: ["model-a"],
835
+ autoFix: { disableFailingCrons: true, cronFailThreshold: 10 },
836
+ };
837
+
838
+ await runOneTick(api);
839
+
840
+ const disableCalls = filterCmdCalls(api, "cron edit");
841
+ expect(disableCalls).toHaveLength(0);
842
+
843
+ // Fail count should still increment
844
+ const state = loadState(stateFile);
845
+ expect(state.cron!.failCounts!["cron-1"]).toBe(3);
846
+ });
847
+
848
+ it("applies new dryRun flag from reloaded config mid-session", async () => {
849
+ saveState(stateFile, {
850
+ ...emptyState(),
851
+ whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
852
+ });
853
+
854
+ const api = mockApi({
855
+ pluginConfig: {
856
+ stateFile,
857
+ sessionsFile,
858
+ configFile,
859
+ configBackupsDir: backupsDir,
860
+ modelOrder: ["model-a"],
861
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
862
+ },
863
+ });
864
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
865
+ exitCode: 0,
866
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
867
+ stderr: "",
868
+ });
869
+ register(api);
870
+
871
+ // Switch to dry-run before tick
872
+ api.pluginConfig = {
873
+ stateFile,
874
+ sessionsFile,
875
+ configFile,
876
+ configBackupsDir: backupsDir,
877
+ modelOrder: ["model-a"],
878
+ dryRun: true,
879
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
880
+ };
881
+
882
+ await runOneTick(api);
883
+
884
+ // Should NOT call actual restart (dry-run)
885
+ const restartCalls = filterCmdCalls(api, "gateway restart");
886
+ expect(restartCalls).toHaveLength(0);
887
+
888
+ // Should log dry-run message
889
+ expect(api.logger.info).toHaveBeenCalledWith(
890
+ expect.stringContaining("[dry-run] would restart gateway")
891
+ );
892
+ });
893
+ });
894
+
895
+ // -------------------------------------------------------------------------
896
+ // Dry-run flag suppresses all side-effects
897
+ // -------------------------------------------------------------------------
898
+
899
+ describe("dry-run flag suppresses all side-effects", () => {
900
+ it("does not restart gateway in dry-run mode", async () => {
901
+ saveState(stateFile, {
902
+ ...emptyState(),
903
+ whatsapp: { disconnectStreak: 5, lastRestartAt: 0 },
904
+ });
905
+
906
+ const api = mockApi({
907
+ pluginConfig: {
908
+ stateFile,
909
+ sessionsFile,
910
+ configFile,
911
+ configBackupsDir: backupsDir,
912
+ modelOrder: ["model-a"],
913
+ dryRun: true,
914
+ autoFix: { whatsappDisconnectThreshold: 2, whatsappMinRestartIntervalSec: 60 },
915
+ },
916
+ });
917
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
918
+ exitCode: 0,
919
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "disconnected" } } }),
920
+ stderr: "",
921
+ });
922
+ register(api);
923
+
924
+ await runOneTick(api);
925
+
926
+ const restartCalls = filterCmdCalls(api, "gateway restart");
927
+ expect(restartCalls).toHaveLength(0);
928
+
929
+ // But state should still be updated
930
+ const state = loadState(stateFile);
931
+ expect(state.whatsapp!.disconnectStreak).toBe(0);
932
+ expect(state.whatsapp!.lastRestartAt).toBeGreaterThan(0);
933
+
934
+ // dry-run event should still be emitted
935
+ const events = findEmitted(api, "self-heal:whatsapp-restart");
936
+ expect(events).toHaveLength(1);
937
+ expect(events[0].payload.dryRun).toBe(true);
938
+ });
939
+
940
+ it("does not disable cron in dry-run mode", async () => {
941
+ saveState(stateFile, {
942
+ ...emptyState(),
943
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
944
+ });
945
+
946
+ const api = mockApi({
947
+ pluginConfig: {
948
+ stateFile,
949
+ sessionsFile,
950
+ configFile,
951
+ configBackupsDir: backupsDir,
952
+ modelOrder: ["model-a"],
953
+ dryRun: true,
954
+ autoFix: {
955
+ disableFailingCrons: true,
956
+ cronFailThreshold: 3,
957
+ issueCooldownSec: 0,
958
+ },
959
+ },
960
+ });
961
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
962
+ exitCode: 0,
963
+ stdout: JSON.stringify({
964
+ jobs: [{
965
+ id: "cron-1",
966
+ name: "daily-report",
967
+ state: { lastStatus: "error", lastError: "timeout" },
968
+ }],
969
+ }),
970
+ stderr: "",
971
+ });
972
+ register(api);
973
+
974
+ await runOneTick(api);
975
+
976
+ // Should NOT call cron edit or gh issue create
977
+ const disableCalls = filterCmdCalls(api, "cron edit");
978
+ expect(disableCalls).toHaveLength(0);
979
+ const issueCalls = filterCmdCalls(api, "gh issue create");
980
+ expect(issueCalls).toHaveLength(0);
981
+
982
+ // Should log dry-run messages
983
+ expect(api.logger.info).toHaveBeenCalledWith(
984
+ expect.stringContaining("[dry-run] would disable cron")
985
+ );
986
+ expect(api.logger.info).toHaveBeenCalledWith(
987
+ expect.stringContaining("[dry-run] would create GitHub issue")
988
+ );
989
+
990
+ // Should emit event with dryRun=true
991
+ const events = findEmitted(api, "self-heal:cron-disabled");
992
+ expect(events).toHaveLength(1);
993
+ expect(events[0].payload.dryRun).toBe(true);
994
+ });
995
+
996
+ it("does not probe models in dry-run mode", async () => {
997
+ const hitAt = nowSec() - 400;
998
+ saveState(stateFile, {
999
+ ...emptyState(),
1000
+ limited: {
1001
+ "model-a": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
1002
+ },
1003
+ });
1004
+
1005
+ const api = mockApi({
1006
+ pluginConfig: {
1007
+ stateFile,
1008
+ sessionsFile,
1009
+ configFile,
1010
+ configBackupsDir: backupsDir,
1011
+ modelOrder: ["model-a", "model-b"],
1012
+ dryRun: true,
1013
+ probeEnabled: true,
1014
+ probeIntervalSec: 300,
1015
+ },
1016
+ });
1017
+ register(api);
1018
+
1019
+ await runOneTick(api);
1020
+
1021
+ const probeCalls = filterCmdCalls(api, "model probe");
1022
+ expect(probeCalls).toHaveLength(0);
1023
+
1024
+ expect(api.logger.info).toHaveBeenCalledWith(
1025
+ expect.stringContaining("[dry-run] would probe model model-a")
1026
+ );
1027
+
1028
+ // Model should still be in cooldown
1029
+ const state = loadState(stateFile);
1030
+ expect(state.limited["model-a"]).toBeDefined();
1031
+ });
1032
+ });
1033
+
1034
+ // -------------------------------------------------------------------------
1035
+ // Status snapshot emission on every tick
1036
+ // -------------------------------------------------------------------------
1037
+
1038
+ describe("status snapshot on every tick", () => {
1039
+ it("emits self-heal:status event with correct health status", async () => {
1040
+ const api = mockApi({
1041
+ pluginConfig: {
1042
+ stateFile,
1043
+ sessionsFile,
1044
+ configFile,
1045
+ configBackupsDir: backupsDir,
1046
+ modelOrder: ["model-a", "model-b"],
1047
+ },
1048
+ });
1049
+ register(api);
1050
+
1051
+ await runOneTick(api);
1052
+
1053
+ const events = findEmitted(api, "self-heal:status");
1054
+ expect(events.length).toBeGreaterThanOrEqual(1);
1055
+ const snapshot = events[0].payload;
1056
+ expect(snapshot.health).toBe("healthy");
1057
+ expect(snapshot.activeModel).toBe("model-a");
1058
+ expect(snapshot.models).toHaveLength(2);
1059
+ expect(snapshot.generatedAt).toBeGreaterThan(0);
1060
+ });
1061
+
1062
+ it("emits degraded health when a model is in cooldown", async () => {
1063
+ saveState(stateFile, {
1064
+ ...emptyState(),
1065
+ limited: {
1066
+ "model-a": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
1067
+ },
1068
+ });
1069
+
1070
+ const api = mockApi({
1071
+ pluginConfig: {
1072
+ stateFile,
1073
+ sessionsFile,
1074
+ configFile,
1075
+ configBackupsDir: backupsDir,
1076
+ modelOrder: ["model-a", "model-b"],
1077
+ probeEnabled: false,
1078
+ },
1079
+ });
1080
+ register(api);
1081
+
1082
+ await runOneTick(api);
1083
+
1084
+ const events = findEmitted(api, "self-heal:status");
1085
+ expect(events.length).toBeGreaterThanOrEqual(1);
1086
+ expect(events[0].payload.health).toBe("degraded");
1087
+ expect(events[0].payload.activeModel).toBe("model-b");
1088
+ });
1089
+
1090
+ it("emits healing health when all models are in cooldown", async () => {
1091
+ saveState(stateFile, {
1092
+ ...emptyState(),
1093
+ limited: {
1094
+ "model-a": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
1095
+ "model-b": { lastHitAt: nowSec() - 10, nextAvailableAt: nowSec() + 9999 },
1096
+ },
1097
+ });
1098
+
1099
+ const api = mockApi({
1100
+ pluginConfig: {
1101
+ stateFile,
1102
+ sessionsFile,
1103
+ configFile,
1104
+ configBackupsDir: backupsDir,
1105
+ modelOrder: ["model-a", "model-b"],
1106
+ probeEnabled: false,
1107
+ },
1108
+ });
1109
+ register(api);
1110
+
1111
+ await runOneTick(api);
1112
+
1113
+ const events = findEmitted(api, "self-heal:status");
1114
+ expect(events.length).toBeGreaterThanOrEqual(1);
1115
+ expect(events[0].payload.health).toBe("healing");
1116
+ });
1117
+
1118
+ it("includes WhatsApp and cron status in snapshot", async () => {
1119
+ saveState(stateFile, {
1120
+ ...emptyState(),
1121
+ whatsapp: { disconnectStreak: 3, lastSeenConnectedAt: nowSec() - 60 },
1122
+ cron: { failCounts: { "c1": 2, "c2": 0 }, lastIssueCreatedAt: {} },
1123
+ });
1124
+
1125
+ const api = mockApi({
1126
+ pluginConfig: {
1127
+ stateFile,
1128
+ sessionsFile,
1129
+ configFile,
1130
+ configBackupsDir: backupsDir,
1131
+ modelOrder: ["model-a"],
1132
+ probeEnabled: false,
1133
+ },
1134
+ });
1135
+ register(api);
1136
+
1137
+ await runOneTick(api);
1138
+
1139
+ const events = findEmitted(api, "self-heal:status");
1140
+ const snapshot = events[0].payload;
1141
+ expect(snapshot.whatsapp.status).toBe("disconnected");
1142
+ expect(snapshot.whatsapp.disconnectStreak).toBe(3);
1143
+ expect(snapshot.cron.trackedJobs).toBe(2);
1144
+ expect(snapshot.cron.failingJobs).toHaveLength(1);
1145
+ expect(snapshot.cron.failingJobs[0].id).toBe("c1");
1146
+ });
1147
+ });
1148
+
1149
+ // -------------------------------------------------------------------------
1150
+ // Combined multi-domain tick
1151
+ // -------------------------------------------------------------------------
1152
+
1153
+ describe("combined multi-domain tick", () => {
1154
+ it("handles WhatsApp, cron, and probe healing in a single tick", async () => {
1155
+ const hitAt = nowSec() - 400;
1156
+ saveState(stateFile, {
1157
+ ...emptyState(),
1158
+ whatsapp: { disconnectStreak: 0 },
1159
+ cron: { failCounts: { "cron-1": 2 }, lastIssueCreatedAt: {} },
1160
+ limited: {
1161
+ "model-b": { lastHitAt: hitAt, nextAvailableAt: nowSec() + 9999 },
1162
+ },
1163
+ });
1164
+
1165
+ const api = mockApi({
1166
+ pluginConfig: {
1167
+ stateFile,
1168
+ sessionsFile,
1169
+ configFile,
1170
+ configBackupsDir: backupsDir,
1171
+ modelOrder: ["model-a", "model-b"],
1172
+ probeEnabled: true,
1173
+ probeIntervalSec: 300,
1174
+ autoFix: {
1175
+ disableFailingCrons: true,
1176
+ cronFailThreshold: 3,
1177
+ issueCooldownSec: 0,
1178
+ issueRepo: "elvatis/test-repo",
1179
+ whatsappDisconnectThreshold: 5,
1180
+ },
1181
+ },
1182
+ });
1183
+ api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
1184
+ const cmd = opts?.command?.join(" ") ?? "";
1185
+ if (cmd.includes("channels status")) {
1186
+ return {
1187
+ exitCode: 0,
1188
+ stdout: JSON.stringify({ channels: { whatsapp: { status: "connected" } } }),
1189
+ stderr: "",
1190
+ };
1191
+ }
1192
+ if (cmd.includes("cron list")) {
1193
+ return {
1194
+ exitCode: 0,
1195
+ stdout: JSON.stringify({
1196
+ jobs: [{
1197
+ id: "cron-1",
1198
+ name: "report",
1199
+ state: { lastStatus: "error", lastError: "timeout" },
1200
+ }],
1201
+ }),
1202
+ stderr: "",
1203
+ };
1204
+ }
1205
+ if (cmd.includes("model probe")) {
1206
+ return { exitCode: 0, stdout: "ok", stderr: "" };
1207
+ }
1208
+ if (cmd.includes("gateway status")) {
1209
+ return { exitCode: 0, stdout: "ok", stderr: "" };
1210
+ }
1211
+ return { exitCode: 0, stdout: "", stderr: "" };
1212
+ });
1213
+ register(api);
1214
+
1215
+ await runOneTick(api);
1216
+
1217
+ const state = loadState(stateFile);
1218
+
1219
+ // WhatsApp: connected, streak reset
1220
+ expect(state.whatsapp!.disconnectStreak).toBe(0);
1221
+ expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
1222
+
1223
+ // Cron: threshold reached, disabled
1224
+ const disableCalls = filterCmdCalls(api, "cron edit cron-1 --disable");
1225
+ expect(disableCalls).toHaveLength(1);
1226
+ const cronEvents = findEmitted(api, "self-heal:cron-disabled");
1227
+ expect(cronEvents).toHaveLength(1);
1228
+
1229
+ // Probe: model-b recovered
1230
+ expect(state.limited["model-b"]).toBeUndefined();
1231
+ const recoveryEvents = findEmitted(api, "self-heal:model-recovered");
1232
+ expect(recoveryEvents).toHaveLength(1);
1233
+ expect(recoveryEvents[0].payload.model).toBe("model-b");
1234
+
1235
+ // Status snapshot emitted
1236
+ const statusEvents = findEmitted(api, "self-heal:status");
1237
+ expect(statusEvents.length).toBeGreaterThanOrEqual(1);
1238
+ });
1239
+
1240
+ it("handles all domains being inactive gracefully", async () => {
1241
+ const api = mockApi({
1242
+ pluginConfig: {
1243
+ stateFile,
1244
+ sessionsFile,
1245
+ configFile,
1246
+ configBackupsDir: backupsDir,
1247
+ modelOrder: ["model-a"],
1248
+ autoFix: {
1249
+ restartWhatsappOnDisconnect: false,
1250
+ disableFailingCrons: false,
1251
+ disableFailingPlugins: false,
1252
+ },
1253
+ probeEnabled: false,
1254
+ },
1255
+ });
1256
+ register(api);
1257
+
1258
+ await runOneTick(api);
1259
+
1260
+ // Only status event should be emitted
1261
+ const statusEvents = findEmitted(api, "self-heal:status");
1262
+ expect(statusEvents.length).toBeGreaterThanOrEqual(1);
1263
+ expect(statusEvents[0].payload.health).toBe("healthy");
1264
+
1265
+ // No healing commands should have been run (no WA check, no cron check, no probes)
1266
+ // Note: startup cleanup calls "openclaw gateway status" once, so we check
1267
+ // that no healing-specific commands were issued
1268
+ const healingCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
1269
+ (c: any[]) => {
1270
+ const cmd = c[0]?.command?.join(" ") ?? "";
1271
+ return cmd.includes("channels status") || cmd.includes("cron list") || cmd.includes("model probe");
1272
+ }
1273
+ );
1274
+ expect(healingCalls).toHaveLength(0);
1275
+ });
1276
+ });
1277
+
1278
+ // -------------------------------------------------------------------------
1279
+ // Edge cases and error handling
1280
+ // -------------------------------------------------------------------------
1281
+
1282
+ describe("edge cases and error handling", () => {
1283
+ it("handles command timeout gracefully during tick", async () => {
1284
+ const api = mockApi({
1285
+ pluginConfig: {
1286
+ stateFile,
1287
+ sessionsFile,
1288
+ configFile,
1289
+ configBackupsDir: backupsDir,
1290
+ modelOrder: ["model-a"],
1291
+ },
1292
+ });
1293
+ api.runtime.system.runCommandWithTimeout.mockRejectedValue(
1294
+ new Error("command timed out")
1295
+ );
1296
+ register(api);
1297
+
1298
+ // Should not throw
1299
+ await runOneTick(api);
1300
+
1301
+ // Status should still be emitted
1302
+ const statusEvents = findEmitted(api, "self-heal:status");
1303
+ expect(statusEvents.length).toBeGreaterThanOrEqual(1);
1304
+ });
1305
+
1306
+ it("handles malformed JSON from channels status command", async () => {
1307
+ const api = mockApi({
1308
+ pluginConfig: {
1309
+ stateFile,
1310
+ sessionsFile,
1311
+ configFile,
1312
+ configBackupsDir: backupsDir,
1313
+ modelOrder: ["model-a"],
1314
+ },
1315
+ });
1316
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
1317
+ exitCode: 0,
1318
+ stdout: "NOT JSON {{{",
1319
+ stderr: "",
1320
+ });
1321
+ register(api);
1322
+
1323
+ // Should not throw
1324
+ await runOneTick(api);
1325
+
1326
+ // WhatsApp: malformed JSON means safeJsonParse returns undefined,
1327
+ // so the wa object is undefined, connected=false, and streak increments by 1
1328
+ const state = loadState(stateFile);
1329
+ expect(state.whatsapp!.disconnectStreak).toBe(1);
1330
+ });
1331
+
1332
+ it("handles malformed JSON from cron list command", async () => {
1333
+ const api = mockApi({
1334
+ pluginConfig: {
1335
+ stateFile,
1336
+ sessionsFile,
1337
+ configFile,
1338
+ configBackupsDir: backupsDir,
1339
+ modelOrder: ["model-a"],
1340
+ autoFix: { disableFailingCrons: true },
1341
+ },
1342
+ });
1343
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
1344
+ exitCode: 0,
1345
+ stdout: "this is not json",
1346
+ stderr: "",
1347
+ });
1348
+ register(api);
1349
+
1350
+ // Should not throw
1351
+ await runOneTick(api);
1352
+
1353
+ const statusEvents = findEmitted(api, "self-heal:status");
1354
+ expect(statusEvents.length).toBeGreaterThanOrEqual(1);
1355
+ });
1356
+
1357
+ it("persists state at end of tick even when no healing actions taken", async () => {
1358
+ const api = mockApi({
1359
+ pluginConfig: {
1360
+ stateFile,
1361
+ sessionsFile,
1362
+ configFile,
1363
+ configBackupsDir: backupsDir,
1364
+ modelOrder: ["model-a"],
1365
+ probeEnabled: false,
1366
+ autoFix: { restartWhatsappOnDisconnect: false, disableFailingCrons: false },
1367
+ },
1368
+ });
1369
+ register(api);
1370
+
1371
+ await runOneTick(api);
1372
+
1373
+ // State file should exist and be valid
1374
+ expect(fs.existsSync(stateFile)).toBe(true);
1375
+ const state = loadState(stateFile);
1376
+ expect(state.limited).toBeDefined();
1377
+ });
1378
+
1379
+ it("uses whatsapp connected=true as alternative connected indicator", async () => {
1380
+ const api = mockApi({
1381
+ pluginConfig: {
1382
+ stateFile,
1383
+ sessionsFile,
1384
+ configFile,
1385
+ configBackupsDir: backupsDir,
1386
+ modelOrder: ["model-a"],
1387
+ },
1388
+ });
1389
+ api.runtime.system.runCommandWithTimeout.mockResolvedValue({
1390
+ exitCode: 0,
1391
+ stdout: JSON.stringify({ channels: { whatsapp: { connected: true } } }),
1392
+ stderr: "",
1393
+ });
1394
+ register(api);
1395
+
1396
+ await runOneTick(api);
1397
+
1398
+ const state = loadState(stateFile);
1399
+ expect(state.whatsapp!.disconnectStreak).toBe(0);
1400
+ expect(state.whatsapp!.lastSeenConnectedAt).toBeGreaterThan(0);
1401
+ });
1402
+ });
1403
+ });