@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
|
@@ -2,6 +2,7 @@ import { Option } from 'commander';
|
|
|
2
2
|
import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdHome, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
|
|
3
3
|
import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
|
|
4
4
|
import { defaultEmbedLockDependencies, runWithEmbedLock, } from '../lib/search-index/embed-lock.js';
|
|
5
|
+
import { checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from '../lib/search-index/load-gate.js';
|
|
5
6
|
import { applyIndexSizeLimit } from '../lib/search-index/max-doc-bytes.js';
|
|
6
7
|
import { findHqRoot } from '../utils/manifest.js';
|
|
7
8
|
import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
|
|
@@ -38,10 +39,22 @@ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
|
|
|
38
39
|
dependencies.runQmd(['update'], { cwd: hqRoot });
|
|
39
40
|
if (!embed)
|
|
40
41
|
return { embedded: false };
|
|
41
|
-
const runEmbed = () => dependencies.runQmd(['embed'], { cwd: hqRoot });
|
|
42
42
|
const lockDependencies = dependencies.embedLockDependencies ?? defaultEmbedLockDependencies();
|
|
43
|
-
const outcome = runWithEmbedLock(resolveQmdHome(lockDependencies.env), lockDependencies, runEmbed);
|
|
44
43
|
const writeStderr = dependencies.writeStderr ?? ((text) => process.stderr.write(text));
|
|
44
|
+
const home = resolveQmdHome(lockDependencies.env);
|
|
45
|
+
const loadGateDependencies = dependencies.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
|
|
46
|
+
const outcome = runWithEmbedLock(home, lockDependencies, () => {
|
|
47
|
+
const decision = checkEmbedLoad(home, loadGateDependencies);
|
|
48
|
+
if (decision.action === 'skip') {
|
|
49
|
+
writeStderr(decision.notice);
|
|
50
|
+
return 'skipped';
|
|
51
|
+
}
|
|
52
|
+
if (decision.forced)
|
|
53
|
+
writeStderr(decision.notice);
|
|
54
|
+
dependencies.runQmd(['embed'], { cwd: hqRoot });
|
|
55
|
+
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
56
|
+
return undefined;
|
|
57
|
+
});
|
|
45
58
|
if (outcome === 'busy')
|
|
46
59
|
writeStderr('hq: qmd embed is already running; skipping this pass.\n');
|
|
47
60
|
if (outcome === 'unavailable')
|
package/dist/commands/reindex.js
CHANGED
|
@@ -38,6 +38,24 @@ const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh
|
|
|
38
38
|
const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
|
|
39
39
|
const WORKTREE_GIT_TIMEOUT_MS = 2_000;
|
|
40
40
|
const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* Ceiling for the shipped hook checker. Sized for the SLOWEST legitimate case,
|
|
43
|
+
* not the fast one: an older core whose checker still runs an unscoped
|
|
44
|
+
* `hq doctor`, on a loaded host, takes tens of seconds. Undercutting that would
|
|
45
|
+
* convert a working checker into a permanent timeout. A wedged checker is still
|
|
46
|
+
* bounded, which is the point.
|
|
47
|
+
*/
|
|
48
|
+
const HOOK_CHECK_TIMEOUT_MS = 60_000;
|
|
49
|
+
/** Ceiling override, in milliseconds. Present so tests need not wait a minute. */
|
|
50
|
+
function hookCheckTimeoutMs() {
|
|
51
|
+
const raw = process.env.HQ_HOOK_CHECK_TIMEOUT_MS?.trim();
|
|
52
|
+
if (raw) {
|
|
53
|
+
const parsed = Number(raw);
|
|
54
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
return HOOK_CHECK_TIMEOUT_MS;
|
|
58
|
+
}
|
|
41
59
|
/** Resolve the same root the repair/check commands must operate on. */
|
|
42
60
|
function resolveHqRoot(repoRoot) {
|
|
43
61
|
const root = repoRoot ?? findHqRoot();
|
|
@@ -335,16 +353,37 @@ function hasCommandHook(value) {
|
|
|
335
353
|
* Run the release health checker when it is available. Its diagnostics cover
|
|
336
354
|
* runtime/configuration issues outside the safe repair scope; the JSON check
|
|
337
355
|
* below additionally recognizes UserPromptSubmit, which older checkers omit.
|
|
356
|
+
*
|
|
357
|
+
* Bounded, because reindex runs from Stop / PostToolUse lifecycle hooks and the
|
|
358
|
+
* checker shells into `hq doctor`: an unbounded wait here is an unbounded stall
|
|
359
|
+
* in front of the agent.
|
|
360
|
+
*
|
|
361
|
+
* An overrun is reported as a FAILED check, not as an absent one. Those two
|
|
362
|
+
* look identical to `spawnSync` (both surface as an error) but mean opposite
|
|
363
|
+
* things: absent is "this tree ships no checker, nothing more to learn", while
|
|
364
|
+
* an overrun is "the checker exists and we did not hear back". Collapsing the
|
|
365
|
+
* second into the first would let the slowest trees — exactly the ones running
|
|
366
|
+
* an older, unscoped checker — report healthy while the drift only this checker
|
|
367
|
+
* detects goes unseen.
|
|
338
368
|
*/
|
|
339
369
|
function runShippedHookCheck(hqRoot) {
|
|
340
370
|
const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
|
|
341
371
|
if (!fs.existsSync(checker))
|
|
342
372
|
return undefined;
|
|
373
|
+
const budgetMs = hookCheckTimeoutMs();
|
|
343
374
|
try {
|
|
344
375
|
const result = spawnSync('bash', [checker, '--root', hqRoot], {
|
|
345
376
|
encoding: 'utf8',
|
|
346
377
|
stdio: 'pipe',
|
|
378
|
+
timeout: budgetMs,
|
|
347
379
|
});
|
|
380
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
381
|
+
return {
|
|
382
|
+
status: 1,
|
|
383
|
+
output: `the shipped ${HOOK_CHECK_RELATIVE_PATH} check did not finish within ` +
|
|
384
|
+
`${Math.round(budgetMs / 1000)}s, so hook health could not be determined`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
348
387
|
if (result.error)
|
|
349
388
|
return undefined;
|
|
350
389
|
return {
|
|
@@ -356,8 +395,17 @@ function runShippedHookCheck(hqRoot) {
|
|
|
356
395
|
return undefined;
|
|
357
396
|
}
|
|
358
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Classify the tree's hook configuration, cheapest signal first.
|
|
400
|
+
*
|
|
401
|
+
* The shipped checker runs LAST and only when everything else looks healthy.
|
|
402
|
+
* That ordering is not a micro-optimisation: its result is consulted in exactly
|
|
403
|
+
* one branch — the final healthy/minor decision — so on a tree that is already
|
|
404
|
+
* extreme or already minor it is a subprocess whose answer is discarded. Since
|
|
405
|
+
* reindex fires on every Stop / PostToolUse hook, that was the single most
|
|
406
|
+
* expensive discarded result in the command.
|
|
407
|
+
*/
|
|
359
408
|
function inspectHookHealth(hqRoot) {
|
|
360
|
-
const checkerResult = runShippedHookCheck(hqRoot);
|
|
361
409
|
const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
|
|
362
410
|
if (!fs.existsSync(settingsPath)) {
|
|
363
411
|
return { state: 'extreme', reason: '.claude/settings.json is missing' };
|
|
@@ -386,6 +434,7 @@ function inspectHookHealth(hqRoot) {
|
|
|
386
434
|
reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
|
|
387
435
|
};
|
|
388
436
|
}
|
|
437
|
+
const checkerResult = runShippedHookCheck(hqRoot);
|
|
389
438
|
if (checkerResult !== undefined && checkerResult.status !== 0) {
|
|
390
439
|
return {
|
|
391
440
|
state: 'minor',
|
|
@@ -23,6 +23,12 @@ export function messagesStreamEvents(line) {
|
|
|
23
23
|
if (ev.parent_tool_use_id !== null && ev.parent_tool_use_id !== undefined)
|
|
24
24
|
return [];
|
|
25
25
|
const message = ev.message && typeof ev.message === "object" ? ev.message : null;
|
|
26
|
+
// Claude Code wraps its own notices (a dead login: "Not logged in", "Failed
|
|
27
|
+
// to authenticate…") in an assistant message from model "<synthetic>". That
|
|
28
|
+
// is the tool talking, not the model: never post it as progress, so a
|
|
29
|
+
// sign-in failure is still recognised as one (the result event carries it).
|
|
30
|
+
if (message?.model === "<synthetic>")
|
|
31
|
+
return [];
|
|
26
32
|
const content = Array.isArray(message?.content) ? message.content : [];
|
|
27
33
|
const out = [];
|
|
28
34
|
for (const block of content) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type EmbedLockDependencies } from "../search-index/embed-lock.js";
|
|
2
|
+
import { type LoadGateDependencies } from "../search-index/load-gate.js";
|
|
2
3
|
import { type UtilityIo } from "./common.js";
|
|
3
4
|
export type QmdReindexOptions = UtilityIo & {
|
|
4
5
|
cwd?: string;
|
|
@@ -14,6 +15,8 @@ export type QmdReindexOptions = UtilityIo & {
|
|
|
14
15
|
sizeLimit?: (hqRoot: string) => unknown;
|
|
15
16
|
/** Test seams for the lock shared by every hq-cli embed entry point. */
|
|
16
17
|
embedLockDependencies?: EmbedLockDependencies;
|
|
18
|
+
/** Test seams for the host load gate consulted while the embed lock is held. */
|
|
19
|
+
loadGateDependencies?: LoadGateDependencies;
|
|
17
20
|
};
|
|
18
21
|
export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
|
|
19
22
|
//# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
|
|
@@ -14,6 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { reconcileCollections, resolveQmdBin, resolveQmdHome, runQmd } from "../search-index/index.js";
|
|
15
15
|
import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
|
|
16
16
|
import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
|
|
17
|
+
import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
|
|
17
18
|
import { ioFor, line } from "./common.js";
|
|
18
19
|
export function qmdReindexAfterSync(args = [], options = {}) {
|
|
19
20
|
let hqRoot = "";
|
|
@@ -66,15 +67,30 @@ export function qmdReindexAfterSync(args = [], options = {}) {
|
|
|
66
67
|
}
|
|
67
68
|
if (embed) {
|
|
68
69
|
const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
|
|
70
|
+
const home = resolveQmdHome(lockDependencies.env);
|
|
71
|
+
const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
|
|
69
72
|
try {
|
|
70
|
-
const outcome = runWithEmbedLock(
|
|
73
|
+
const outcome = runWithEmbedLock(home, lockDependencies, () => {
|
|
74
|
+
const decision = checkEmbedLoad(home, loadGateDependencies);
|
|
75
|
+
if (decision.action === "skip") {
|
|
76
|
+
line(stderr, decision.notice.trimEnd());
|
|
77
|
+
return "skipped";
|
|
78
|
+
}
|
|
79
|
+
if (decision.forced)
|
|
80
|
+
line(stderr, decision.notice.trimEnd());
|
|
81
|
+
run(["embed"], { bin, cwd: hqRoot });
|
|
82
|
+
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
83
|
+
return undefined;
|
|
84
|
+
});
|
|
71
85
|
if (outcome === "busy") {
|
|
72
86
|
line(stderr, "hq: qmd embed is already running; skipping this pass.");
|
|
73
87
|
}
|
|
74
88
|
if (outcome === "unavailable")
|
|
75
89
|
line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
|
|
76
90
|
}
|
|
77
|
-
catch {
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error instanceof LoadGateConfigurationError)
|
|
93
|
+
line(stderr, `hq: ${error.message}`);
|
|
78
94
|
/* embeddings are deferred-cost and optional */
|
|
79
95
|
}
|
|
80
96
|
}
|
|
@@ -71,6 +71,14 @@ export interface DoctorJsonDocument {
|
|
|
71
71
|
summary: StatusCounts;
|
|
72
72
|
/** The exit code this run produced: 0, or 1 when any FAIL/UNKNOWN is present. */
|
|
73
73
|
exitCode: number;
|
|
74
|
+
/**
|
|
75
|
+
* The check families this run was restricted to (`--only`), in registration
|
|
76
|
+
* order. ABSENT on a full run, which is the only reading a consumer may treat
|
|
77
|
+
* as a verdict on the whole tree: a scoped document reports nothing at all
|
|
78
|
+
* about the families that never ran, and "no FAILs" in it must not be
|
|
79
|
+
* mistaken for a healthy tree.
|
|
80
|
+
*/
|
|
81
|
+
scope?: string[];
|
|
74
82
|
/** The full, flattened result list in family/registration order. */
|
|
75
83
|
results: DoctorJsonResult[];
|
|
76
84
|
}
|
|
@@ -82,6 +90,8 @@ export interface BuildDoctorJsonInput {
|
|
|
82
90
|
families: FamilyRun[];
|
|
83
91
|
/** Detected platform. Defaults to {@link UNKNOWN_PLATFORM}. */
|
|
84
92
|
platform?: DoctorPlatform;
|
|
93
|
+
/** Families this run was restricted to. Omitted on a full run. */
|
|
94
|
+
scope?: readonly string[];
|
|
85
95
|
}
|
|
86
96
|
/** Assemble the machine-readable document from a completed run. */
|
|
87
97
|
export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
|
|
@@ -27,6 +27,20 @@ export declare class DoctorRegistry {
|
|
|
27
27
|
families(): CheckFamily[];
|
|
28
28
|
/** Whether a family with this id is registered. */
|
|
29
29
|
has(id: string): boolean;
|
|
30
|
+
/** The registered family ids, in registration order. */
|
|
31
|
+
ids(): string[];
|
|
32
|
+
/**
|
|
33
|
+
* A registry holding only the named families, still in REGISTRATION order —
|
|
34
|
+
* the caller's argument order is a selection, not a reordering, so a scoped
|
|
35
|
+
* report reads the same way as a full one.
|
|
36
|
+
*
|
|
37
|
+
* Unknown ids are returned rather than ignored: running zero checks and
|
|
38
|
+
* reporting success would describe a tree nothing looked at.
|
|
39
|
+
*/
|
|
40
|
+
select(ids: readonly string[]): {
|
|
41
|
+
registry: DoctorRegistry;
|
|
42
|
+
unknown: string[];
|
|
43
|
+
};
|
|
30
44
|
/** Run every family in order and collect grouped results. */
|
|
31
45
|
run(context: CheckContext): Promise<FamilyRun[]>;
|
|
32
46
|
}
|
|
@@ -43,6 +43,28 @@ export class DoctorRegistry {
|
|
|
43
43
|
has(id) {
|
|
44
44
|
return this.familiesById.has(id);
|
|
45
45
|
}
|
|
46
|
+
/** The registered family ids, in registration order. */
|
|
47
|
+
ids() {
|
|
48
|
+
return [...this.familiesById.keys()];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A registry holding only the named families, still in REGISTRATION order —
|
|
52
|
+
* the caller's argument order is a selection, not a reordering, so a scoped
|
|
53
|
+
* report reads the same way as a full one.
|
|
54
|
+
*
|
|
55
|
+
* Unknown ids are returned rather than ignored: running zero checks and
|
|
56
|
+
* reporting success would describe a tree nothing looked at.
|
|
57
|
+
*/
|
|
58
|
+
select(ids) {
|
|
59
|
+
const wanted = new Set(ids);
|
|
60
|
+
const unknown = [...wanted].filter((id) => !this.familiesById.has(id));
|
|
61
|
+
const registry = new DoctorRegistry();
|
|
62
|
+
for (const family of this.families()) {
|
|
63
|
+
if (wanted.has(family.id))
|
|
64
|
+
registry.register(family);
|
|
65
|
+
}
|
|
66
|
+
return { registry, unknown };
|
|
67
|
+
}
|
|
46
68
|
/** Run every family in order and collect grouped results. */
|
|
47
69
|
async run(context) {
|
|
48
70
|
const runs = [];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type StdioOptions } from 'node:child_process';
|
|
2
2
|
import { type QmdProcessResult, type RunQmdOptions } from './index.js';
|
|
3
|
+
import { type LoadGateDependencies } from './load-gate.js';
|
|
3
4
|
export type BackgroundResult = {
|
|
4
|
-
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
|
|
5
|
+
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'deferred' | 'completed' | 'update-failed' | 'terminated';
|
|
5
6
|
} | {
|
|
6
7
|
state: 'launched';
|
|
7
8
|
pid: number;
|
|
@@ -27,6 +28,9 @@ export type BackgroundDependencies = {
|
|
|
27
28
|
/** Test seams for signal delivery; production uses the real process. */
|
|
28
29
|
processEvents?: Pick<NodeJS.Process, 'once'>;
|
|
29
30
|
exit?: (code: number) => void;
|
|
31
|
+
/** Test seams for the host load gate consulted immediately before qmd embed. */
|
|
32
|
+
loadGateDependencies?: LoadGateDependencies;
|
|
33
|
+
writeStderr?: (text: string) => void;
|
|
30
34
|
};
|
|
31
35
|
export type BackgroundStatus = {
|
|
32
36
|
lock: 'held' | 'stale' | 'free';
|
|
@@ -5,6 +5,7 @@ import { Sentry } from '../../sentry.js';
|
|
|
5
5
|
import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
|
|
6
6
|
import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-bytes.js';
|
|
7
7
|
import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
|
|
8
|
+
import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from './load-gate.js';
|
|
8
9
|
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
9
10
|
function errnoInfo(error) {
|
|
10
11
|
const e = error;
|
|
@@ -123,6 +124,7 @@ export function defaultBackgroundDependencies(hqRoot) {
|
|
|
123
124
|
applyIndexSizeLimit: defaultApplyIndexSizeLimit,
|
|
124
125
|
runQmd: defaultRunQmd,
|
|
125
126
|
spawnWorker: defaultSpawnWorker,
|
|
127
|
+
writeStderr: (text) => process.stderr.write(text),
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
130
|
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
@@ -350,8 +352,30 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
350
352
|
}
|
|
351
353
|
if (await signalWindow())
|
|
352
354
|
return { state: 'terminated' };
|
|
355
|
+
const loadGateDependencies = dependencies.loadGateDependencies ?? defaultLoadGateDependencies(dependencies.env);
|
|
356
|
+
let loadDecision;
|
|
357
|
+
try {
|
|
358
|
+
loadDecision = checkEmbedLoad(home, loadGateDependencies);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error instanceof LoadGateConfigurationError) {
|
|
362
|
+
(dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(`hq: ${error.message}\n`);
|
|
363
|
+
cleanup();
|
|
364
|
+
return { state: 'deferred' };
|
|
365
|
+
}
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
if (loadDecision.action === 'skip') {
|
|
369
|
+
(dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
|
|
370
|
+
writeCompletion(home, dependencies);
|
|
371
|
+
cleanup();
|
|
372
|
+
return { state: 'deferred' };
|
|
373
|
+
}
|
|
374
|
+
if (loadDecision.forced)
|
|
375
|
+
(dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
|
|
353
376
|
try {
|
|
354
377
|
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
|
|
378
|
+
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
355
379
|
}
|
|
356
380
|
catch (error) {
|
|
357
381
|
appendWorkerLog(logPath, errorOutput(error)); // a completed embed attempt still permits the stamp
|
|
@@ -10,7 +10,7 @@ export type EmbedLockDependencies = {
|
|
|
10
10
|
export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
|
|
11
11
|
removeListener?: NodeJS.Process['removeListener'];
|
|
12
12
|
};
|
|
13
|
-
export type EmbedLockRunResult = 'ran' | 'busy' | 'unavailable';
|
|
13
|
+
export type EmbedLockRunResult = 'ran' | 'skipped' | 'busy' | 'unavailable';
|
|
14
14
|
/**
|
|
15
15
|
* Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
|
|
16
16
|
* another tool that does not call this module) cannot participate, because qmd
|
|
@@ -34,5 +34,5 @@ export declare function installEmbedLockCleanup(cleanup: () => void, processEven
|
|
|
34
34
|
* termination. An interrupted owner is safely reclaimed through the lock's
|
|
35
35
|
* existing staleness and owner-grace protocol.
|
|
36
36
|
*/
|
|
37
|
-
export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => void): EmbedLockRunResult;
|
|
37
|
+
export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => 'skipped' | void): EmbedLockRunResult;
|
|
38
38
|
//# sourceMappingURL=embed-lock.d.ts.map
|
|
@@ -276,8 +276,7 @@ export function runWithEmbedLock(home, dependencies, run) {
|
|
|
276
276
|
if (acquired === 'busy')
|
|
277
277
|
return 'busy';
|
|
278
278
|
try {
|
|
279
|
-
run();
|
|
280
|
-
return 'ran';
|
|
279
|
+
return run() === 'skipped' ? 'skipped' : 'ran';
|
|
281
280
|
}
|
|
282
281
|
finally {
|
|
283
282
|
releaseEmbedLock(home, dependencies);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
export declare const DEFAULT_MAX_LOAD_PERCENT = 50;
|
|
3
|
+
/**
|
|
4
|
+
* Twelve requests is enough to avoid quietly starving a frequently requested
|
|
5
|
+
* index, while still leaving several opportunities for a busy machine to cool
|
|
6
|
+
* down before we spend CPU on an embed. The thirteenth request is forced.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_CONSECUTIVE_DEFERRED_EMBEDS = 12;
|
|
9
|
+
/** Six hours bounds deferral even when indexing requests are infrequent. */
|
|
10
|
+
export declare const MAX_EMBED_DEFERRAL_SECONDS: number;
|
|
11
|
+
export type LoadGateDependencies = {
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
now: () => number;
|
|
14
|
+
readFile: (file: string) => string;
|
|
15
|
+
writeFile: (file: string, contents: string) => void;
|
|
16
|
+
rename: (source: string, destination: string) => void;
|
|
17
|
+
mkdir: (directory: string) => void;
|
|
18
|
+
/** A synchronous seam so the existing synchronous qmd entry points stay synchronous. */
|
|
19
|
+
sleep: (milliseconds: number) => void;
|
|
20
|
+
cpus: () => os.CpuInfo[];
|
|
21
|
+
/** Only used outside Linux, where /proc/meminfo is unavailable. */
|
|
22
|
+
freemem: () => number;
|
|
23
|
+
totalmem: () => number;
|
|
24
|
+
};
|
|
25
|
+
type CpuCounters = {
|
|
26
|
+
total: number;
|
|
27
|
+
idle: number;
|
|
28
|
+
};
|
|
29
|
+
export type EmbedLoadDecision = {
|
|
30
|
+
action: 'embed';
|
|
31
|
+
forced: false;
|
|
32
|
+
} | {
|
|
33
|
+
action: 'embed';
|
|
34
|
+
forced: true;
|
|
35
|
+
notice: string;
|
|
36
|
+
} | {
|
|
37
|
+
action: 'skip';
|
|
38
|
+
signal: 'cpu' | 'memory';
|
|
39
|
+
percent: number;
|
|
40
|
+
limit: number;
|
|
41
|
+
notice: string;
|
|
42
|
+
};
|
|
43
|
+
export declare class LoadGateConfigurationError extends Error {
|
|
44
|
+
constructor(value: string);
|
|
45
|
+
}
|
|
46
|
+
export declare function defaultLoadGateDependencies(env?: NodeJS.ProcessEnv): LoadGateDependencies;
|
|
47
|
+
export declare function parseProcStatAggregate(contents: string): CpuCounters | undefined;
|
|
48
|
+
export declare function cpuBusyPercent(before: CpuCounters, after: CpuCounters): number | undefined;
|
|
49
|
+
/** Sample aggregate CPU busy time over 200 ms, falling back only without /proc/stat. */
|
|
50
|
+
export declare function measureCpuPercent(dependencies: LoadGateDependencies): number;
|
|
51
|
+
export declare function memAvailableFraction(contents: string): number | undefined;
|
|
52
|
+
/** Prefer Linux MemAvailable, because MemFree alone treats reclaimable cache as used memory. */
|
|
53
|
+
export declare function measureMemoryPercent(dependencies: LoadGateDependencies): number;
|
|
54
|
+
export declare function parseLoadThreshold(env: NodeJS.ProcessEnv): number | undefined;
|
|
55
|
+
export declare function loadGateStatePath(home: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Decide immediately before qmd embed whether this host can absorb it. The
|
|
58
|
+
* caller must already own the shared embed lock, so state updates are atomic
|
|
59
|
+
* with respect to hq-cli's other embed entry points.
|
|
60
|
+
*/
|
|
61
|
+
export declare function checkEmbedLoad(home: string, dependencies: LoadGateDependencies): EmbedLoadDecision;
|
|
62
|
+
/** Reset the starvation counter only after qmd embed has actually returned successfully. */
|
|
63
|
+
export declare function recordSuccessfulEmbed(home: string, dependencies: LoadGateDependencies): void;
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=load-gate.d.ts.map
|