@elvatis_com/openclaw-self-healing-elvatis 0.2.4 → 0.2.7
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/.ai/handoff/MANIFEST.json +14 -13
- package/README.md +18 -4
- package/index.ts +79 -16
- package/openclaw.plugin.json +3 -3
- package/package.json +1 -1
- package/scripts/create-roadmap-issues.sh +1 -1
- package/test/index.test.ts +11 -11
- package/test/monitor-integration.test.ts +16 -10
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
"created": "2026-02-27T23:00:00Z",
|
|
53
53
|
"priority": "high",
|
|
54
54
|
"title": "Implement structured plugin health monitoring and auto-disable",
|
|
55
|
-
"status": "
|
|
55
|
+
"status": "done",
|
|
56
56
|
"depends_on": [],
|
|
57
57
|
"blocked_reason": "Waiting for openclaw plugins list --json API",
|
|
58
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
59
|
-
"github_issue": 3
|
|
58
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
59
|
+
"github_issue": 3,
|
|
60
|
+
"completed": "2026-03-07T06:58:52.606Z"
|
|
60
61
|
},
|
|
61
62
|
"T-006": {
|
|
62
63
|
"title": "Support configuration hot-reload without gateway restart",
|
|
@@ -66,7 +67,7 @@
|
|
|
66
67
|
"created": "2026-02-28T13:25:43.201Z",
|
|
67
68
|
"notes": "## Summary\n\nPlugin config (`modelOrder`, `cooldownMinutes`, `autoFix.*`) is read once at startup via `api.pluginConfig`. Changing any config value requires a full gateway restart.\n\n## Scope\n\n- Watch for config changes (file watch or periodic re-read)\n- Re-read plugin config on change and update internal variables\n- Handle edge cases: invalid new config (keep old), partial updates\n- Alternatively, support a reload command: `openclaw self-heal reload`\n\n## Acceptance criteria\n\n- Config changes take",
|
|
68
69
|
"github_issue": 8,
|
|
69
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
70
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
70
71
|
"completed": "2026-02-28T13:42:34.823Z"
|
|
71
72
|
},
|
|
72
73
|
"T-007": {
|
|
@@ -77,7 +78,7 @@
|
|
|
77
78
|
"created": "2026-02-28T13:25:43.204Z",
|
|
78
79
|
"notes": "## Summary\n\nModels in cooldown are currently recovered passively: `pickFallback()` checks `nextAvailableAt` only when a new failure occurs. If a model recovers early, the plugin still uses the fallback until the full cooldown expires.\n\n## Scope\n\n- Add a periodic probe (e.g., every 5 minutes) that tests cooldown models\n- Use a lightweight API call (e.g., model info endpoint or small completion)\n- If the model responds successfully, remove it from cooldown early\n- Configurable probe interval and e",
|
|
79
80
|
"github_issue": 7,
|
|
80
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
81
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
81
82
|
"completed": "2026-02-28T14:42:14.373Z"
|
|
82
83
|
},
|
|
83
84
|
"T-008": {
|
|
@@ -88,7 +89,7 @@
|
|
|
88
89
|
"created": "2026-02-28T13:25:43.204Z",
|
|
89
90
|
"notes": "## Summary\n\nThere is no way to test what the plugin would do without it actually taking healing actions (restarting gateways, disabling crons, patching sessions). A dry-run mode is needed for validation and debugging.\n\n## Scope\n\n- Add a `dryRun: boolean` config option (default: false)\n- When enabled: log all actions that _would_ be taken, but do not execute them\n- State tracking still updates (to test state transitions) but side-effects are skipped\n- Useful for: initial setup validation, debuggi",
|
|
90
91
|
"github_issue": 6,
|
|
91
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
92
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
92
93
|
"completed": "2026-02-28T15:14:21.885Z"
|
|
93
94
|
},
|
|
94
95
|
"T-009": {
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
"created": "2026-02-28T13:25:43.204Z",
|
|
100
101
|
"notes": "## Summary\n\nThe plugin uses `api.logger` for logging but emits no structured events. Monitoring and alerting systems cannot track heal actions, cooldown entries, or failure rates.\n\n## Scope\n\n- Emit structured events via `api.emit()` or equivalent for:\n - `self-heal:model-cooldown` - model put into cooldown (with model ID, reason, duration)\n - `self-heal:session-patched` - session model pin overridden (with session key, old/new model)\n - `self-heal:whatsapp-restart` - WhatsApp gateway restarte",
|
|
101
102
|
"github_issue": 5,
|
|
102
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
103
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
103
104
|
"completed": "2026-02-28T15:23:54.846Z"
|
|
104
105
|
},
|
|
105
106
|
"T-010": {
|
|
@@ -110,7 +111,7 @@
|
|
|
110
111
|
"created": "2026-02-28T13:25:43.204Z",
|
|
111
112
|
"notes": "## Summary\n\nExternal tools (dashboards, other plugins, CLI) cannot query the self-heal plugin state. There is no way to know which models are in cooldown, how many heals have occurred, or the current WhatsApp connection status without reading the raw state file.\n\n## Scope\n\n- Register a plugin command or API endpoint (depends on openclaw plugin API):\n - `openclaw self-heal status` or similar\n - Returns JSON with: cooldown models, WhatsApp status, cron heal history, last heal actions\n- Alternati",
|
|
112
113
|
"github_issue": 4,
|
|
113
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
114
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
114
115
|
"completed": "2026-02-28T16:15:24.131Z"
|
|
115
116
|
},
|
|
116
117
|
"T-011": {
|
|
@@ -121,7 +122,7 @@
|
|
|
121
122
|
"created": "2026-03-02T00:00:00Z",
|
|
122
123
|
"notes": "## Summary\n\nThe current test suite has 122 unit tests covering helpers and pure functions (parseConfig, pickFallback, buildStatusSnapshot, etc.) but the monitor service tick() function runs all healing logic and is not covered by integration tests.\n\n## Scope\n\n- Add integration tests for the full monitor tick cycle using a mocked api object\n- Cover: WhatsApp disconnect streak -> restart path\n- Cover: cron failure accumulation -> disable + issue create path\n- Cover: active model recovery probe -> cooldown removal path\n- Cover: config hot-reload during tick\n- Cover: dry-run flag suppresses all side-effects\n- Use Jest timer mocks for setInterval control\n\n## Acceptance criteria\n\n- At least 20 new integration-level tests added\n- All healing paths (WhatsApp, cron, probe) exercised in tests\n- Tests pass with npm test",
|
|
123
124
|
"github_issue": 9,
|
|
124
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
125
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
125
126
|
"completed": "2026-03-02T02:17:03.808Z"
|
|
126
127
|
},
|
|
127
128
|
"T-012": {
|
|
@@ -132,7 +133,7 @@
|
|
|
132
133
|
"created": "2026-03-02T00:00:00Z",
|
|
133
134
|
"notes": "## Summary\n\nCurrently parseConfig() silently falls back to defaults for any invalid value. The plugin starts even if modelOrder is empty, paths are not writable, or cooldownMinutes is set to an absurd value. This makes misconfiguration hard to diagnose.\n\n## Scope\n\n- Add a validateConfig(config: PluginConfig): { valid: boolean; errors: string[] } function\n- Validate: modelOrder has at least one entry\n- Validate: cooldownMinutes is between 1 and 10080 (1 week)\n- Validate: probeIntervalSec >= 60\n- Validate: whatsappMinRestartIntervalSec >= 60\n- Validate: state file directory is writable (best-effort)\n- Log each validation error clearly with api.logger?.error\n- Return early from register() if validation fails (fail-fast)\n\n## Acceptance criteria\n\n- validateConfig function is exported and unit-tested\n- Plugin refuses to start on invalid config and logs all reasons\n- README config section updated with valid ranges",
|
|
134
135
|
"github_issue": 10,
|
|
135
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
136
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
136
137
|
"completed": "2026-03-02T02:50:50.546Z"
|
|
137
138
|
},
|
|
138
139
|
"T-013": {
|
|
@@ -143,7 +144,7 @@
|
|
|
143
144
|
"created": "2026-03-02T00:00:00Z",
|
|
144
145
|
"notes": "## Summary\n\nThe plugin already builds a StatusSnapshot object and emits it via api.emit('self-heal:status', snapshot) on each tick. However, external tools (scripts, dashboards, other plugins) cannot read this snapshot without subscribing to the event bus. A file-based status report would allow polling from any tool.\n\n## Scope\n\n- After each tick, write buildStatusSnapshot(state, config) to a JSON file\n- Default path: ~/.openclaw/workspace/memory/self-heal-status.json (configurable via statusFile config key)\n- Write atomically (write to .tmp then rename)\n- Add statusFile to PluginConfig type and parseConfig\n- Add a writeStatusFile(path, snapshot) helper function (exported for tests)\n\n## Acceptance criteria\n\n- Status file is written on every tick\n- File contains valid JSON matching StatusSnapshot type\n- Atomic write prevents partial reads\n- Tests cover writeStatusFile helper\n- README documents the statusFile config key and file format",
|
|
145
146
|
"github_issue": 11,
|
|
146
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
147
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis",
|
|
147
148
|
"completed": "2026-03-02T03:16:30.819Z"
|
|
148
149
|
},
|
|
149
150
|
"T-014": {
|
|
@@ -154,12 +155,12 @@
|
|
|
154
155
|
"created": "2026-03-02T00:00:00Z",
|
|
155
156
|
"notes": "## Summary\n\nHeal events are currently only visible in logs and via api.emit(). There is no persistent record of past heal actions for analysis or alerting. The aahp-runner project writes structured metrics to ~/.aahp/metrics.jsonl; the self-heal plugin should do the same.\n\n## Scope\n\n- Add an appendMetric(line: object, metricsFile: string) helper that appends a JSON line\n- Emit one JSONL line per heal event:\n - { ts, plugin: \"self-heal\", event: \"model-cooldown\", model, reason, cooldownSec }\n - { ts, plugin: \"self-heal\", event: \"session-patched\", sessionKey, oldModel, newModel }\n - { ts, plugin: \"self-heal\", event: \"whatsapp-restart\", disconnectStreak }\n - { ts, plugin: \"self-heal\", event: \"cron-disabled\", cronId, cronName, consecutiveFailures }\n - { ts, plugin: \"self-heal\", event: \"model-recovered\", model, isPreferred }\n- Default metrics file: ~/.aahp/metrics.jsonl (configurable via metricsFile config key)\n- Skip metrics write in dry-run mode (or mark with dryRun: true)\n- Create parent directory if missing\n\n## Acceptance criteria\n\n- appendMetric helper is exported and unit-tested\n- All 5 heal event types write a metrics line\n- Dry-run events are either skipped or marked\n- README documents the metrics format and metricsFile config key",
|
|
156
157
|
"github_issue": 12,
|
|
157
|
-
"github_repo": "elvatis/openclaw-self-healing-
|
|
158
|
+
"github_repo": "elvatis/openclaw-self-healing-elvatis"
|
|
158
159
|
}
|
|
159
160
|
},
|
|
160
161
|
"quick_context": "T-013 done: writeStatusFile() helper with atomic write (tmp+rename) added. statusFile config key with default ~/.openclaw/workspace/memory/self-heal-status.json. Written on every monitor tick. 9 new tests, total 181 passing. CI green. T-005 still blocked on openclaw plugins list --json API. Remaining v0.3 task: T-014 (metrics export).",
|
|
161
162
|
"aahp_version": "3.0",
|
|
162
|
-
"project": "openclaw-self-healing-
|
|
163
|
+
"project": "openclaw-self-healing-elvatis",
|
|
163
164
|
"token_budget": {
|
|
164
165
|
"full_read": 1000,
|
|
165
166
|
"manifest_only": 100,
|
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ openclaw gateway restart
|
|
|
57
57
|
"patchSessionPins": true,
|
|
58
58
|
"disableFailingPlugins": false,
|
|
59
59
|
"disableFailingCrons": false,
|
|
60
|
-
"issueRepo": "elvatis/openclaw-self-healing-
|
|
60
|
+
"issueRepo": "elvatis/openclaw-self-healing-elvatis"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -66,7 +66,7 @@ openclaw gateway restart
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
`autoFix.issueRepo` must use `owner/repo` format. Invalid values are ignored and the plugin falls back to `GITHUB_REPOSITORY` (if valid) or `elvatis/openclaw-self-healing-
|
|
69
|
+
`autoFix.issueRepo` must use `owner/repo` format. Invalid values are ignored and the plugin falls back to `GITHUB_REPOSITORY` (if valid) or `elvatis/openclaw-self-healing-elvatis`.
|
|
70
70
|
|
|
71
71
|
### Config validation
|
|
72
72
|
|
|
@@ -94,15 +94,29 @@ The file is written atomically (write to `.tmp` then rename) to prevent partial
|
|
|
94
94
|
"health": "healthy | degraded | healing",
|
|
95
95
|
"activeModel": "anthropic/claude-opus-4-6",
|
|
96
96
|
"models": [
|
|
97
|
-
{
|
|
97
|
+
{
|
|
98
|
+
"id": "anthropic/claude-opus-4-6",
|
|
99
|
+
"status": "available | cooldown",
|
|
100
|
+
"cooldownReason": "rate limit (only when in cooldown)",
|
|
101
|
+
"cooldownRemainingSec": 1234,
|
|
102
|
+
"nextAvailableAt": 1700001234,
|
|
103
|
+
"lastProbeAt": 1700000900
|
|
104
|
+
}
|
|
98
105
|
],
|
|
99
|
-
"whatsapp": {
|
|
106
|
+
"whatsapp": {
|
|
107
|
+
"status": "connected | disconnected | unknown",
|
|
108
|
+
"disconnectStreak": 0,
|
|
109
|
+
"lastRestartAt": null,
|
|
110
|
+
"lastSeenConnectedAt": 1700000000
|
|
111
|
+
},
|
|
100
112
|
"cron": { "trackedJobs": 2, "failingJobs": [] },
|
|
101
113
|
"config": { "dryRun": false, "probeEnabled": true, "cooldownMinutes": 300, "modelOrder": ["..."] },
|
|
102
114
|
"generatedAt": 1700000000
|
|
103
115
|
}
|
|
104
116
|
```
|
|
105
117
|
|
|
118
|
+
Fields `cooldownReason`, `cooldownRemainingSec`, `nextAvailableAt`, and `lastProbeAt` on model entries are only present when the model is in cooldown.
|
|
119
|
+
|
|
106
120
|
## Notes
|
|
107
121
|
|
|
108
122
|
Infrastructure changes remain ask-first.
|
package/index.ts
CHANGED
|
@@ -158,7 +158,7 @@ export function isValidIssueRepoSlug(value: string): boolean {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
export function resolveIssueRepo(configValue: unknown, envValue: unknown): string {
|
|
161
|
-
const defaultRepo = "elvatis/openclaw-self-healing-
|
|
161
|
+
const defaultRepo = "elvatis/openclaw-self-healing-elvatis";
|
|
162
162
|
const candidates = [configValue, envValue, defaultRepo];
|
|
163
163
|
for (const candidate of candidates) {
|
|
164
164
|
if (typeof candidate !== "string") continue;
|
|
@@ -350,7 +350,14 @@ export function patchSessionModel(sessionsFile: string, sessionKey: string, mode
|
|
|
350
350
|
if (!data[sessionKey]) return false;
|
|
351
351
|
const prev = data[sessionKey].model;
|
|
352
352
|
data[sessionKey].model = model;
|
|
353
|
-
|
|
353
|
+
// atomic write: write to tmp file then rename
|
|
354
|
+
const tmpFile = `${sessionsFile}.tmp.${Date.now()}`;
|
|
355
|
+
try {
|
|
356
|
+
fs.writeFileSync(tmpFile, JSON.stringify(data, null, 0), { encoding: "utf-8", mode: 0o600 });
|
|
357
|
+
fs.renameSync(tmpFile, sessionsFile);
|
|
358
|
+
} finally {
|
|
359
|
+
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
360
|
+
}
|
|
354
361
|
logger?.warn?.(`[self-heal] patched session model: ${sessionKey} ${prev} -> ${model}`);
|
|
355
362
|
return true;
|
|
356
363
|
} catch (e: any) {
|
|
@@ -361,15 +368,18 @@ export function patchSessionModel(sessionsFile: string, sessionKey: string, mode
|
|
|
361
368
|
|
|
362
369
|
async function runCmd(api: any, cmd: string, timeoutMs = 15000): Promise<{ ok: boolean; stdout: string; stderr: string; code?: number }> {
|
|
363
370
|
try {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
371
|
+
// runCommandWithTimeout(argv: string[], options) — two separate args, not one object.
|
|
372
|
+
// SpawnResult uses `code`; accept `exitCode` too for mock compatibility.
|
|
373
|
+
const res = await api.runtime.system.runCommandWithTimeout(
|
|
374
|
+
["bash", "-lc", cmd],
|
|
375
|
+
{ timeoutMs },
|
|
376
|
+
);
|
|
377
|
+
const exitCode = res.code ?? (res as any).exitCode ?? null;
|
|
368
378
|
return {
|
|
369
|
-
ok:
|
|
379
|
+
ok: exitCode === 0,
|
|
370
380
|
stdout: String(res.stdout ?? ""),
|
|
371
381
|
stderr: String(res.stderr ?? ""),
|
|
372
|
-
code:
|
|
382
|
+
code: exitCode ?? undefined,
|
|
373
383
|
};
|
|
374
384
|
} catch (e: any) {
|
|
375
385
|
return { ok: false, stdout: "", stderr: e?.message ?? String(e) };
|
|
@@ -748,17 +758,70 @@ export default function register(api: any) {
|
|
|
748
758
|
|
|
749
759
|
// --- Plugin error rollback (disable plugin) ---
|
|
750
760
|
if (config.disableFailingPlugins) {
|
|
751
|
-
const res = await runCmd(api, "openclaw plugins list", 15000);
|
|
761
|
+
const res = await runCmd(api, "openclaw plugins list --json", 15000);
|
|
752
762
|
if (res.ok) {
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
763
|
+
type PluginEntry = { id: string; name?: string; enabled?: boolean; status?: string; version?: string; error?: string };
|
|
764
|
+
const parsed = safeJsonParse<{ plugins: PluginEntry[] }>(res.stdout);
|
|
765
|
+
const plugins: PluginEntry[] = parsed?.plugins ?? [];
|
|
766
|
+
const selfId = "openclaw-self-healing-elvatis";
|
|
767
|
+
|
|
768
|
+
for (const plugin of plugins) {
|
|
769
|
+
const id = plugin.id;
|
|
770
|
+
if (!id || id === selfId) continue; // never disable ourselves
|
|
771
|
+
|
|
772
|
+
const isFailing = plugin.status === "error" || plugin.status === "crash";
|
|
773
|
+
if (!isFailing) continue;
|
|
774
|
+
|
|
775
|
+
const lastDisableAt = state.plugins!.lastDisableAt![id] ?? 0;
|
|
776
|
+
if (nowSec() - lastDisableAt < config.pluginDisableCooldownSec) {
|
|
777
|
+
api.logger?.info?.(`[self-heal] plugin ${id} still in disable cooldown, skipping`);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const name = plugin.name ?? id;
|
|
782
|
+
const failReason = plugin.error ? `status=${plugin.status} error=${plugin.error}` : `status=${plugin.status}`;
|
|
783
|
+
|
|
784
|
+
if (config.dryRun) {
|
|
785
|
+
api.logger?.info?.(`[self-heal] [dry-run] would disable plugin ${name} (${id}), reason=${failReason}`);
|
|
786
|
+
state.plugins!.lastDisableAt![id] = nowSec();
|
|
787
|
+
} else {
|
|
788
|
+
const v = isConfigValid();
|
|
789
|
+
if (!v.ok) {
|
|
790
|
+
api.logger?.error?.(`[self-heal] NOT disabling plugin: openclaw.json invalid: ${v.error}`);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
api.logger?.warn?.(`[self-heal] Disabling failing plugin ${name} (${id}), reason=${failReason}`);
|
|
794
|
+
backupConfig("pre-plugin-disable");
|
|
795
|
+
await runCmd(api, `openclaw plugins disable ${shellQuote(id)}`, 15000);
|
|
796
|
+
await cleanupPendingBackups("post-plugin-disable");
|
|
797
|
+
state.plugins!.lastDisableAt![id] = nowSec();
|
|
798
|
+
|
|
799
|
+
const body = [
|
|
800
|
+
`Plugin was detected as failing and disabled by openclaw-self-healing.`,
|
|
801
|
+
``,
|
|
802
|
+
`Plugin ID: ${id}`,
|
|
803
|
+
`Plugin Name: ${name}`,
|
|
804
|
+
`Version: ${plugin.version ?? "unknown"}`,
|
|
805
|
+
`Status: ${plugin.status}`,
|
|
806
|
+
...(plugin.error ? [`Error: ${plugin.error}`] : []),
|
|
807
|
+
].join("\n");
|
|
808
|
+
const issueCommand = buildGhIssueCreateCommand({
|
|
809
|
+
repo: config.issueRepo,
|
|
810
|
+
title: `Plugin disabled: ${name}`,
|
|
811
|
+
body,
|
|
812
|
+
labels: ["security"],
|
|
813
|
+
});
|
|
814
|
+
await runCmd(api, issueCommand, 20000);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
api.emit?.("self-heal:plugin-disabled", {
|
|
818
|
+
pluginId: id,
|
|
819
|
+
pluginName: name,
|
|
820
|
+
reason: failReason,
|
|
821
|
+
dryRun: config.dryRun,
|
|
822
|
+
});
|
|
759
823
|
}
|
|
760
824
|
}
|
|
761
|
-
// TODO: when openclaw provides plugins list --json, parse and disable any status=error.
|
|
762
825
|
}
|
|
763
826
|
|
|
764
827
|
// --- Active model recovery probing ---
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-self-healing-elvatis",
|
|
3
3
|
"name": "OpenClaw Self Healing",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.7",
|
|
5
5
|
"description": "Self-healing health checks + guardrails + auto-fix for reversible failures (rate limits, auth errors, stuck session pins).",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
},
|
|
96
96
|
"issueRepo": {
|
|
97
97
|
"type": "string",
|
|
98
|
-
"default": "elvatis/openclaw-self-healing-
|
|
98
|
+
"default": "elvatis/openclaw-self-healing-elvatis",
|
|
99
99
|
"description": "GitHub repository (owner/repo) where auto-created cron failure issues should be opened."
|
|
100
100
|
},
|
|
101
101
|
"pluginDisableCooldownSec": {
|
|
@@ -107,4 +107,4 @@
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
-
}
|
|
110
|
+
}
|
package/package.json
CHANGED
package/test/index.test.ts
CHANGED
|
@@ -1087,7 +1087,7 @@ describe("register", () => {
|
|
|
1087
1087
|
// not for a probe (since the model's cooldown already expired)
|
|
1088
1088
|
const probeCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1089
1089
|
(c: any[]) => {
|
|
1090
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1090
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1091
1091
|
return cmd.includes("model probe");
|
|
1092
1092
|
}
|
|
1093
1093
|
);
|
|
@@ -1214,7 +1214,7 @@ describe("register", () => {
|
|
|
1214
1214
|
// Should NOT have called the actual probe command
|
|
1215
1215
|
const probeCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1216
1216
|
(c: any[]) => {
|
|
1217
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1217
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1218
1218
|
return cmd.includes("model probe");
|
|
1219
1219
|
}
|
|
1220
1220
|
);
|
|
@@ -1244,7 +1244,7 @@ describe("register", () => {
|
|
|
1244
1244
|
// model-a probe succeeds, model-b probe fails
|
|
1245
1245
|
let probeCount = 0;
|
|
1246
1246
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
1247
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
1247
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
1248
1248
|
if (cmd.includes("model probe")) {
|
|
1249
1249
|
probeCount++;
|
|
1250
1250
|
if (cmd.includes("model-a")) {
|
|
@@ -1420,7 +1420,7 @@ describe("register", () => {
|
|
|
1420
1420
|
// Should NOT have called gateway restart
|
|
1421
1421
|
const restartCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1422
1422
|
(c: any[]) => {
|
|
1423
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1423
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1424
1424
|
return cmd.includes("gateway restart");
|
|
1425
1425
|
}
|
|
1426
1426
|
);
|
|
@@ -1472,7 +1472,7 @@ describe("register", () => {
|
|
|
1472
1472
|
// Should NOT have called cron edit disable
|
|
1473
1473
|
const cronCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1474
1474
|
(c: any[]) => {
|
|
1475
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1475
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1476
1476
|
return cmd.includes("cron edit");
|
|
1477
1477
|
}
|
|
1478
1478
|
);
|
|
@@ -1481,7 +1481,7 @@ describe("register", () => {
|
|
|
1481
1481
|
// Should NOT have called gh issue create
|
|
1482
1482
|
const issueCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1483
1483
|
(c: any[]) => {
|
|
1484
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1484
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1485
1485
|
return cmd.includes("gh issue create");
|
|
1486
1486
|
}
|
|
1487
1487
|
);
|
|
@@ -1963,7 +1963,7 @@ describe("parseConfig", () => {
|
|
|
1963
1963
|
expect(c.whatsappMinRestartIntervalSec).toBe(300);
|
|
1964
1964
|
expect(c.cronFailThreshold).toBe(3);
|
|
1965
1965
|
expect(c.issueCooldownSec).toBe(6 * 3600);
|
|
1966
|
-
expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-
|
|
1966
|
+
expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-elvatis");
|
|
1967
1967
|
expect(c.pluginDisableCooldownSec).toBe(3600);
|
|
1968
1968
|
expect(c.probeEnabled).toBe(true);
|
|
1969
1969
|
expect(c.probeIntervalSec).toBe(300);
|
|
@@ -2006,7 +2006,7 @@ describe("parseConfig", () => {
|
|
|
2006
2006
|
|
|
2007
2007
|
it("falls back to default when configured issueRepo is invalid", () => {
|
|
2008
2008
|
const c = parseConfig({ autoFix: { issueRepo: "not-a-slug" } });
|
|
2009
|
-
expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-
|
|
2009
|
+
expect(c.issueRepo).toBe("elvatis/openclaw-self-healing-elvatis");
|
|
2010
2010
|
});
|
|
2011
2011
|
|
|
2012
2012
|
it("does not share modelOrder array reference with input", () => {
|
|
@@ -2047,7 +2047,7 @@ describe("parseConfig", () => {
|
|
|
2047
2047
|
|
|
2048
2048
|
describe("GitHub issue helpers", () => {
|
|
2049
2049
|
it("validates owner/repo slug", () => {
|
|
2050
|
-
expect(isValidIssueRepoSlug("elvatis/openclaw-self-healing-
|
|
2050
|
+
expect(isValidIssueRepoSlug("elvatis/openclaw-self-healing-elvatis")).toBe(true);
|
|
2051
2051
|
expect(isValidIssueRepoSlug("owner/repo_1")).toBe(true);
|
|
2052
2052
|
expect(isValidIssueRepoSlug("bad")).toBe(false);
|
|
2053
2053
|
expect(isValidIssueRepoSlug("owner/repo/extra")).toBe(false);
|
|
@@ -2056,7 +2056,7 @@ describe("GitHub issue helpers", () => {
|
|
|
2056
2056
|
it("resolves issue repo from config then env then default", () => {
|
|
2057
2057
|
expect(resolveIssueRepo("owner/config-repo", "owner/env-repo")).toBe("owner/config-repo");
|
|
2058
2058
|
expect(resolveIssueRepo("bad", "owner/env-repo")).toBe("owner/env-repo");
|
|
2059
|
-
expect(resolveIssueRepo(undefined, "bad")).toBe("elvatis/openclaw-self-healing-
|
|
2059
|
+
expect(resolveIssueRepo(undefined, "bad")).toBe("elvatis/openclaw-self-healing-elvatis");
|
|
2060
2060
|
});
|
|
2061
2061
|
|
|
2062
2062
|
it("builds shell-safe gh issue command with labels", () => {
|
|
@@ -2354,7 +2354,7 @@ describe("validateConfig", () => {
|
|
|
2354
2354
|
whatsappMinRestartIntervalSec: 300,
|
|
2355
2355
|
cronFailThreshold: 3,
|
|
2356
2356
|
issueCooldownSec: 21600,
|
|
2357
|
-
issueRepo: "elvatis/openclaw-self-healing-
|
|
2357
|
+
issueRepo: "elvatis/openclaw-self-healing-elvatis",
|
|
2358
2358
|
pluginDisableCooldownSec: 3600,
|
|
2359
2359
|
probeEnabled: true,
|
|
2360
2360
|
probeIntervalSec: 300,
|
|
@@ -79,7 +79,13 @@ async function runOneTick(api: ReturnType<typeof mockApi>) {
|
|
|
79
79
|
|
|
80
80
|
/** Build a command-matching predicate for runCommandWithTimeout call args. */
|
|
81
81
|
function cmdContains(call: any[], fragment: string): boolean {
|
|
82
|
-
|
|
82
|
+
// Handles both call signatures:
|
|
83
|
+
// old (broken): runCommandWithTimeout({ command: string[], timeoutMs })
|
|
84
|
+
// new (correct): runCommandWithTimeout(string[], { timeoutMs })
|
|
85
|
+
const argv: string[] | undefined = Array.isArray(call[0])
|
|
86
|
+
? call[0]
|
|
87
|
+
: call[0]?.command;
|
|
88
|
+
const cmd = (argv ?? []).join(" ");
|
|
83
89
|
return cmd.includes(fragment);
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -195,7 +201,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
195
201
|
},
|
|
196
202
|
});
|
|
197
203
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
198
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
204
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
199
205
|
if (cmd.includes("channels status")) {
|
|
200
206
|
return {
|
|
201
207
|
exitCode: 0,
|
|
@@ -319,7 +325,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
319
325
|
},
|
|
320
326
|
});
|
|
321
327
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
322
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
328
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
323
329
|
commandLog.push(cmd);
|
|
324
330
|
if (cmd.includes("channels status")) {
|
|
325
331
|
return {
|
|
@@ -459,7 +465,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
459
465
|
},
|
|
460
466
|
});
|
|
461
467
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
462
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
468
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
463
469
|
if (cmd.includes("cron list")) {
|
|
464
470
|
return {
|
|
465
471
|
exitCode: 0,
|
|
@@ -527,7 +533,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
527
533
|
},
|
|
528
534
|
});
|
|
529
535
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
530
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
536
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
531
537
|
if (cmd.includes("cron list")) {
|
|
532
538
|
return {
|
|
533
539
|
exitCode: 0,
|
|
@@ -660,7 +666,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
660
666
|
},
|
|
661
667
|
});
|
|
662
668
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
663
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
669
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
664
670
|
if (cmd.includes("model probe")) {
|
|
665
671
|
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
666
672
|
}
|
|
@@ -700,7 +706,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
700
706
|
},
|
|
701
707
|
});
|
|
702
708
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
703
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
709
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
704
710
|
if (cmd.includes("model probe")) {
|
|
705
711
|
return { exitCode: 1, stdout: "", stderr: "still limited" };
|
|
706
712
|
}
|
|
@@ -739,7 +745,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
739
745
|
},
|
|
740
746
|
});
|
|
741
747
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
742
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
748
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
743
749
|
if (cmd.includes("model probe")) {
|
|
744
750
|
return { exitCode: 0, stdout: "ok", stderr: "" };
|
|
745
751
|
}
|
|
@@ -1181,7 +1187,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
1181
1187
|
},
|
|
1182
1188
|
});
|
|
1183
1189
|
api.runtime.system.runCommandWithTimeout.mockImplementation(async (opts: any) => {
|
|
1184
|
-
const cmd = opts?.command?.join(" ") ?? "";
|
|
1190
|
+
const cmd = (Array.isArray(opts) ? opts : opts?.command)?.join(" ") ?? "";
|
|
1185
1191
|
if (cmd.includes("channels status")) {
|
|
1186
1192
|
return {
|
|
1187
1193
|
exitCode: 0,
|
|
@@ -1267,7 +1273,7 @@ describe("monitor service - integration tick flows", () => {
|
|
|
1267
1273
|
// that no healing-specific commands were issued
|
|
1268
1274
|
const healingCalls = api.runtime.system.runCommandWithTimeout.mock.calls.filter(
|
|
1269
1275
|
(c: any[]) => {
|
|
1270
|
-
const cmd = c[0]?.command?.join(" ") ?? "";
|
|
1276
|
+
const cmd = (Array.isArray(c[0]) ? c[0] : c[0]?.command)?.join(" ") ?? "";
|
|
1271
1277
|
return cmd.includes("channels status") || cmd.includes("cron list") || cmd.includes("model probe");
|
|
1272
1278
|
}
|
|
1273
1279
|
);
|