@indigoai-us/hq-cli 5.115.3 → 5.115.4
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 +38 -0
- 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 +108 -5
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.115.4] — 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Search indexing now leaves qmd embeddings for a later pass when CPU or memory
|
|
9
|
+
use reaches 50%, while still updating the index. Set
|
|
10
|
+
`HQ_INDEX_MAX_LOAD_PERCENT` to another percentage, or to `0` or `off` to
|
|
11
|
+
disable this check. After 12 skipped passes or six hours, hq runs an embed
|
|
12
|
+
despite the load so search results do not fall behind forever.
|
|
13
|
+
- HQ's housekeeping pass (`hq reindex`, which runs after an agent finishes a
|
|
14
|
+
turn) is much faster on large setups. On a root with 21 companies it took
|
|
15
|
+
about two and a half minutes even with nothing to do. Two things were to
|
|
16
|
+
blame. The scan that finds your workers read every folder inside any source
|
|
17
|
+
code you keep under a company, `node_modules` and `.git` included, which on
|
|
18
|
+
that root meant reading 83,950 folders to find 126 worker files. It now skips
|
|
19
|
+
those folders instead of walking them, and takes 0.4 seconds instead of 22.
|
|
20
|
+
Separately, the hook health check ran every `hq doctor` check in order to
|
|
21
|
+
read six of them; it now asks only for the group those six live in.
|
|
22
|
+
- `hq doctor` takes `--only <families>`, so you can run one group of checks
|
|
23
|
+
instead of all of them: `hq doctor --only hooks`. A name it does not
|
|
24
|
+
recognise is refused rather than quietly checking nothing, and `--json`
|
|
25
|
+
records which groups ran, so a partial result cannot be read as a verdict on
|
|
26
|
+
the whole tree.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- When Claude Code's saved login has stopped working, a local bot now says it needs a sign-in and keeps your message for when it works again. Before, the bot relayed Claude Code's own "Failed to authenticate" notice as if it were the model's answer, so HQ never showed the "Sign in again" button.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- You can now switch off the background helper that tidies HQ after a
|
|
35
|
+
checkpoint, without giving up checkpoints themselves. Add
|
|
36
|
+
`"env": { "HQ_CHECKPOINT_AGENT": "0" }` to `.claude/settings.json` in your HQ
|
|
37
|
+
folder and the helper stops running; your checkpoint notes are still saved.
|
|
38
|
+
Set it back to `"1"` to turn it on again. It works the same whether you use
|
|
39
|
+
Claude Code, Codex or Grok, and `settings.local.json` overrides it if you
|
|
40
|
+
keep one. If the value is something the setting does not understand, hq says
|
|
41
|
+
so rather than quietly leaving the helper on.
|
|
42
|
+
|
|
5
43
|
## [5.115.3] — 2026-09-15
|
|
6
44
|
|
|
7
45
|
### Changed
|
|
@@ -4534,7 +4534,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4534
4534
|
readonly description: "touch stamps without a checkpoint";
|
|
4535
4535
|
}, {
|
|
4536
4536
|
readonly flags: "--no-agent";
|
|
4537
|
-
readonly description: "do not spawn the maintenance sibling";
|
|
4537
|
+
readonly description: "do not spawn the maintenance sibling (set it off for good with \"env\": {\"HQ_CHECKPOINT_AGENT\": \"0\"} in .claude/settings.json)";
|
|
4538
4538
|
}, {
|
|
4539
4539
|
readonly flags: "--backend <auto|claude|codex|grok|none>";
|
|
4540
4540
|
readonly description: "sibling backend";
|
|
@@ -4952,6 +4952,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4952
4952
|
}, {
|
|
4953
4953
|
readonly flags: "--company <slug>";
|
|
4954
4954
|
readonly description: "Company slug for integrations checks (otherwise resolves your single active company).";
|
|
4955
|
+
}, {
|
|
4956
|
+
readonly flags: "--only <families>";
|
|
4957
|
+
readonly description: "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.";
|
|
4955
4958
|
}, {
|
|
4956
4959
|
readonly flags: "--fix";
|
|
4957
4960
|
readonly description: "Apply the allowlisted safe repairs (backs up first; read-only without this flag).";
|
|
@@ -5867,7 +5867,7 @@ export const COMMAND_CATALOG = [
|
|
|
5867
5867
|
},
|
|
5868
5868
|
{
|
|
5869
5869
|
"flags": "--no-agent",
|
|
5870
|
-
"description": "do not spawn the maintenance sibling"
|
|
5870
|
+
"description": "do not spawn the maintenance sibling (set it off for good with \"env\": {\"HQ_CHECKPOINT_AGENT\": \"0\"} in .claude/settings.json)"
|
|
5871
5871
|
},
|
|
5872
5872
|
{
|
|
5873
5873
|
"flags": "--backend <auto|claude|codex|grok|none>",
|
|
@@ -6392,6 +6392,10 @@ export const COMMAND_CATALOG = [
|
|
|
6392
6392
|
"flags": "--company <slug>",
|
|
6393
6393
|
"description": "Company slug for integrations checks (otherwise resolves your single active company)."
|
|
6394
6394
|
},
|
|
6395
|
+
{
|
|
6396
|
+
"flags": "--only <families>",
|
|
6397
|
+
"description": "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope."
|
|
6398
|
+
},
|
|
6395
6399
|
{
|
|
6396
6400
|
"flags": "--fix",
|
|
6397
6401
|
"description": "Apply the allowlisted safe repairs (backs up first; read-only without this flag)."
|
|
@@ -59,6 +59,13 @@ export declare function writeStamps(liveRoot: string, sessionId: string | undefi
|
|
|
59
59
|
* tested directly.
|
|
60
60
|
*/
|
|
61
61
|
export declare function autoBackendPreference(): SpawnableBackend[];
|
|
62
|
+
/**
|
|
63
|
+
* Whether the maintenance sibling may run at all. The process environment wins
|
|
64
|
+
* over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
|
|
65
|
+
* checkpoint …` still works and a host that exports the settings `env` block
|
|
66
|
+
* (Claude Code does) agrees with a host that does not.
|
|
67
|
+
*/
|
|
68
|
+
export declare function siblingEnabledBySetting(liveRoot: string): boolean;
|
|
62
69
|
export declare function siblingArgs(backend: SpawnableBackend, prompt: string): string[];
|
|
63
70
|
/** Attach the native checkpoint command to the hidden `hq core` group. */
|
|
64
71
|
export declare function registerCoreCheckpointCommand(core: Command): void;
|
|
@@ -32,6 +32,25 @@ const GROK_SIBLING_MAX_TURNS = "100";
|
|
|
32
32
|
* unavailable, unresponsive, out of credits (surfaces as a crashed run), or
|
|
33
33
|
* simply unknown. First healthy candidate wins.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Durable opt-out for the maintenance sibling, read from the HQ root's
|
|
37
|
+
* `.claude/settings.json` (overlaid by `settings.local.json`, the same order
|
|
38
|
+
* Claude Code merges them):
|
|
39
|
+
*
|
|
40
|
+
* { "env": { "HQ_CHECKPOINT_AGENT": "0" } }
|
|
41
|
+
*
|
|
42
|
+
* `--no-agent` and `--backend none` already turn the sibling off, but only for
|
|
43
|
+
* one invocation — no help when the checkpoint is fired by the Stop gate,
|
|
44
|
+
* where the user has no command line to edit. The sibling is the expensive
|
|
45
|
+
* half of a checkpoint (an unattended agent turn per Stop), so the off switch
|
|
46
|
+
* has to be somewhere a user can set it once. The `env` block is that place:
|
|
47
|
+
* Claude Code already exports it into hook processes, and reading the file
|
|
48
|
+
* directly means the setting also holds for the Codex and grok gates, whose
|
|
49
|
+
* hosts never read it.
|
|
50
|
+
*/
|
|
51
|
+
const SIBLING_SETTING = "HQ_CHECKPOINT_AGENT";
|
|
52
|
+
const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
|
|
53
|
+
const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
|
|
35
54
|
const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
|
|
36
55
|
/** A backend that cannot answer `--version` this fast is treated as broken. */
|
|
37
56
|
const BACKEND_PROBE_TIMEOUT_MS = 10_000;
|
|
@@ -578,10 +597,86 @@ export function autoBackendPreference() {
|
|
|
578
597
|
return [...FALLBACK_BACKEND_ORDER];
|
|
579
598
|
return [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)];
|
|
580
599
|
}
|
|
581
|
-
function
|
|
600
|
+
function settingsEnvValue(liveRoot, key) {
|
|
601
|
+
// settings.local.json wins, matching Claude Code's own merge order.
|
|
602
|
+
for (const file of ["settings.local.json", "settings.json"]) {
|
|
603
|
+
const settingsPath = path.join(liveRoot, ".claude", file);
|
|
604
|
+
let contents;
|
|
605
|
+
try {
|
|
606
|
+
contents = fs.readFileSync(settingsPath, "utf8");
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
continue; // absent overlay or no .claude directory at all
|
|
610
|
+
}
|
|
611
|
+
let env;
|
|
612
|
+
try {
|
|
613
|
+
env = JSON.parse(contents)?.env;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
printError(`checkpoint: ignoring ${settingsPath} (not valid JSON)`);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (!env || typeof env !== "object" || !(key in env))
|
|
620
|
+
continue;
|
|
621
|
+
const raw = env[key];
|
|
622
|
+
if (typeof raw === "string") {
|
|
623
|
+
const trimmed = raw.trim();
|
|
624
|
+
// An empty string is not a deferral to the next file: the key is here and
|
|
625
|
+
// says nothing, which is a mistake worth naming.
|
|
626
|
+
return trimmed
|
|
627
|
+
? { kind: "value", value: trimmed, source: settingsPath }
|
|
628
|
+
: { kind: "invalid", source: settingsPath, raw };
|
|
629
|
+
}
|
|
630
|
+
// Claude Code's env block takes strings, but JSON's own `false`/`0` is what
|
|
631
|
+
// a user reaches for first. Honour it rather than ignoring the intent.
|
|
632
|
+
if (typeof raw === "boolean" || typeof raw === "number") {
|
|
633
|
+
return { kind: "value", value: String(raw), source: settingsPath };
|
|
634
|
+
}
|
|
635
|
+
return { kind: "invalid", source: settingsPath, raw };
|
|
636
|
+
}
|
|
637
|
+
return { kind: "absent" };
|
|
638
|
+
}
|
|
639
|
+
/** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
|
|
640
|
+
function parseSiblingSetting(raw, source) {
|
|
641
|
+
const value = raw.toLowerCase();
|
|
642
|
+
if (SIBLING_SETTING_OFF.has(value))
|
|
643
|
+
return false;
|
|
644
|
+
if (SIBLING_SETTING_ON.has(value))
|
|
645
|
+
return true;
|
|
646
|
+
printError(`checkpoint: ignoring ${SIBLING_SETTING}=${raw} from ${source} ` +
|
|
647
|
+
"(expected 0/1, false/true, off/on or no/yes)");
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Whether the maintenance sibling may run at all. The process environment wins
|
|
652
|
+
* over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
|
|
653
|
+
* checkpoint …` still works and a host that exports the settings `env` block
|
|
654
|
+
* (Claude Code does) agrees with a host that does not.
|
|
655
|
+
*/
|
|
656
|
+
export function siblingEnabledBySetting(liveRoot) {
|
|
657
|
+
const fromEnv = process.env[SIBLING_SETTING]?.trim();
|
|
658
|
+
if (fromEnv)
|
|
659
|
+
return parseSiblingSetting(fromEnv, "the environment");
|
|
660
|
+
const lookup = settingsEnvValue(liveRoot, SIBLING_SETTING);
|
|
661
|
+
if (lookup.kind === "absent")
|
|
662
|
+
return true;
|
|
663
|
+
if (lookup.kind === "invalid") {
|
|
664
|
+
printError(`checkpoint: ignoring ${SIBLING_SETTING} in ${lookup.source}: ` +
|
|
665
|
+
`${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
|
|
666
|
+
'(expected a string such as "0" or "1")');
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
return parseSiblingSetting(lookup.value, lookup.source);
|
|
670
|
+
}
|
|
671
|
+
/** Rejects an unknown `--backend` without paying for the probe that resolves one. */
|
|
672
|
+
function assertKnownBackend(requested) {
|
|
582
673
|
const value = requested ?? "auto";
|
|
583
674
|
if (!BACKENDS.has(value))
|
|
584
675
|
usage(`checkpoint: unknown backend: ${value}`);
|
|
676
|
+
return value;
|
|
677
|
+
}
|
|
678
|
+
function resolveBackend(requested, liveRoot) {
|
|
679
|
+
const value = assertKnownBackend(requested);
|
|
585
680
|
// An explicitly named backend is honoured as given; only `auto` shops around.
|
|
586
681
|
if (value !== "auto")
|
|
587
682
|
return value;
|
|
@@ -869,7 +964,12 @@ function runCheckpoint(options, command, group) {
|
|
|
869
964
|
if (!input.summary?.trim()) {
|
|
870
965
|
usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
|
|
871
966
|
}
|
|
872
|
-
|
|
967
|
+
// Resolved before resolveBackend so an opted-out run pays for no backend
|
|
968
|
+
// probe: `--backend auto` shells out to every installed CLI for a version.
|
|
969
|
+
const siblingOptOut = options.agent === false || !siblingEnabledBySetting(liveRoot);
|
|
970
|
+
// Validated even when nothing will spawn, so `--backend bogus` is still an error.
|
|
971
|
+
const requestedBackend = assertKnownBackend(options.backend);
|
|
972
|
+
const backend = siblingOptOut ? "none" : resolveBackend(requestedBackend, liveRoot);
|
|
873
973
|
const now = new Date();
|
|
874
974
|
const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
|
|
875
975
|
const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
|
|
@@ -890,7 +990,7 @@ function runCheckpoint(options, command, group) {
|
|
|
890
990
|
worker: input.worker,
|
|
891
991
|
},
|
|
892
992
|
stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
|
|
893
|
-
sibling:
|
|
993
|
+
sibling: siblingOptOut ? null : { backend },
|
|
894
994
|
});
|
|
895
995
|
return;
|
|
896
996
|
}
|
|
@@ -926,7 +1026,10 @@ function runCheckpoint(options, command, group) {
|
|
|
926
1026
|
// requested backend that is not installed).
|
|
927
1027
|
try {
|
|
928
1028
|
if (options.agent !== false) {
|
|
929
|
-
if (
|
|
1029
|
+
if (siblingOptOut) {
|
|
1030
|
+
printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
|
|
1031
|
+
}
|
|
1032
|
+
else if (backend === "none") {
|
|
930
1033
|
printResult("checkpoint: sibling disabled (backend none)");
|
|
931
1034
|
}
|
|
932
1035
|
else {
|
|
@@ -966,7 +1069,7 @@ export function registerCoreCheckpointCommand(core) {
|
|
|
966
1069
|
.option("--transcript <path>", "session transcript path")
|
|
967
1070
|
.option("--payload <file|->", "JSON payload file, or - for stdin")
|
|
968
1071
|
.option("--idle", "touch stamps without a checkpoint")
|
|
969
|
-
.option("--no-agent",
|
|
1072
|
+
.option("--no-agent", `do not spawn the maintenance sibling (set it off for good with "env": {"${SIBLING_SETTING}": "0"} in .claude/settings.json)`)
|
|
970
1073
|
.option("--backend <auto|claude|codex|grok|none>", "sibling backend", "auto")
|
|
971
1074
|
.option("--gate-probe", "write the local Stop-hook eligibility verdict")
|
|
972
1075
|
.option("--hq-root <path>", "HQ installation to operate on")
|
|
@@ -89,6 +89,16 @@ export interface RunDoctorOptions {
|
|
|
89
89
|
company?: string;
|
|
90
90
|
/** Internal test seam; production CLI always runs integrations checks. */
|
|
91
91
|
integrations?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Run only these check families (`--only`). Omitted or empty runs them all.
|
|
94
|
+
*
|
|
95
|
+
* This exists for callers that need one family's verdict and nothing else —
|
|
96
|
+
* `core/scripts/check-hq-hooks.sh` reads six hook check ids, and `hq reindex`
|
|
97
|
+
* runs that checker on every pass. Without scoping they pay for every other
|
|
98
|
+
* family too, which on a large tree is dominated by the per-company sync
|
|
99
|
+
* journal scan. An unknown id is an error, never a silent empty run.
|
|
100
|
+
*/
|
|
101
|
+
only?: readonly string[];
|
|
92
102
|
}
|
|
93
103
|
/** The outcome of a doctor run, returned rather than thrown so it is testable. */
|
|
94
104
|
export interface RunDoctorResult {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -93,7 +93,25 @@ export async function runDoctor(options = {}) {
|
|
|
93
93
|
` Run hq doctor from inside your HQ root.\n`);
|
|
94
94
|
return { exitCode: 1, hqRoot: null, families: [] };
|
|
95
95
|
}
|
|
96
|
-
const
|
|
96
|
+
const fullRegistry = options.registry ?? createDefaultRegistry();
|
|
97
|
+
const requested = options.only && options.only.length > 0 ? [...options.only] : undefined;
|
|
98
|
+
let registry = fullRegistry;
|
|
99
|
+
let scope;
|
|
100
|
+
if (requested) {
|
|
101
|
+
const selection = fullRegistry.select(requested);
|
|
102
|
+
// The deep families are appended by `--deep-test` rather than registered,
|
|
103
|
+
// so they are addressable only when that flag is present — naming one
|
|
104
|
+
// without it is the same mistake as naming a family that does not exist.
|
|
105
|
+
const appendable = options.deepTest ? [DEEP_FAMILY_ID, PARITY_FAMILY_ID] : [];
|
|
106
|
+
const unknown = selection.unknown.filter((id) => !appendable.includes(id));
|
|
107
|
+
if (unknown.length > 0) {
|
|
108
|
+
writeErr(`hq doctor --only: unknown check ${unknown.length === 1 ? "family" : "families"}: ${unknown.join(", ")}\n` +
|
|
109
|
+
` Available: ${[...fullRegistry.ids(), ...appendable].join(", ")}\n`);
|
|
110
|
+
return { exitCode: 1, hqRoot, families: [] };
|
|
111
|
+
}
|
|
112
|
+
registry = selection.registry;
|
|
113
|
+
scope = [...registry.ids(), ...appendable.filter((id) => requested.includes(id))];
|
|
114
|
+
}
|
|
97
115
|
const platform = options.platform ?? UNKNOWN_PLATFORM;
|
|
98
116
|
// The detected platform and the session id are exposed to every check so the
|
|
99
117
|
// host-specific runtime probe (US-006) can decide UNKNOWN vs FAIL vs UNTESTED.
|
|
@@ -111,7 +129,11 @@ export async function runDoctor(options = {}) {
|
|
|
111
129
|
// own family. Run only when asked — appending here, not in the default
|
|
112
130
|
// registry, is what keeps `hq doctor` from ever spawning a hook without the
|
|
113
131
|
// flag. Any FAIL/UNKNOWN it produces flows through computeExitCode below.
|
|
114
|
-
|
|
132
|
+
// The deep families are appended here rather than registered, so the `--only`
|
|
133
|
+
// filter has to be applied to them explicitly — otherwise
|
|
134
|
+
// `--only hooks --deep-test` would still sandbox-fire every hook.
|
|
135
|
+
const inScope = (id) => !requested || requested.includes(id);
|
|
136
|
+
if (options.deepTest && inScope(DEEP_FAMILY_ID)) {
|
|
115
137
|
const deepResults = await runDeepGuardTests(context);
|
|
116
138
|
// US-009: side-effecting hooks (autocommit, checkpoint, journal, reindex, …)
|
|
117
139
|
// cannot be verified by verdict, so they run in throwaway sandboxes and their
|
|
@@ -122,10 +144,12 @@ export async function runDoctor(options = {}) {
|
|
|
122
144
|
family: { id: DEEP_FAMILY_ID, title: DEEP_FAMILY_TITLE },
|
|
123
145
|
results: [...deepResults, ...effectResults],
|
|
124
146
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
}
|
|
148
|
+
// Cross-platform parity replay (US-010): replay every pure-guard fixture
|
|
149
|
+
// case through the Claude, Codex, and Grok adapters and compare verdicts, so
|
|
150
|
+
// platform drift surfaces as a test result. Also gated behind --deep-test,
|
|
151
|
+
// and it too runs only in its own sandbox — never the live tree.
|
|
152
|
+
if (options.deepTest && inScope(PARITY_FAMILY_ID)) {
|
|
129
153
|
const parityResults = await runParityReplay(context);
|
|
130
154
|
families.push({
|
|
131
155
|
family: { id: PARITY_FAMILY_ID, title: PARITY_FAMILY_TITLE },
|
|
@@ -133,7 +157,7 @@ export async function runDoctor(options = {}) {
|
|
|
133
157
|
});
|
|
134
158
|
}
|
|
135
159
|
if (options.json) {
|
|
136
|
-
write(renderJson(buildDoctorJson({ hqRoot, families, platform })));
|
|
160
|
+
write(renderJson(buildDoctorJson({ hqRoot, families, platform, scope })));
|
|
137
161
|
}
|
|
138
162
|
else {
|
|
139
163
|
write(renderText({
|
|
@@ -160,10 +184,24 @@ export function registerDoctorCommand(program) {
|
|
|
160
184
|
.option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
|
|
161
185
|
.option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
|
|
162
186
|
.option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
|
|
187
|
+
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
163
188
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
164
189
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
165
190
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
166
191
|
.action(async (opts) => {
|
|
192
|
+
// `--fix` repairs the whole tree from its own allowlist and has no
|
|
193
|
+
// notion of a check family, so a scope passed alongside it could only
|
|
194
|
+
// be ignored. Ignoring it silently is the dangerous reading: an
|
|
195
|
+
// operator who wrote `--only sync --fix` would believe they had scoped
|
|
196
|
+
// hook configuration out of a write, and `--only typo --fix` would slip
|
|
197
|
+
// past the unknown-family rejection. Refuse the combination instead.
|
|
198
|
+
if (opts.fix === true && parseOnly(opts.only) !== undefined) {
|
|
199
|
+
process.stderr.write(`hq doctor: --only cannot be combined with --fix.\n` +
|
|
200
|
+
` --fix applies the allowlisted repairs across the whole tree; it has no family scope.\n` +
|
|
201
|
+
` Inspect with \`hq doctor --only ${parseOnly(opts.only)?.join(",")}\`, then repair with \`hq doctor --fix\`.\n`);
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
167
205
|
// `--fix` is the only write path. It resolves the tree, applies the
|
|
168
206
|
// allowlisted repairs behind a backup + confirmation, and returns its own
|
|
169
207
|
// exit code; the read-only report below never runs in this branch.
|
|
@@ -208,6 +246,7 @@ export function registerDoctorCommand(program) {
|
|
|
208
246
|
deepTest: opts.deepTest === true,
|
|
209
247
|
liveRuntimes: opts.liveRuntimes === true,
|
|
210
248
|
company: opts.company,
|
|
249
|
+
only: parseOnly(opts.only),
|
|
211
250
|
});
|
|
212
251
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
213
252
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -215,6 +254,19 @@ export function registerDoctorCommand(program) {
|
|
|
215
254
|
process.exitCode = result.exitCode;
|
|
216
255
|
});
|
|
217
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Split `--only` into family ids. Blank entries are dropped so a trailing comma
|
|
259
|
+
* or a quoted empty string reads as "no scope" rather than as a family named "".
|
|
260
|
+
*/
|
|
261
|
+
function parseOnly(value) {
|
|
262
|
+
if (value === undefined)
|
|
263
|
+
return undefined;
|
|
264
|
+
const ids = value
|
|
265
|
+
.split(",")
|
|
266
|
+
.map((id) => id.trim())
|
|
267
|
+
.filter((id) => id.length > 0);
|
|
268
|
+
return ids.length > 0 ? ids : undefined;
|
|
269
|
+
}
|
|
218
270
|
/**
|
|
219
271
|
* Interactive y/N confirmation for `--fix`. Resolves false on a non-TTY stdin
|
|
220
272
|
* (so a piped run without `--yes` writes nothing) and on anything other than an
|
|
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
|
|
3
3
|
import { type BackgroundDependencies, type BackgroundResult, type BackgroundStatus } from '../lib/search-index/background.js';
|
|
4
4
|
import { type EmbedLockDependencies } from '../lib/search-index/embed-lock.js';
|
|
5
|
+
import { type LoadGateDependencies } from '../lib/search-index/load-gate.js';
|
|
5
6
|
export type SearchIndexDependencies = {
|
|
6
7
|
reconcileCollections: (hqRoot: string) => unknown;
|
|
7
8
|
/** Apply the per-document size cap to qmd's config before the update reads anything. */
|
|
@@ -16,6 +17,8 @@ export type SearchIndexDependencies = {
|
|
|
16
17
|
backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
|
|
17
18
|
/** Test seams for the lock shared by every hq-cli embed entry point. */
|
|
18
19
|
embedLockDependencies?: EmbedLockDependencies;
|
|
20
|
+
/** Test seams for the host load gate consulted while the embed lock is held. */
|
|
21
|
+
loadGateDependencies?: LoadGateDependencies;
|
|
19
22
|
writeStderr?: (text: string) => void;
|
|
20
23
|
};
|
|
21
24
|
export type SyncSearchIndexResult = {
|
|
@@ -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
|
|
@@ -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.
|