@lifeaitools/clauth 2.15.5 → 2.15.6
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.
|
@@ -32,6 +32,15 @@
|
|
|
32
32
|
.supervisor-pill.ok{border-color:rgba(74,222,128,.35);color:var(--green-light);background:rgba(34,197,94,.08)}
|
|
33
33
|
.supervisor-pill.warn{border-color:rgba(250,204,21,.35);color:var(--gold-light);background:rgba(250,204,21,.08)}
|
|
34
34
|
.supervisor-pill.bad{border-color:rgba(248,113,113,.35);color:var(--pink);background:rgba(248,113,113,.08)}
|
|
35
|
+
/* NEEDS HELP -- a surface that exhausted MAX_CONSECUTIVE_RECONCILE_FAILURES
|
|
36
|
+
and clauth has stopped auto-restarting. Distinct from .bad ("down right
|
|
37
|
+
now, clauth is still retrying"): this is "clauth gave up, a human/agent
|
|
38
|
+
has to look." Pulses so it reads as an alarm among a fleet that regularly
|
|
39
|
+
has a few surfaces transiently down in .bad. */
|
|
40
|
+
.supervisor-pill.critical{border-color:rgba(248,113,113,.6);color:#fff;background:rgba(220,38,38,.55);animation:supervisor-pulse 1.4s ease-in-out infinite}
|
|
41
|
+
.supervisor-row.needs-help{border-color:rgba(220,38,38,.6);box-shadow:0 0 0 1px rgba(220,38,38,.35) inset}
|
|
42
|
+
.supervisor-meta.critical{color:var(--pink);white-space:normal}
|
|
43
|
+
@keyframes supervisor-pulse{0%,100%{opacity:1}50%{opacity:.55}}
|
|
35
44
|
/* Card GRID, not a stack. flex-direction:column gave one full-width row per
|
|
36
45
|
surface, so ten surfaces meant ten rows and constant scrolling to see the
|
|
37
46
|
fleet. auto-fill + minmax packs as many small cards per row as the panel
|
|
@@ -305,7 +305,15 @@ class ClauthSurfacesPanel extends window.ClauthPanelElement {
|
|
|
305
305
|
renderSupervisorSurface(surface) {
|
|
306
306
|
const compositeId = surface.plugin_id + ":" + surface.id;
|
|
307
307
|
const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
|
|
308
|
-
|
|
308
|
+
// "needs_help" is a DIFFERENT alarm than "bad": bad means "down right now,
|
|
309
|
+
// clauth is still retrying"; needs_help means "clauth gave up after
|
|
310
|
+
// MAX_CONSECUTIVE_RECONCILE_FAILURES — a human/agent has to look." It gets
|
|
311
|
+
// its own loud, pulsing pill (see .supervisor-pill.critical) rather than
|
|
312
|
+
// reusing "bad", which would render as just another red flicker in a fleet
|
|
313
|
+
// that regularly has a few surfaces transiently down.
|
|
314
|
+
const needsHelp = surface.state === "needs_help";
|
|
315
|
+
const stateKind = needsHelp ? "critical" : (surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn"));
|
|
316
|
+
const stateLabel = needsHelp ? "NEEDS HELP" : (surface.state || surface.status || "unknown");
|
|
309
317
|
const openUrl = surface.__pseudo ? null : openUiUrlForSurface(surface);
|
|
310
318
|
const isSelected = this.selectedSupervisorSurface && this.selectedSupervisorSurface.id === compositeId;
|
|
311
319
|
const rowClass = "supervisor-row" + (surface.__pseudo ? " readonly" : "") + (isSelected ? " selected" : "");
|
|
@@ -339,14 +347,22 @@ class ClauthSurfacesPanel extends window.ClauthPanelElement {
|
|
|
339
347
|
: (surface.last_health_ok === false && surface.last_health_error
|
|
340
348
|
? surface.last_health_error + " · " + (surface.health || "no health")
|
|
341
349
|
: (surface.health || surface.health_url || "no health"));
|
|
342
|
-
|
|
350
|
+
// The loud line: WHY it needs help and WHAT to do, spelled out on the
|
|
351
|
+
// card itself — not just a pill label a reader has to already know the
|
|
352
|
+
// meaning of. Mirrors the same wording reconcileSurfaceHealth() writes
|
|
353
|
+
// into the daemon log, so the card and the log never disagree.
|
|
354
|
+
const needsHelpLine = needsHelp
|
|
355
|
+
? "Stopped auto-restarting after " + (surface.consecutive_reconcile_failures || "several") + " failed attempts — check the log, fix the cause, then Start/Restart to resume auto-repair."
|
|
356
|
+
: null;
|
|
357
|
+
return '<div class="' + rowClass + (needsHelp ? " needs-help" : "") + '" data-surface-id="' + htmlEscape(compositeId) + '"' + rowAction + '>' +
|
|
343
358
|
'<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
|
|
344
359
|
healthPill +
|
|
345
|
-
supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(
|
|
360
|
+
supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(stateLabel, stateKind) +
|
|
346
361
|
openBtn +
|
|
347
362
|
'</div>' +
|
|
348
363
|
'<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
|
|
349
364
|
'<div class="supervisor-meta">' + htmlEscape(healthLine) + '</div>' +
|
|
365
|
+
(needsHelpLine ? '<div class="supervisor-meta critical">' + htmlEscape(needsHelpLine) + '</div>' : '') +
|
|
350
366
|
'</div>';
|
|
351
367
|
}
|
|
352
368
|
|
|
@@ -18,6 +18,22 @@ const ACTIONS = new Set(["start", "stop", "restart", "reconcile", "test", "promo
|
|
|
18
18
|
const DEFAULT_HEALTH_RECONCILE_INTERVAL_MS = 10000;
|
|
19
19
|
const DEFAULT_HEALTH_TIMEOUT_MS = 2500;
|
|
20
20
|
const HEALTH_RECONCILE_COOLDOWN_MS = 15000;
|
|
21
|
+
// After this many CONSECUTIVE failed reconcile attempts (each gated by
|
|
22
|
+
// HEALTH_RECONCILE_COOLDOWN_MS, so >= 5 * 15s = 75s before this fires),
|
|
23
|
+
// reconcileSurfaceHealth() stops auto-restarting the surface and latches it
|
|
24
|
+
// into "needs_help" instead of retrying forever. Without a cap, a surface
|
|
25
|
+
// that can never come up (a missing dependency, a bad config) gets
|
|
26
|
+
// relaunched every cooldown window FOREVER: the restart command itself can
|
|
27
|
+
// exit 0 (pm2 accepted it) while the process crashes immediately after, so
|
|
28
|
+
// this loop never sees a hard failure to stop on, and pm2's OWN crash-loop
|
|
29
|
+
// backoff never engages either because clauth's periodic explicit restart
|
|
30
|
+
// keeps resetting it. Root cause of the regen-media-local window-flashing
|
|
31
|
+
// incident (2026-09-05): a missing `tsx` dependency meant every restart
|
|
32
|
+
// "succeeded" and immediately crashed, forever, because nothing was
|
|
33
|
+
// counting. A latched surface self-heals the moment its health probe
|
|
34
|
+
// succeeds (handled before this cap is ever consulted) or a human/agent
|
|
35
|
+
// issues an explicit non-reconcile start/stop/restart (see runSurfaceAction).
|
|
36
|
+
export const MAX_CONSECUTIVE_RECONCILE_FAILURES = 5;
|
|
21
37
|
const DOCUMENTATION_FIELDS = ["architecture", "operator_guide", "install", "runbook", "tool_reference", "release", "agent_context"];
|
|
22
38
|
|
|
23
39
|
function hasMcpToken(value) {
|
|
@@ -182,11 +198,25 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
|
|
|
182
198
|
const error = health.error;
|
|
183
199
|
|
|
184
200
|
if (healthy) {
|
|
185
|
-
updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null });
|
|
201
|
+
updateSurfaceState(id, { state: "current", last_health_at: observedAt, last_health_ok: true, last_health_error: null, consecutive_reconcile_failures: 0 });
|
|
186
202
|
inspected.push({ surface_id: id, state: "healthy", observed_at: observedAt });
|
|
187
203
|
continue;
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
// Latched: this surface already exhausted MAX_CONSECUTIVE_RECONCILE_FAILURES
|
|
207
|
+
// and clauth stopped auto-restarting it. Record the failed probe (so the
|
|
208
|
+
// UI's "last checked" freshness stays honest) WITHOUT calling
|
|
209
|
+
// runSurfaceAction again — that call is exactly the crash loop this latch
|
|
210
|
+
// exists to stop. The only ways out are a health probe that succeeds
|
|
211
|
+
// (the branch above, checked every tick regardless of latch state) or an
|
|
212
|
+
// explicit human/agent action (runSurfaceAction resets the counter and
|
|
213
|
+
// clears this latch on any successful non-reconcile command).
|
|
214
|
+
if (surface.state === "needs_help") {
|
|
215
|
+
updateSurfaceState(id, { last_health_at: observedAt, last_health_ok: false, last_health_error: error });
|
|
216
|
+
inspected.push({ surface_id: id, state: "needs_help", error, latched: true, observed_at: observedAt });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
const lastAttempt = Date.parse(surface.last_reconcile_at || "") || 0;
|
|
191
221
|
if (Date.now() - lastAttempt < HEALTH_RECONCILE_COOLDOWN_MS) {
|
|
192
222
|
updateSurfaceState(id, { state: "unavailable", last_health_at: observedAt, last_health_ok: false, last_health_error: error });
|
|
@@ -206,15 +236,52 @@ export async function reconcileSurfaceHealth({ fetchImpl = globalThis.fetch, tim
|
|
|
206
236
|
const commandCompleted = receipt?.resulting_state?.ok === true;
|
|
207
237
|
const postHealth = commandCompleted ? await probeSurfaceHealth(url, fetchImpl, timeoutMs) : { healthy: false, error: receipt?.resulting_state?.state || "reconcile_failed" };
|
|
208
238
|
const repaired = commandCompleted && postHealth.healthy;
|
|
239
|
+
|
|
240
|
+
if (repaired) {
|
|
241
|
+
updateSurfaceState(id, {
|
|
242
|
+
state: "current",
|
|
243
|
+
last_health_at: now(),
|
|
244
|
+
last_health_ok: true,
|
|
245
|
+
last_health_error: null,
|
|
246
|
+
last_reconcile_operation_id: receipt?.operationId || null,
|
|
247
|
+
consecutive_reconcile_failures: 0,
|
|
248
|
+
});
|
|
249
|
+
appendSupervisorEvent({ kind: "surface_reconciled", surface_id: id, operation_id: receipt?.operationId || null, command_completed: commandCompleted, health_ok: true, error: null });
|
|
250
|
+
inspected.push({ surface_id: id, state: "reconciled", error: null, operation_id: receipt?.operationId || null, observed_at: observedAt });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Not repaired: count it. MAX_CONSECUTIVE_RECONCILE_FAILURES is the
|
|
255
|
+
// circuit breaker — cross it and the surface latches to "needs_help"
|
|
256
|
+
// instead of feeding another attempt back into runSurfaceAction next tick.
|
|
257
|
+
const failures = (surface.consecutive_reconcile_failures || 0) + 1;
|
|
258
|
+
const exhausted = failures >= MAX_CONSECUTIVE_RECONCILE_FAILURES;
|
|
209
259
|
updateSurfaceState(id, {
|
|
210
|
-
state:
|
|
260
|
+
state: exhausted ? "needs_help" : "unavailable",
|
|
211
261
|
last_health_at: now(),
|
|
212
|
-
last_health_ok:
|
|
213
|
-
last_health_error:
|
|
262
|
+
last_health_ok: false,
|
|
263
|
+
last_health_error: postHealth.error || error,
|
|
214
264
|
last_reconcile_operation_id: receipt?.operationId || null,
|
|
265
|
+
consecutive_reconcile_failures: failures,
|
|
266
|
+
});
|
|
267
|
+
appendSupervisorEvent({
|
|
268
|
+
kind: exhausted ? "surface_needs_help" : "surface_reconcile_failed",
|
|
269
|
+
surface_id: id,
|
|
270
|
+
operation_id: receipt?.operationId || null,
|
|
271
|
+
command_completed: commandCompleted,
|
|
272
|
+
health_ok: false,
|
|
273
|
+
error: postHealth.error || error,
|
|
274
|
+
consecutive_failures: failures,
|
|
275
|
+
...(exhausted ? { message: `${id} failed to come up after ${failures} consecutive restart attempts — clauth has stopped auto-restarting it. Check its log, fix the underlying cause, then Start/Restart it manually (dashboard or \`clauth mcp restart ${id}\`) to resume auto-repair.` } : {}),
|
|
276
|
+
});
|
|
277
|
+
inspected.push({
|
|
278
|
+
surface_id: id,
|
|
279
|
+
state: exhausted ? "needs_help" : "reconcile_failed",
|
|
280
|
+
error: postHealth.error || error,
|
|
281
|
+
operation_id: receipt?.operationId || null,
|
|
282
|
+
consecutive_failures: failures,
|
|
283
|
+
observed_at: observedAt,
|
|
215
284
|
});
|
|
216
|
-
appendSupervisorEvent({ kind: repaired ? "surface_reconciled" : "surface_reconcile_failed", surface_id: id, operation_id: receipt?.operationId || null, command_completed: commandCompleted, health_ok: postHealth.healthy, error: postHealth.error || null });
|
|
217
|
-
inspected.push({ surface_id: id, state: repaired ? "reconciled" : "reconcile_failed", error: postHealth.error || error, operation_id: receipt?.operationId || null, observed_at: observedAt });
|
|
218
285
|
}
|
|
219
286
|
return { inspected };
|
|
220
287
|
}
|
|
@@ -1245,6 +1312,22 @@ export function runSurfaceAction(id, action, actor = "localhost") {
|
|
|
1245
1312
|
fallbackUsed = true;
|
|
1246
1313
|
result.stderr = `restart exited ${restartStatus}; start fallback attempted\n${result.stderr || ""}`;
|
|
1247
1314
|
}
|
|
1315
|
+
// A deliberate, successful manual action — start/stop/restart, never
|
|
1316
|
+
// "reconcile" itself — is a human or agent saying "I looked at this."
|
|
1317
|
+
// Give the surface a fresh crash-loop budget instead of carrying forward a
|
|
1318
|
+
// counter (or a "needs_help" latch) from before the intervention. Only
|
|
1319
|
+
// reconcileSurfaceHealth's own automatic attempts count against
|
|
1320
|
+
// MAX_CONSECUTIVE_RECONCILE_FAILURES, so this is the other side of that
|
|
1321
|
+
// cap: the way OUT of "needs_help", not just the way IN. Reset does not
|
|
1322
|
+
// claim the surface is healthy again — it has done no health probe — it
|
|
1323
|
+
// only clears the latch so the next reconcile tick (or health probe) gets
|
|
1324
|
+
// to judge it fresh.
|
|
1325
|
+
if (action !== "reconcile" && result.status === 0) {
|
|
1326
|
+
updateSurfaceState(id, {
|
|
1327
|
+
consecutive_reconcile_failures: 0,
|
|
1328
|
+
state: surface.state === "needs_help" ? "unavailable" : surface.state,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1248
1331
|
return operation(action, { surface_id: id }, surface, {
|
|
1249
1332
|
ok: result.status === 0,
|
|
1250
1333
|
state: result.status === 0 ? "operation_completed" : "operation_failed",
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
isMcpServerPlugin,
|
|
13
13
|
listPlugins,
|
|
14
14
|
listSurfaces,
|
|
15
|
+
MAX_CONSECUTIVE_RECONCILE_FAILURES,
|
|
15
16
|
probeAllSurfaceHealth,
|
|
16
17
|
surfaceOpenUrl,
|
|
18
|
+
readSupervisorEvents,
|
|
17
19
|
reconcileSurfaceHealth,
|
|
18
20
|
registerPlugin,
|
|
19
21
|
runPluginAction,
|
|
@@ -60,6 +62,33 @@ async function withTempSupervisor(fn) {
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// Fast-forwards Date.now()/`new Date()` without real sleeping.
|
|
66
|
+
// HEALTH_RECONCILE_COOLDOWN_MS is a real 15s per attempt, and proving the
|
|
67
|
+
// crash-loop cap needs MAX_CONSECUTIVE_RECONCILE_FAILURES of them -- a test
|
|
68
|
+
// cannot afford to actually sleep that long. Overriding ONLY Date.now() is
|
|
69
|
+
// not enough: now() (supervisor-registry.js) stamps last_reconcile_at via
|
|
70
|
+
// `new Date().toISOString()`, so the cooldown gate (Date.now() minus
|
|
71
|
+
// Date.parse(last_reconcile_at)) would compare a mocked "now" against a REAL
|
|
72
|
+
// timestamp and never agree. Subclassing keeps every other Date behavior
|
|
73
|
+
// (Date.parse, `new Date(iso)`) real.
|
|
74
|
+
async function withMockedClock(fn) {
|
|
75
|
+
const RealDate = global.Date;
|
|
76
|
+
let clock = RealDate.now();
|
|
77
|
+
class MockDate extends RealDate {
|
|
78
|
+
constructor(...args) {
|
|
79
|
+
if (args.length === 0) super(clock);
|
|
80
|
+
else super(...args);
|
|
81
|
+
}
|
|
82
|
+
static now() { return clock; }
|
|
83
|
+
}
|
|
84
|
+
global.Date = MockDate;
|
|
85
|
+
try {
|
|
86
|
+
return await fn({ advance: (ms) => { clock += ms; } });
|
|
87
|
+
} finally {
|
|
88
|
+
global.Date = RealDate;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
function writePlugin(root, source, id, manifest) {
|
|
64
93
|
const dir = path.join(root, source, id);
|
|
65
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -453,6 +482,135 @@ test("health reconciliation does not claim current when a successful command lea
|
|
|
453
482
|
}
|
|
454
483
|
});
|
|
455
484
|
|
|
485
|
+
test("a crash-looping surface stops auto-restarting after MAX_CONSECUTIVE_RECONCILE_FAILURES and needs a human", async () => {
|
|
486
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-crashloop-"));
|
|
487
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
488
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
489
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
490
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
491
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
492
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
493
|
+
try {
|
|
494
|
+
// restart "succeeds" (exit 0 -- pm2 would accept it) but health NEVER
|
|
495
|
+
// recovers: exactly the regen-media-local shape -- a process that
|
|
496
|
+
// restarts cleanly and crashes immediately after, forever.
|
|
497
|
+
writePlugin(root, "managed", "crashloop-demo", baseManifest("crashloop-demo", {
|
|
498
|
+
core: true,
|
|
499
|
+
enable_default: true,
|
|
500
|
+
surfaces: [{
|
|
501
|
+
id: "primary",
|
|
502
|
+
destination: "local/clauth/pm2",
|
|
503
|
+
lifecycle_owner: "clauth",
|
|
504
|
+
port: 39121,
|
|
505
|
+
health: "/health",
|
|
506
|
+
restart: [process.execPath, "--version"],
|
|
507
|
+
start: [process.execPath, "--version"],
|
|
508
|
+
}],
|
|
509
|
+
}));
|
|
510
|
+
discoverPlugins();
|
|
511
|
+
const alwaysDown = { fetchImpl: async () => ({ ok: false, status: 503 }) };
|
|
512
|
+
|
|
513
|
+
await withMockedClock(async ({ advance }) => {
|
|
514
|
+
let last;
|
|
515
|
+
for (let i = 0; i < MAX_CONSECUTIVE_RECONCILE_FAILURES; i++) {
|
|
516
|
+
advance(20000); // past the 15s reconcile cooldown every tick
|
|
517
|
+
last = await reconcileSurfaceHealth(alwaysDown);
|
|
518
|
+
}
|
|
519
|
+
const surface = listSurfaces().find((item) => item.plugin_id === "crashloop-demo");
|
|
520
|
+
assert.equal(surface.state, "needs_help", "must latch once the cap is reached");
|
|
521
|
+
assert.equal(surface.consecutive_reconcile_failures, MAX_CONSECUTIVE_RECONCILE_FAILURES);
|
|
522
|
+
assert.equal(last.inspected[0].state, "needs_help");
|
|
523
|
+
|
|
524
|
+
// THE load-bearing assertion: one more tick past cooldown must NOT
|
|
525
|
+
// spawn another restart. Count "reconcile" operation receipts before
|
|
526
|
+
// and after -- if the count moves, the loop never actually stopped.
|
|
527
|
+
const before = readSupervisorEvents(1000).filter((e) => e.kind === "operation" && e.action === "reconcile").length;
|
|
528
|
+
advance(20000);
|
|
529
|
+
const again = await reconcileSurfaceHealth(alwaysDown);
|
|
530
|
+
const after = readSupervisorEvents(1000).filter((e) => e.kind === "operation" && e.action === "reconcile").length;
|
|
531
|
+
assert.equal(after, before, "a latched surface must not be re-restarted -- that is the crash loop this cap exists to stop");
|
|
532
|
+
assert.equal(again.inspected[0].state, "needs_help");
|
|
533
|
+
assert.equal(again.inspected[0].latched, true);
|
|
534
|
+
|
|
535
|
+
// Self-heal: health recovering on its own (independent of clauth's
|
|
536
|
+
// restart) clears the latch -- the cap is a circuit breaker, not a
|
|
537
|
+
// permanent ban.
|
|
538
|
+
advance(20000);
|
|
539
|
+
const healed = await reconcileSurfaceHealth({ fetchImpl: async () => ({ ok: true, status: 200 }) });
|
|
540
|
+
assert.equal(healed.inspected[0].state, "healthy");
|
|
541
|
+
const healedSurface = listSurfaces().find((item) => item.plugin_id === "crashloop-demo");
|
|
542
|
+
assert.equal(healedSurface.state, "current");
|
|
543
|
+
assert.equal(healedSurface.consecutive_reconcile_failures, 0);
|
|
544
|
+
});
|
|
545
|
+
} finally {
|
|
546
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
547
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
548
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
549
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
550
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
551
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
552
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("an explicit manual restart clears a needs_help latch and gives a fresh crash-loop budget", async () => {
|
|
557
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-manual-reset-"));
|
|
558
|
+
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|
|
559
|
+
const oldManaged = process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
560
|
+
const oldUser = process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
561
|
+
process.env.CLAUTH_SUPERVISOR_DIR = root;
|
|
562
|
+
process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = path.join(root, "managed");
|
|
563
|
+
process.env.CLAUTH_USER_PLUGIN_ROOTS = path.join(root, "user");
|
|
564
|
+
try {
|
|
565
|
+
writePlugin(root, "managed", "manual-reset-demo", baseManifest("manual-reset-demo", {
|
|
566
|
+
core: true,
|
|
567
|
+
enable_default: true,
|
|
568
|
+
surfaces: [{
|
|
569
|
+
id: "primary",
|
|
570
|
+
destination: "local/clauth/pm2",
|
|
571
|
+
lifecycle_owner: "clauth",
|
|
572
|
+
port: 39122,
|
|
573
|
+
health: "/health",
|
|
574
|
+
restart: [process.execPath, "--version"],
|
|
575
|
+
start: [process.execPath, "--version"],
|
|
576
|
+
}],
|
|
577
|
+
}));
|
|
578
|
+
discoverPlugins();
|
|
579
|
+
const alwaysDown = { fetchImpl: async () => ({ ok: false, status: 503 }) };
|
|
580
|
+
|
|
581
|
+
await withMockedClock(async ({ advance }) => {
|
|
582
|
+
for (let i = 0; i < MAX_CONSECUTIVE_RECONCILE_FAILURES; i++) {
|
|
583
|
+
advance(20000);
|
|
584
|
+
await reconcileSurfaceHealth(alwaysDown);
|
|
585
|
+
}
|
|
586
|
+
assert.equal(listSurfaces()[0].state, "needs_help");
|
|
587
|
+
|
|
588
|
+
// A human/agent looks at it and issues an explicit restart -- through
|
|
589
|
+
// runSurfaceAction directly, never through the "reconcile" action the
|
|
590
|
+
// background loop uses.
|
|
591
|
+
const receipt = runSurfaceAction("manual-reset-demo:primary", "restart", "human");
|
|
592
|
+
assert.equal(receipt.resulting_state.ok, true);
|
|
593
|
+
const reset = listSurfaces()[0];
|
|
594
|
+
assert.equal(reset.state, "unavailable", "cleared out of needs_help, but not falsely claimed healthy without a probe");
|
|
595
|
+
assert.equal(reset.consecutive_reconcile_failures, 0);
|
|
596
|
+
|
|
597
|
+
// And the surface gets its full budget back, not zero.
|
|
598
|
+
advance(20000);
|
|
599
|
+
const tick = await reconcileSurfaceHealth(alwaysDown);
|
|
600
|
+
assert.equal(tick.inspected[0].state, "reconcile_failed");
|
|
601
|
+
assert.equal(listSurfaces()[0].consecutive_reconcile_failures, 1);
|
|
602
|
+
});
|
|
603
|
+
} finally {
|
|
604
|
+
if (oldDir === undefined) delete process.env.CLAUTH_SUPERVISOR_DIR;
|
|
605
|
+
else process.env.CLAUTH_SUPERVISOR_DIR = oldDir;
|
|
606
|
+
if (oldManaged === undefined) delete process.env.CLAUTH_MANAGED_PLUGIN_ROOTS;
|
|
607
|
+
else process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = oldManaged;
|
|
608
|
+
if (oldUser === undefined) delete process.env.CLAUTH_USER_PLUGIN_ROOTS;
|
|
609
|
+
else process.env.CLAUTH_USER_PLUGIN_ROOTS = oldUser;
|
|
610
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
456
614
|
test("health reconciliation never restarts external or plugin-owned surfaces", async () => {
|
|
457
615
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-supervisor-observe-"));
|
|
458
616
|
const oldDir = process.env.CLAUTH_SUPERVISOR_DIR;
|