@indigoai-us/hq-cli 5.115.3 → 5.115.5
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/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +102 -5
- package/dist/command-catalog.generated.d.ts +4 -1
- package/dist/command-catalog.generated.js +5 -1
- package/dist/commands/core-checkpoint.d.ts +7 -0
- package/dist/commands/core-checkpoint.js +131 -12
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +59 -7
- package/dist/commands/index-cmd.d.ts +3 -0
- package/dist/commands/index-cmd.js +15 -2
- package/dist/commands/reindex.js +50 -1
- package/dist/lib/bot/runtime/messages-stream.js +6 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +3 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +18 -2
- package/dist/lib/doctor/json-output.d.ts +10 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.d.ts +14 -0
- package/dist/lib/doctor/registry.js +22 -0
- package/dist/lib/search-index/background.d.ts +5 -1
- package/dist/lib/search-index/background.js +24 -0
- package/dist/lib/search-index/embed-lock.d.ts +2 -2
- package/dist/lib/search-index/embed-lock.js +1 -2
- package/dist/lib/search-index/load-gate.d.ts +65 -0
- package/dist/lib/search-index/load-gate.js +270 -0
- package/dist/lib/workers-registry/index.js +37 -9
- package/package.json +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { lockRoot } from './embed-lock.js';
|
|
5
|
+
export const DEFAULT_MAX_LOAD_PERCENT = 50;
|
|
6
|
+
/**
|
|
7
|
+
* Twelve requests is enough to avoid quietly starving a frequently requested
|
|
8
|
+
* index, while still leaving several opportunities for a busy machine to cool
|
|
9
|
+
* down before we spend CPU on an embed. The thirteenth request is forced.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_CONSECUTIVE_DEFERRED_EMBEDS = 12;
|
|
12
|
+
/** Six hours bounds deferral even when indexing requests are infrequent. */
|
|
13
|
+
export const MAX_EMBED_DEFERRAL_SECONDS = 6 * 60 * 60;
|
|
14
|
+
const LOAD_GATE_STATE_NAME = 'qmd-embed-load-state';
|
|
15
|
+
const PROC_STAT = '/proc/stat';
|
|
16
|
+
const PROC_MEMINFO = '/proc/meminfo';
|
|
17
|
+
export class LoadGateConfigurationError extends Error {
|
|
18
|
+
constructor(value) {
|
|
19
|
+
super(`HQ_INDEX_MAX_LOAD_PERCENT must be an integer from 1 to 100, 0, or off; received ${JSON.stringify(value)}.`);
|
|
20
|
+
this.name = 'LoadGateConfigurationError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function synchronousSleep(milliseconds) {
|
|
24
|
+
const slot = new Int32Array(new SharedArrayBuffer(4));
|
|
25
|
+
Atomics.wait(slot, 0, 0, milliseconds);
|
|
26
|
+
}
|
|
27
|
+
export function defaultLoadGateDependencies(env = process.env) {
|
|
28
|
+
return {
|
|
29
|
+
env,
|
|
30
|
+
now: () => Math.floor(Date.now() / 1_000),
|
|
31
|
+
readFile: (file) => fs.readFileSync(file, 'utf8'),
|
|
32
|
+
writeFile: (file, contents) => fs.writeFileSync(file, contents),
|
|
33
|
+
rename: (source, destination) => fs.renameSync(source, destination),
|
|
34
|
+
mkdir: (directory) => fs.mkdirSync(directory, { recursive: true }),
|
|
35
|
+
sleep: synchronousSleep,
|
|
36
|
+
cpus: () => os.cpus(),
|
|
37
|
+
// os.freemem() reports MemFree on Linux, which excludes reclaimable cache.
|
|
38
|
+
// Use it only where /proc/meminfo does not exist.
|
|
39
|
+
freemem: () => os.freemem(),
|
|
40
|
+
totalmem: () => os.totalmem(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function errorCode(error) {
|
|
44
|
+
return error?.code;
|
|
45
|
+
}
|
|
46
|
+
function missing(error) {
|
|
47
|
+
return errorCode(error) === 'ENOENT';
|
|
48
|
+
}
|
|
49
|
+
export function parseProcStatAggregate(contents) {
|
|
50
|
+
const line = contents.split('\n').find((candidate) => /^cpu\s+/.test(candidate));
|
|
51
|
+
if (!line)
|
|
52
|
+
return undefined;
|
|
53
|
+
const values = line.trim().split(/\s+/).slice(1).map(Number);
|
|
54
|
+
if (values.length < 5 || values.some((value) => !Number.isFinite(value) || value < 0))
|
|
55
|
+
return undefined;
|
|
56
|
+
// Linux reports guest and guest_nice inside user and nice respectively.
|
|
57
|
+
const total = values.slice(0, 8).reduce((sum, value) => sum + value, 0);
|
|
58
|
+
const idle = values[3] + values[4];
|
|
59
|
+
return total > 0 && Number.isFinite(idle) ? { total, idle } : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function cpuBusyPercent(before, after) {
|
|
62
|
+
const totalDelta = after.total - before.total;
|
|
63
|
+
const idleDelta = after.idle - before.idle;
|
|
64
|
+
if (totalDelta <= 0 || idleDelta < 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
return Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
|
|
67
|
+
}
|
|
68
|
+
function cpuCountersFromCpus(cpus) {
|
|
69
|
+
let total = 0;
|
|
70
|
+
let idle = 0;
|
|
71
|
+
if (cpus.length === 0)
|
|
72
|
+
return undefined;
|
|
73
|
+
for (const cpu of cpus) {
|
|
74
|
+
const { user, nice, sys, idle: cpuIdle, irq } = cpu.times;
|
|
75
|
+
const values = [user, nice, sys, cpuIdle, irq];
|
|
76
|
+
if (values.some((value) => !Number.isFinite(value) || value < 0))
|
|
77
|
+
return undefined;
|
|
78
|
+
total += values.reduce((sum, value) => sum + value, 0);
|
|
79
|
+
idle += cpuIdle;
|
|
80
|
+
}
|
|
81
|
+
return total > 0 ? { total, idle } : undefined;
|
|
82
|
+
}
|
|
83
|
+
function fallbackCpuPercent(dependencies) {
|
|
84
|
+
const before = cpuCountersFromCpus(dependencies.cpus());
|
|
85
|
+
dependencies.sleep(200);
|
|
86
|
+
const after = cpuCountersFromCpus(dependencies.cpus());
|
|
87
|
+
const busy = before && after ? cpuBusyPercent(before, after) : undefined;
|
|
88
|
+
if (busy === undefined)
|
|
89
|
+
throw new Error('Cannot determine CPU busy time from os.cpus().');
|
|
90
|
+
return busy;
|
|
91
|
+
}
|
|
92
|
+
/** Sample aggregate CPU busy time over 200 ms, falling back only without /proc/stat. */
|
|
93
|
+
export function measureCpuPercent(dependencies) {
|
|
94
|
+
let first;
|
|
95
|
+
try {
|
|
96
|
+
first = dependencies.readFile(PROC_STAT);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (missing(error))
|
|
100
|
+
return fallbackCpuPercent(dependencies);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
dependencies.sleep(200);
|
|
104
|
+
let second;
|
|
105
|
+
try {
|
|
106
|
+
second = dependencies.readFile(PROC_STAT);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (missing(error))
|
|
110
|
+
return fallbackCpuPercent(dependencies);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const before = parseProcStatAggregate(first);
|
|
114
|
+
const after = parseProcStatAggregate(second);
|
|
115
|
+
const busy = before && after ? cpuBusyPercent(before, after) : undefined;
|
|
116
|
+
if (busy === undefined)
|
|
117
|
+
throw new Error('Cannot determine CPU busy time from /proc/stat.');
|
|
118
|
+
return busy;
|
|
119
|
+
}
|
|
120
|
+
export function memAvailableFraction(contents) {
|
|
121
|
+
const fields = new Map();
|
|
122
|
+
for (const line of contents.split('\n')) {
|
|
123
|
+
const match = /^(MemTotal|MemAvailable):\s+(\d+)\s+kB$/.exec(line.trim());
|
|
124
|
+
if (match)
|
|
125
|
+
fields.set(match[1], Number(match[2]));
|
|
126
|
+
}
|
|
127
|
+
const total = fields.get('MemTotal');
|
|
128
|
+
const available = fields.get('MemAvailable');
|
|
129
|
+
if (total === undefined || available === undefined || total <= 0 || available < 0 || available > total)
|
|
130
|
+
return undefined;
|
|
131
|
+
return 1 - available / total;
|
|
132
|
+
}
|
|
133
|
+
/** Prefer Linux MemAvailable, because MemFree alone treats reclaimable cache as used memory. */
|
|
134
|
+
export function measureMemoryPercent(dependencies) {
|
|
135
|
+
try {
|
|
136
|
+
const fraction = memAvailableFraction(dependencies.readFile(PROC_MEMINFO));
|
|
137
|
+
if (fraction === undefined)
|
|
138
|
+
throw new Error('Cannot determine memory use from /proc/meminfo.');
|
|
139
|
+
return fraction * 100;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!missing(error))
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
const total = dependencies.totalmem();
|
|
146
|
+
const free = dependencies.freemem();
|
|
147
|
+
if (total <= 0 || free < 0 || free > total)
|
|
148
|
+
throw new Error('Cannot determine memory use.');
|
|
149
|
+
return (1 - free / total) * 100;
|
|
150
|
+
}
|
|
151
|
+
export function parseLoadThreshold(env) {
|
|
152
|
+
const value = env.HQ_INDEX_MAX_LOAD_PERCENT?.trim() || String(DEFAULT_MAX_LOAD_PERCENT);
|
|
153
|
+
if (value === '0' || value.toLowerCase() === 'off')
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!/^[1-9]\d{0,2}$/.test(value) || Number(value) > 100)
|
|
156
|
+
throw new LoadGateConfigurationError(value);
|
|
157
|
+
return Number(value);
|
|
158
|
+
}
|
|
159
|
+
export function loadGateStatePath(home) {
|
|
160
|
+
return path.join(lockRoot(home), LOAD_GATE_STATE_NAME);
|
|
161
|
+
}
|
|
162
|
+
function readFields(file, dependencies) {
|
|
163
|
+
try {
|
|
164
|
+
const fields = {};
|
|
165
|
+
for (const line of dependencies.readFile(file).split('\n')) {
|
|
166
|
+
if (line === '')
|
|
167
|
+
continue;
|
|
168
|
+
const index = line.indexOf('=');
|
|
169
|
+
const key = line.slice(0, index);
|
|
170
|
+
if (index < 1 || !['skips', 'firstDeferredAt', 'lastSuccessfulEmbedAt'].includes(key) || fields[key] !== undefined)
|
|
171
|
+
return undefined;
|
|
172
|
+
fields[key] = line.slice(index + 1);
|
|
173
|
+
}
|
|
174
|
+
return fields;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (missing(error))
|
|
178
|
+
return undefined;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function nonnegativeInteger(value) {
|
|
183
|
+
return value !== undefined && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
184
|
+
}
|
|
185
|
+
function readState(home, dependencies) {
|
|
186
|
+
const fields = readFields(loadGateStatePath(home), dependencies);
|
|
187
|
+
if (!fields)
|
|
188
|
+
return { skips: 0 };
|
|
189
|
+
const skips = nonnegativeInteger(fields.skips);
|
|
190
|
+
const firstDeferredAt = nonnegativeInteger(fields.firstDeferredAt);
|
|
191
|
+
const lastSuccessfulEmbedAt = nonnegativeInteger(fields.lastSuccessfulEmbedAt);
|
|
192
|
+
if (skips === undefined
|
|
193
|
+
|| (fields.firstDeferredAt !== undefined && firstDeferredAt === undefined)
|
|
194
|
+
|| (fields.lastSuccessfulEmbedAt !== undefined && lastSuccessfulEmbedAt === undefined)
|
|
195
|
+
|| (skips > 0 && firstDeferredAt === undefined)
|
|
196
|
+
|| (skips === 0 && firstDeferredAt !== undefined))
|
|
197
|
+
return { skips: 0 };
|
|
198
|
+
return {
|
|
199
|
+
skips,
|
|
200
|
+
...(firstDeferredAt === undefined ? {} : { firstDeferredAt }),
|
|
201
|
+
...(lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function writeState(home, state, dependencies) {
|
|
205
|
+
dependencies.mkdir(lockRoot(home));
|
|
206
|
+
const statePath = loadGateStatePath(home);
|
|
207
|
+
const temporaryPath = `${statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
208
|
+
dependencies.writeFile(temporaryPath, [
|
|
209
|
+
`skips=${state.skips}`,
|
|
210
|
+
...(state.firstDeferredAt === undefined ? [] : [`firstDeferredAt=${state.firstDeferredAt}`]),
|
|
211
|
+
...(state.lastSuccessfulEmbedAt === undefined ? [] : [`lastSuccessfulEmbedAt=${state.lastSuccessfulEmbedAt}`]),
|
|
212
|
+
'',
|
|
213
|
+
].join('\n'));
|
|
214
|
+
dependencies.rename(temporaryPath, statePath);
|
|
215
|
+
}
|
|
216
|
+
function rounded(percent) {
|
|
217
|
+
return Math.round(percent);
|
|
218
|
+
}
|
|
219
|
+
function trippedSignal(cpuPercent, memoryPercent, limit) {
|
|
220
|
+
if (cpuPercent >= limit)
|
|
221
|
+
return { signal: 'cpu', percent: rounded(cpuPercent) };
|
|
222
|
+
if (memoryPercent >= limit)
|
|
223
|
+
return { signal: 'memory', percent: rounded(memoryPercent) };
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Decide immediately before qmd embed whether this host can absorb it. The
|
|
228
|
+
* caller must already own the shared embed lock, so state updates are atomic
|
|
229
|
+
* with respect to hq-cli's other embed entry points.
|
|
230
|
+
*/
|
|
231
|
+
export function checkEmbedLoad(home, dependencies) {
|
|
232
|
+
const limit = parseLoadThreshold(dependencies.env);
|
|
233
|
+
if (limit === undefined)
|
|
234
|
+
return { action: 'embed', forced: false };
|
|
235
|
+
const cpuPercent = measureCpuPercent(dependencies);
|
|
236
|
+
const memoryPercent = measureMemoryPercent(dependencies);
|
|
237
|
+
const tripped = trippedSignal(cpuPercent, memoryPercent, limit);
|
|
238
|
+
if (!tripped)
|
|
239
|
+
return { action: 'embed', forced: false };
|
|
240
|
+
const now = dependencies.now();
|
|
241
|
+
const state = readState(home, dependencies);
|
|
242
|
+
const skips = state.skips + 1;
|
|
243
|
+
const deferredSince = state.firstDeferredAt ?? now;
|
|
244
|
+
const exceededSkipBound = skips > MAX_CONSECUTIVE_DEFERRED_EMBEDS;
|
|
245
|
+
const exceededTimeBound = now - deferredSince >= MAX_EMBED_DEFERRAL_SECONDS;
|
|
246
|
+
if (exceededSkipBound || exceededTimeBound) {
|
|
247
|
+
return {
|
|
248
|
+
action: 'embed',
|
|
249
|
+
forced: true,
|
|
250
|
+
notice: `hq: proceeding with qmd embed despite ${tripped.signal} ${tripped.percent}% (limit ${limit}%) because embeddings have been deferred too long.\n`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
writeState(home, {
|
|
254
|
+
skips,
|
|
255
|
+
firstDeferredAt: state.firstDeferredAt ?? now,
|
|
256
|
+
...(state.lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt: state.lastSuccessfulEmbedAt }),
|
|
257
|
+
}, dependencies);
|
|
258
|
+
return {
|
|
259
|
+
action: 'skip',
|
|
260
|
+
signal: tripped.signal,
|
|
261
|
+
percent: tripped.percent,
|
|
262
|
+
limit,
|
|
263
|
+
notice: `hq: skipping qmd embed — ${tripped.signal} ${tripped.percent}% (limit ${limit}%).\n`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Reset the starvation counter only after qmd embed has actually returned successfully. */
|
|
267
|
+
export function recordSuccessfulEmbed(home, dependencies) {
|
|
268
|
+
writeState(home, { skips: 0, lastSuccessfulEmbedAt: dependencies.now() }, dependencies);
|
|
269
|
+
}
|
|
270
|
+
//# sourceMappingURL=load-gate.js.map
|
|
@@ -18,10 +18,26 @@ function isFile(target) {
|
|
|
18
18
|
return false;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A sorted `find -L … -name worker.yaml -type f` equivalent, including
|
|
23
|
+
* symlinked pack workers.
|
|
24
|
+
*
|
|
25
|
+
* Excluded subtrees are pruned at the descent, not filtered from the result.
|
|
26
|
+
* That distinction is the whole cost of this scan: a tenant's `repos/` holds
|
|
27
|
+
* source checkouts, so filtering afterwards means reading every `node_modules`
|
|
28
|
+
* and `.git` directory inside them — tens of thousands of directories on a real
|
|
29
|
+
* root — to produce matches that are discarded a moment later.
|
|
30
|
+
*
|
|
31
|
+
* Directory entries carry their own type, so the common case costs one
|
|
32
|
+
* `readdir` per directory instead of an extra `stat` per entry. Symlinks are
|
|
33
|
+
* the exception: their dirent type describes the link, so resolving one still
|
|
34
|
+
* needs a `stat`, which is what keeps this faithful to `find -L`.
|
|
35
|
+
*/
|
|
22
36
|
function workerYamlFiles(root, relativeRoot) {
|
|
23
37
|
const output = [];
|
|
24
38
|
const walk = (relative, ancestors) => {
|
|
39
|
+
if (isExcludedWorkerDirectory(relative))
|
|
40
|
+
return;
|
|
25
41
|
const absolute = path.join(root, relative);
|
|
26
42
|
if (!isDirectory(absolute))
|
|
27
43
|
return;
|
|
@@ -35,20 +51,23 @@ function workerYamlFiles(root, relativeRoot) {
|
|
|
35
51
|
if (ancestors.has(physical))
|
|
36
52
|
return; // `find -L` reports loops; a bounded walk is safer here.
|
|
37
53
|
const nextAncestors = new Set(ancestors).add(physical);
|
|
38
|
-
let
|
|
54
|
+
let entries;
|
|
39
55
|
try {
|
|
40
|
-
|
|
56
|
+
entries = fs.readdirSync(absolute, { withFileTypes: true });
|
|
41
57
|
}
|
|
42
58
|
catch {
|
|
43
59
|
return;
|
|
44
60
|
}
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const childRelative = path.join(relative, entry.name);
|
|
47
64
|
const child = path.join(root, childRelative);
|
|
48
|
-
if (name === 'worker.yaml' && isFile(child))
|
|
65
|
+
if (entry.name === 'worker.yaml' && (entry.isFile() || isFile(child))) {
|
|
49
66
|
output.push(childRelative);
|
|
50
|
-
|
|
67
|
+
}
|
|
68
|
+
else if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(child))) {
|
|
51
69
|
walk(childRelative, nextAncestors);
|
|
70
|
+
}
|
|
52
71
|
}
|
|
53
72
|
};
|
|
54
73
|
walk(relativeRoot, new Set());
|
|
@@ -139,8 +158,8 @@ function withoutTimestamp(content) {
|
|
|
139
158
|
* both `core` and `personal` — the checkout wins. The operator's actual worker
|
|
140
159
|
* then silently vanishes from the registry every skill reads.
|
|
141
160
|
*/
|
|
142
|
-
function
|
|
143
|
-
const normalized =
|
|
161
|
+
function isExcludedWorkerDirectory(relativeDirectory) {
|
|
162
|
+
const normalized = relativeDirectory.replaceAll('\\', '/');
|
|
144
163
|
const [root, tenant, nested] = normalized.split('/');
|
|
145
164
|
if (root !== 'companies')
|
|
146
165
|
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
@@ -150,6 +169,15 @@ function isExcludedWorkerPath(relativeFile) {
|
|
|
150
169
|
return true;
|
|
151
170
|
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
152
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Every exclusion is a property of the directory holding the `worker.yaml`, so
|
|
174
|
+
* the file rule is the directory rule applied to its parent. Deriving it keeps
|
|
175
|
+
* the pruned descent and this final filter from ever disagreeing — a drift that
|
|
176
|
+
* would either resurrect a shadowing copy or silently drop a real worker.
|
|
177
|
+
*/
|
|
178
|
+
function isExcludedWorkerPath(relativeFile) {
|
|
179
|
+
return isExcludedWorkerDirectory(path.dirname(relativeFile));
|
|
180
|
+
}
|
|
153
181
|
/**
|
|
154
182
|
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
155
183
|
* are quarantined but do not prevent all valid workers from being registered.
|