Skip to content

Commit 64f13e5

Browse files
neo-opus-adatobiu
andauthored
feat(ai): hung-heavy-lease watchdog decision (pure) — sub of #13624 (#13760) (#13761)
Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent f0f0954 commit 64f13e5

2 files changed

Lines changed: 78 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @module Neo.ai.daemons.orchestrator.services.leaseWatchdog
3+
* @summary Pure decision for the hung-heavy-lease watchdog. The exclusive-heavy-maintenance lease
4+
* releases on pid-death + TTL-expiry (`isLeaseStale` in `HeavyMaintenanceLeaseService`), but neither
5+
* catches a holder that is ALIVE + within-TTL yet HUNG (sustained ~0% cpu — e.g. blocked on a wedged
6+
* embedder). A hung holder otherwise monopolizes the lease until the full TTL, starving every other
7+
* maintenance task (the orchestrator DRAIN). The orchestrator lease-monitor loop samples the active
8+
* lease holder's cpu% over time and force-releases on a true verdict; THIS module is the pure verdict
9+
* only — the ps-sampling and the force-release are the integration (kept out of this slice).
10+
*/
11+
12+
/**
13+
* @summary Decides whether the heavy-lease holder is HUNG — a sustained-idle process holding the lease
14+
* without progressing. Returns `true` iff the last `minConsecutiveIdle` cpu samples are ALL at or below
15+
* `idleThresholdPct`: a single idle sample between work bursts is normal, so only a sustained trailing
16+
* run counts as a hang. Total + never-throws (it runs inside the orchestrator loop, where a throw would
17+
* trap the daemon): a non-array, too-short, or malformed sample list returns `false` — fail-SAFE, the
18+
* watchdog never force-releases on bad/insufficient data. A non-finite sample is treated as non-idle
19+
* (same fail-safe: don't conclude "hung" from a missing reading). Exported + unit-tested.
20+
* @param {Object} [options]
21+
* @param {Number[]} [options.cpuPercentSamples=[]] Recent cpu% samples for the lease holder pid, oldest→newest.
22+
* @param {Number} [options.idleThresholdPct=1] A sample at or below this cpu% counts as idle.
23+
* @param {Number} [options.minConsecutiveIdle=3] Consecutive trailing idle samples required to call it hung.
24+
* @returns {Boolean}
25+
*/
26+
export function isHungLeaseHolder({
27+
cpuPercentSamples = [],
28+
idleThresholdPct = 1,
29+
minConsecutiveIdle = 3
30+
} = {}) {
31+
if (!Array.isArray(cpuPercentSamples)) return false;
32+
if (!Number.isFinite(minConsecutiveIdle) || minConsecutiveIdle < 1) return false;
33+
if (cpuPercentSamples.length < minConsecutiveIdle) return false;
34+
35+
return cpuPercentSamples
36+
.slice(-minConsecutiveIdle)
37+
.every(sample => Number.isFinite(sample) && sample <= idleThresholdPct);
38+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {test, expect} from '@playwright/test';
2+
import {isHungLeaseHolder} from '../../../../../../../ai/daemons/orchestrator/services/leaseWatchdog.mjs';
3+
4+
/**
5+
* Coverage for the pure hung-heavy-lease watchdog decision (sub of the orchestrator heavy-maintenance
6+
* epic). Catches the alive + within-TTL but sustained-idle holder the pid/TTL stale-check misses. The
7+
* sustained-run semantics + the fail-SAFE-on-bad-data contract are pinned here.
8+
*/
9+
test.describe('ai/daemons/orchestrator/services/leaseWatchdog — isHungLeaseHolder', () => {
10+
test('a sustained trailing-idle run → hung (true)', () => {
11+
expect(isHungLeaseHolder({cpuPercentSamples: [42, 0, 0.2, 0.1]})).toBe(true);
12+
});
13+
14+
test('an active sample anywhere in the trailing window → not hung (false)', () => {
15+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 0, 35]})).toBe(false); // newest active
16+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 35, 0]})).toBe(false); // active mid-window
17+
});
18+
19+
test('too few samples → false (cannot conclude a sustained hang)', () => {
20+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 0]})).toBe(false); // < minConsecutiveIdle (3)
21+
});
22+
23+
test('boundary: a sample exactly at the threshold counts as idle', () => {
24+
expect(isHungLeaseHolder({cpuPercentSamples: [1, 1, 1], idleThresholdPct: 1})).toBe(true);
25+
expect(isHungLeaseHolder({cpuPercentSamples: [1.1, 1, 1], idleThresholdPct: 1})).toBe(false); // 1.1 > 1
26+
});
27+
28+
test('respects a custom minConsecutiveIdle window', () => {
29+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 0], minConsecutiveIdle: 2})).toBe(true);
30+
expect(isHungLeaseHolder({cpuPercentSamples: [5, 0], minConsecutiveIdle: 2})).toBe(false);
31+
});
32+
33+
test('fail-SAFE on malformed / insufficient input (never force-release on bad data)', () => {
34+
expect(isHungLeaseHolder()).toBe(false); // no args
35+
expect(isHungLeaseHolder({cpuPercentSamples: 'nope'})).toBe(false); // non-array
36+
expect(isHungLeaseHolder({cpuPercentSamples: []})).toBe(false); // empty
37+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 0, NaN]})).toBe(false); // non-finite → not idle
38+
expect(isHungLeaseHolder({cpuPercentSamples: [0, 0, 0], minConsecutiveIdle: 0})).toBe(false); // bad window
39+
});
40+
});

0 commit comments

Comments
 (0)