@indigoai-us/hq-cli 5.345.90 → 5.345.92
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 +12 -0
- package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +17 -4
- package/dist/commands/integrations-connect.d.ts +6 -0
- package/dist/commands/integrations-connect.js +114 -10
- package/dist/lib/search-index/background.d.ts +31 -8
- package/dist/lib/search-index/background.js +86 -7
- package/dist/lib/search-index/index.d.ts +24 -0
- package/dist/lib/search-index/index.js +25 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.92] — 2026-10-12
|
|
6
|
+
- The background search-index worker no longer spins at full CPU when its working directory is deleted: it only writes to absolute log paths, rejects a `C:/...` log path on macOS and Linux, receives the launcher's HQ root, and kills any qmd cleanup, update, or embed step that runs past 2 hours (`QMD_REINDEX_STEP_TIMEOUT_SEC` overrides). The bundled `qmd-reindex-bg.sh` converts paths with `cygpath` only on Git Bash, MSYS2, or Cygwin.
|
|
7
|
+
|
|
8
|
+
## [5.345.91] — 2026-10-12
|
|
9
|
+
- Add published E2E checks comparing native commands with bundled shell scripts for qmd background reindex, hq-session, checkpoint stop gate, and worker skill commands.
|
|
10
|
+
|
|
11
|
+
- `hq integrations connect <app> --auth key` (and `--token-stdin`) now
|
|
12
|
+
sends the access key to the app's MCP endpoint. Catalog Slack used to
|
|
13
|
+
ignore the key and only `--mcp-url` connected. If the server says the
|
|
14
|
+
app needs a key and cannot sign HQ in automatically, the command
|
|
15
|
+
collects the key instead of stopping.
|
|
16
|
+
|
|
5
17
|
## [5.345.90] — 2026-10-12
|
|
6
18
|
- The setup bot that ships with hq-cli (used before an HQ has synced its core workers) now reads the HQ desktop app's first-run handoff, the same as hq-core's copy. It treats the one note the app sends when its visual setup closes as its kickoff, never offers or repeats an import the app already did, and never runs a second scan. It also no longer asks what the person already said: told "I run an independent software engineering company", it sets up for a software services firm instead of asking "for clients, or your own product?".
|
|
7
19
|
|
|
@@ -37,15 +37,28 @@ if ! command -v hq >/dev/null 2>&1; then
|
|
|
37
37
|
exit 127
|
|
38
38
|
fi
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# Convert paths to Windows-native form ONLY on a Windows Bash (Git Bash, MSYS2,
|
|
41
|
+
# Cygwin). A `cygpath` merely being on PATH is not evidence of Windows: a test
|
|
42
|
+
# that stubs it on macOS turned `--log /tmp/qmd-handoff.log` into the RELATIVE
|
|
43
|
+
# POSIX path `C:/tmp/qmd-handoff.log`, and the detached worker that inherited
|
|
44
|
+
# it spun at 100% CPU for days once its temp cwd was deleted.
|
|
45
|
+
convert_windows_paths=0
|
|
46
|
+
case "${OSTYPE:-}" in
|
|
47
|
+
msys* | cygwin*) convert_windows_paths=1 ;;
|
|
48
|
+
esac
|
|
49
|
+
case "$(uname -s 2>/dev/null || true)" in
|
|
50
|
+
MINGW* | MSYS* | CYGWIN*) convert_windows_paths=1 ;;
|
|
51
|
+
esac
|
|
52
|
+
if [ "$convert_windows_paths" -eq 1 ] && ! command -v cygpath >/dev/null 2>&1; then
|
|
53
|
+
convert_windows_paths=0
|
|
42
54
|
fi
|
|
43
|
-
|
|
55
|
+
|
|
56
|
+
if [ "$convert_windows_paths" -eq 1 ]; then
|
|
44
57
|
HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
|
|
45
58
|
fi
|
|
46
59
|
forwarded_args=("$@")
|
|
47
60
|
forwarded_arg_count=$#
|
|
48
|
-
if
|
|
61
|
+
if [ "$convert_windows_paths" -eq 1 ]; then
|
|
49
62
|
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
50
63
|
case "${forwarded_args[index]}" in
|
|
51
64
|
--log) index=$((index + 1)); if ((index < forwarded_arg_count)); then forwarded_args[index]="$(cygpath -m "${forwarded_args[index]}")"; fi ;;
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* install and treats hq-pro's `INTEGRATION_FACTORY_OAUTH_REQUIRED` as the
|
|
16
16
|
* signal to switch into the browser flow. That way a catalog row that says
|
|
17
17
|
* "key" but is really OAuth-protected still connects on the first try.
|
|
18
|
+
*
|
|
19
|
+
* A pasted key is different. Catalog/domain installs can drop `--auth key` /
|
|
20
|
+
* `--token-stdin` (hq-pro only forwards a bearer token on the direct MCP URL
|
|
21
|
+
* path, and an OAuth catalog row short-circuits to sign-in). When the caller
|
|
22
|
+
* supplied a key, or the server says the endpoint needs one and cannot
|
|
23
|
+
* register HQ, retry against the inspected MCP URL with the key.
|
|
18
24
|
*/
|
|
19
25
|
import { Command } from "commander";
|
|
20
26
|
import { type ResidentCommandLeaseOptions } from "../utils/resident-package-preload.js";
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* install and treats hq-pro's `INTEGRATION_FACTORY_OAUTH_REQUIRED` as the
|
|
16
16
|
* signal to switch into the browser flow. That way a catalog row that says
|
|
17
17
|
* "key" but is really OAuth-protected still connects on the first try.
|
|
18
|
+
*
|
|
19
|
+
* A pasted key is different. Catalog/domain installs can drop `--auth key` /
|
|
20
|
+
* `--token-stdin` (hq-pro only forwards a bearer token on the direct MCP URL
|
|
21
|
+
* path, and an OAuth catalog row short-circuits to sign-in). When the caller
|
|
22
|
+
* supplied a key, or the server says the endpoint needs one and cannot
|
|
23
|
+
* register HQ, retry against the inspected MCP URL with the key.
|
|
18
24
|
*/
|
|
19
25
|
import chalk from "chalk";
|
|
20
26
|
import open from "open";
|
|
@@ -30,6 +36,8 @@ import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBluep
|
|
|
30
36
|
import { OAUTH_LOOPBACK_TIMEOUT_CODE, startLoopbackListener, } from "./integrations-oauth.js";
|
|
31
37
|
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
32
38
|
const OAUTH_REQUIRED_CODE = "INTEGRATION_FACTORY_OAUTH_REQUIRED";
|
|
39
|
+
/** hq-pro's machine code for "this MCP endpoint refused the auth we sent". */
|
|
40
|
+
const DIRECT_MCP_AUTH_REJECTED_CODE = "DIRECT_MCP_AUTH_REJECTED";
|
|
33
41
|
function warnBrowserOpenFailure(error) {
|
|
34
42
|
console.warn("[hq-cli:integrations-connect] browser open failed", safeCaughtError(error));
|
|
35
43
|
}
|
|
@@ -51,6 +59,17 @@ function isOAuthRequiredError(err) {
|
|
|
51
59
|
return (err instanceof IntegrationsCliError &&
|
|
52
60
|
(err.code === OAUTH_REQUIRED_CODE || err.oauthProtected === true));
|
|
53
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The server probed the MCP endpoint, got an OAuth challenge, and determined
|
|
64
|
+
* it cannot self-register HQ (no DCR). The recovery is a pasted access key,
|
|
65
|
+
* not a browser sign-in. `oauthProtected: true` is the opposite signal and
|
|
66
|
+
* must keep taking the sign-in branch.
|
|
67
|
+
*/
|
|
68
|
+
function isAccessKeyRequiredError(err) {
|
|
69
|
+
return (err instanceof IntegrationsCliError &&
|
|
70
|
+
err.code === DIRECT_MCP_AUTH_REJECTED_CODE &&
|
|
71
|
+
err.oauthProtected !== true);
|
|
72
|
+
}
|
|
54
73
|
/**
|
|
55
74
|
* hq-pro will not take our loopback callback, for either of the two reasons it
|
|
56
75
|
* can refuse one:
|
|
@@ -894,6 +913,59 @@ async function completeCredentialIfNeeded(token, companyUid, target, opts, resul
|
|
|
894
913
|
bearerToken,
|
|
895
914
|
});
|
|
896
915
|
}
|
|
916
|
+
function mcpUrlFromBlueprint(blueprint) {
|
|
917
|
+
const surfaces = [
|
|
918
|
+
...(blueprint.recommendedSurface ? [blueprint.recommendedSurface] : []),
|
|
919
|
+
...blueprint.surfaces,
|
|
920
|
+
];
|
|
921
|
+
for (const surface of surfaces) {
|
|
922
|
+
const url = surface.url?.trim();
|
|
923
|
+
if (url && /^https:\/\//i.test(url))
|
|
924
|
+
return url;
|
|
925
|
+
}
|
|
926
|
+
return undefined;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Catalog/domain/query installs are the path that drops a pasted key. The
|
|
930
|
+
* working connect is the same one `--mcp-url` already uses: a direct MCP
|
|
931
|
+
* endpoint plus `authMode: bearer`.
|
|
932
|
+
*/
|
|
933
|
+
async function targetForAccessKeyConnect(token, companyUid, target, opts) {
|
|
934
|
+
if (target.ref.mcpUrl)
|
|
935
|
+
return { ...target, authClass: "key" };
|
|
936
|
+
const blueprint = await pullBlueprint(token, companyUid, {
|
|
937
|
+
...(target.ref.domain ? { domain: target.ref.domain } : {}),
|
|
938
|
+
...(target.ref.query ? { query: target.ref.query } : {}),
|
|
939
|
+
...(target.ref.catalogEntryId ? { catalogEntryId: target.ref.catalogEntryId } : {}),
|
|
940
|
+
});
|
|
941
|
+
const mcpUrl = mcpUrlFromBlueprint(blueprint);
|
|
942
|
+
if (!mcpUrl) {
|
|
943
|
+
throw new IntegrationsCliError(`${target.label} needs an access key. Re-run with --mcp-url <endpoint> --auth key --token-stdin.`, { expected: true });
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
ref: {
|
|
947
|
+
mcpUrl,
|
|
948
|
+
...(opts.provider ? { provider: opts.provider } : blueprint.provider ? { provider: blueprint.provider } : {}),
|
|
949
|
+
...(opts.displayName
|
|
950
|
+
? { displayName: opts.displayName }
|
|
951
|
+
: blueprint.displayName
|
|
952
|
+
? { displayName: blueprint.displayName }
|
|
953
|
+
: {}),
|
|
954
|
+
},
|
|
955
|
+
authClass: "key",
|
|
956
|
+
label: target.label,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
function bearerInstallInput(target, opts, bearerToken) {
|
|
960
|
+
return {
|
|
961
|
+
...target.ref,
|
|
962
|
+
authMode: "bearer",
|
|
963
|
+
bearerToken,
|
|
964
|
+
...(opts.authHeader
|
|
965
|
+
? { authScheme: { placement: "header", header: opts.authHeader } }
|
|
966
|
+
: {}),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
897
969
|
/** Print the outcome of a successful connect. */
|
|
898
970
|
function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
899
971
|
if (opts.json) {
|
|
@@ -923,17 +995,34 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
923
995
|
: opts.auth ?? target.authClass;
|
|
924
996
|
assertUserClientApplicable(opts, target, authMode);
|
|
925
997
|
assertScopesApplicable(opts, target, authMode);
|
|
926
|
-
if (authMode === "oauth") {
|
|
927
|
-
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
928
|
-
if (result)
|
|
929
|
-
reportInstall(result, opts, expectedRevivedConnectionId);
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
998
|
// A key is only collected when something already says one is needed, or
|
|
933
999
|
// the caller supplied one — otherwise a no-auth app would pointlessly
|
|
934
|
-
// prompt.
|
|
1000
|
+
// prompt. Compute this BEFORE the OAuth branch: a catalog row marked
|
|
1001
|
+
// oauth plus `--token-stdin` used to skip the key entirely.
|
|
935
1002
|
const wantsKey = authMode === "key" || Boolean(opts.token || opts.tokenStdin);
|
|
1003
|
+
const finishWithAccessKey = async (existingKey) => {
|
|
1004
|
+
const keyTarget = await targetForAccessKeyConnect(token, companyUid, target, opts);
|
|
1005
|
+
const bearerToken = existingKey ?? (await resolveKey(opts, keyTarget.label));
|
|
1006
|
+
const result = await installIntegration(token, companyUid, bearerInstallInput(keyTarget, opts, bearerToken));
|
|
1007
|
+
reportInstall(await completeCredentialIfNeeded(token, companyUid, keyTarget, opts, result), opts, expectedRevivedConnectionId);
|
|
1008
|
+
};
|
|
1009
|
+
if (authMode === "oauth" && !wantsKey) {
|
|
1010
|
+
try {
|
|
1011
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
1012
|
+
if (result)
|
|
1013
|
+
reportInstall(result, opts, expectedRevivedConnectionId);
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
if (isAccessKeyRequiredError(err)) {
|
|
1017
|
+
await finishWithAccessKey();
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
936
1024
|
const bearerToken = wantsKey ? await resolveKey(opts, target.label) : undefined;
|
|
1025
|
+
const alreadyDirectKeyInstall = Boolean(target.ref.mcpUrl && bearerToken);
|
|
937
1026
|
try {
|
|
938
1027
|
const result = await installIntegration(token, companyUid, {
|
|
939
1028
|
...target.ref,
|
|
@@ -952,13 +1041,28 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
952
1041
|
reportInstall(await completeCredentialIfNeeded(token, companyUid, target, opts, result), opts, expectedRevivedConnectionId);
|
|
953
1042
|
}
|
|
954
1043
|
catch (err) {
|
|
1044
|
+
const retryAsKey = !alreadyDirectKeyInstall &&
|
|
1045
|
+
(isAccessKeyRequiredError(err) || (isOAuthRequiredError(err) && wantsKey));
|
|
1046
|
+
if (retryAsKey) {
|
|
1047
|
+
await finishWithAccessKey(bearerToken);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
955
1050
|
// Server-authoritative detection: the endpoint turned out to be
|
|
956
1051
|
// OAuth-protected, so run the browser flow instead of making the
|
|
957
1052
|
// caller re-issue the command with --auth oauth.
|
|
958
1053
|
if (isOAuthRequiredError(err)) {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
const result = await connectViaOAuth(token, companyUid, target, opts);
|
|
1056
|
+
if (result)
|
|
1057
|
+
reportInstall(result, opts, expectedRevivedConnectionId);
|
|
1058
|
+
}
|
|
1059
|
+
catch (oauthErr) {
|
|
1060
|
+
if (isAccessKeyRequiredError(oauthErr)) {
|
|
1061
|
+
await finishWithAccessKey(bearerToken);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
throw oauthErr;
|
|
1065
|
+
}
|
|
962
1066
|
return;
|
|
963
1067
|
}
|
|
964
1068
|
throw err;
|
|
@@ -19,10 +19,10 @@ export type BackgroundDependencies = {
|
|
|
19
19
|
/** Apply the per-document size cap to qmd's config before the update reads anything. */
|
|
20
20
|
applyIndexSizeLimit: (hqRoot: string) => unknown;
|
|
21
21
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
22
|
-
spawnWorker: (options:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
spawnWorker: (options: SpawnWorkerOptions) => number;
|
|
23
|
+
/** Test seams for log-path resolution; production uses the real process. */
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
25
|
+
cwd?: () => string;
|
|
26
26
|
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
27
27
|
afterOwnerPublish?: (ownerFile: string) => void;
|
|
28
28
|
/** Test seams for signal delivery; production uses the real process. */
|
|
@@ -36,6 +36,32 @@ export type BackgroundStatus = {
|
|
|
36
36
|
lock: 'held' | 'stale' | 'free';
|
|
37
37
|
completedAt?: number;
|
|
38
38
|
};
|
|
39
|
+
export type SpawnWorkerOptions = {
|
|
40
|
+
logPath: string;
|
|
41
|
+
fallbackLogPath?: string;
|
|
42
|
+
/** Absolute HQ root the worker must index; forwarded as --hq-root. */
|
|
43
|
+
hqRoot?: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Turn a requested worker log path (or HQ root) into an absolute native path,
|
|
47
|
+
* or undefined when it cannot be used safely.
|
|
48
|
+
*
|
|
49
|
+
* - On a non-Windows host a drive-letter path (`C:/tmp/qmd-handoff.log`) is the
|
|
50
|
+
* residue of a Windows-only `cygpath -m` conversion applied where it does not
|
|
51
|
+
* belong. POSIX treats it as RELATIVE, so honouring it would create a `C:`
|
|
52
|
+
* directory inside whatever the current directory happens to be. Reject it.
|
|
53
|
+
* - A relative path is resolved against `cwd` only when a cwd is supplied (the
|
|
54
|
+
* launcher, whose cwd is the caller's). The detached worker never resolves
|
|
55
|
+
* against its inherited cwd: that directory can be deleted under it (a test
|
|
56
|
+
* sandbox's temp dir), and Node's `fs.mkdirSync(relative, { recursive: true })`
|
|
57
|
+
* then loops forever at 100% CPU instead of failing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveNativeAbsolutePath(logPath: string, options?: {
|
|
60
|
+
platform?: NodeJS.Platform;
|
|
61
|
+
cwd?: () => string;
|
|
62
|
+
}): string | undefined;
|
|
63
|
+
/** Per-step qmd timeout for the detached worker (QMD_REINDEX_STEP_TIMEOUT_SEC). */
|
|
64
|
+
export declare function workerStepTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
39
65
|
/**
|
|
40
66
|
* Injectable seams for the launcher's worker-log open and detached spawn. Split
|
|
41
67
|
* out purely as a test seam so a synthetic open failure can be forced without
|
|
@@ -87,10 +113,7 @@ type WorkerLogOpen = {
|
|
|
87
113
|
* (appendWorkerLog, capWorkerLog) already swallows I/O failures the same way.
|
|
88
114
|
*/
|
|
89
115
|
export declare function openWorkerLog(logPath: string, fallbackLogPath: string | undefined, io: Pick<SpawnWorkerIo, 'mkdirSync' | 'openSync'>): WorkerLogOpen;
|
|
90
|
-
export declare function defaultSpawnWorker({ logPath, fallbackLogPath }:
|
|
91
|
-
logPath: string;
|
|
92
|
-
fallbackLogPath?: string;
|
|
93
|
-
}, io?: SpawnWorkerIo): number;
|
|
116
|
+
export declare function defaultSpawnWorker({ logPath, fallbackLogPath, hqRoot }: SpawnWorkerOptions, io?: SpawnWorkerIo): number;
|
|
94
117
|
/** Defaults used by the CLI; tests supply every nondeterministic dependency. */
|
|
95
118
|
export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
|
|
96
119
|
export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
|
|
@@ -8,6 +8,54 @@ import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-byt
|
|
|
8
8
|
import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
|
|
9
9
|
import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from './load-gate.js';
|
|
10
10
|
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
11
|
+
/**
|
|
12
|
+
* Default hard wall-clock limit for EACH qmd step (cleanup, update, embed) the
|
|
13
|
+
* detached worker runs. The worker has no terminal and nobody waiting on it, so
|
|
14
|
+
* an unbounded step could hold the single-flight lock and a CPU for days. Two
|
|
15
|
+
* hours leaves a first full CPU-only embed of a large HQ room to finish.
|
|
16
|
+
* Override with QMD_REINDEX_STEP_TIMEOUT_SEC (a positive integer).
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_STEP_TIMEOUT_MS = 2 * 60 * 60 * 1_000;
|
|
19
|
+
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/;
|
|
20
|
+
/**
|
|
21
|
+
* Turn a requested worker log path (or HQ root) into an absolute native path,
|
|
22
|
+
* or undefined when it cannot be used safely.
|
|
23
|
+
*
|
|
24
|
+
* - On a non-Windows host a drive-letter path (`C:/tmp/qmd-handoff.log`) is the
|
|
25
|
+
* residue of a Windows-only `cygpath -m` conversion applied where it does not
|
|
26
|
+
* belong. POSIX treats it as RELATIVE, so honouring it would create a `C:`
|
|
27
|
+
* directory inside whatever the current directory happens to be. Reject it.
|
|
28
|
+
* - A relative path is resolved against `cwd` only when a cwd is supplied (the
|
|
29
|
+
* launcher, whose cwd is the caller's). The detached worker never resolves
|
|
30
|
+
* against its inherited cwd: that directory can be deleted under it (a test
|
|
31
|
+
* sandbox's temp dir), and Node's `fs.mkdirSync(relative, { recursive: true })`
|
|
32
|
+
* then loops forever at 100% CPU instead of failing.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveNativeAbsolutePath(logPath, options = {}) {
|
|
35
|
+
const platform = options.platform ?? process.platform;
|
|
36
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
37
|
+
if (!logPath)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (platform !== 'win32' && WINDOWS_DRIVE_PATH.test(logPath))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (pathApi.isAbsolute(logPath))
|
|
42
|
+
return logPath;
|
|
43
|
+
if (!options.cwd)
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
return pathApi.resolve(options.cwd(), logPath);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Per-step qmd timeout for the detached worker (QMD_REINDEX_STEP_TIMEOUT_SEC). */
|
|
53
|
+
export function workerStepTimeoutMs(env) {
|
|
54
|
+
const raw = env.QMD_REINDEX_STEP_TIMEOUT_SEC;
|
|
55
|
+
if (raw && /^\d+$/.test(raw) && Number(raw) > 0)
|
|
56
|
+
return Number(raw) * 1_000;
|
|
57
|
+
return DEFAULT_STEP_TIMEOUT_MS;
|
|
58
|
+
}
|
|
11
59
|
function errnoInfo(error) {
|
|
12
60
|
const e = error;
|
|
13
61
|
return { code: e?.code, syscall: e?.syscall };
|
|
@@ -75,7 +123,7 @@ const defaultSpawnWorkerIo = {
|
|
|
75
123
|
spawn: (command, args, options) => spawn(command, args, options),
|
|
76
124
|
report: reportWorkerLogDegradation,
|
|
77
125
|
};
|
|
78
|
-
export function defaultSpawnWorker({ logPath, fallbackLogPath }, io = defaultSpawnWorkerIo) {
|
|
126
|
+
export function defaultSpawnWorker({ logPath, fallbackLogPath, hqRoot }, io = defaultSpawnWorkerIo) {
|
|
79
127
|
const opened = openWorkerLog(logPath, fallbackLogPath, io);
|
|
80
128
|
if (opened.error) {
|
|
81
129
|
io.report({ requested: logPath, used: opened.logPath, usedFallback: opened.usedFallback, ...opened.error });
|
|
@@ -91,7 +139,10 @@ export function defaultSpawnWorker({ logPath, fallbackLogPath }, io = defaultSpa
|
|
|
91
139
|
const stdio = opened.fd === null
|
|
92
140
|
? ['ignore', 'ignore', 'ignore']
|
|
93
141
|
: ['ignore', opened.fd, opened.fd];
|
|
94
|
-
|
|
142
|
+
// Forward the launcher's root so the worker indexes what the caller asked for
|
|
143
|
+
// instead of re-deriving a root from its inherited (possibly temporary) cwd.
|
|
144
|
+
const rootArgs = hqRoot ? ['--hq-root', hqRoot] : [];
|
|
145
|
+
const child = io.spawn(process.execPath, [entry, 'index', 'background', '--worker', ...rootArgs, ...logArgs], {
|
|
95
146
|
detached: true,
|
|
96
147
|
windowsHide: true,
|
|
97
148
|
stdio,
|
|
@@ -203,6 +254,10 @@ function workerLogPath(env) {
|
|
|
203
254
|
function appendWorkerLog(logPath, text) {
|
|
204
255
|
if (!text)
|
|
205
256
|
return;
|
|
257
|
+
// Never mkdir a relative path: in a deleted cwd Node's recursive mkdirSync
|
|
258
|
+
// spins forever (see resolveNativeAbsolutePath). Callers pass an absolute path.
|
|
259
|
+
if (!path.isAbsolute(logPath))
|
|
260
|
+
return;
|
|
206
261
|
try {
|
|
207
262
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
208
263
|
fs.appendFileSync(logPath, text.endsWith('\n') ? text : `${text}\n`);
|
|
@@ -217,6 +272,8 @@ function capWorkerLog(logPath, env) {
|
|
|
217
272
|
const max = Number(raw);
|
|
218
273
|
if (max === 0)
|
|
219
274
|
return;
|
|
275
|
+
if (!path.isAbsolute(logPath))
|
|
276
|
+
return;
|
|
220
277
|
try {
|
|
221
278
|
if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= max)
|
|
222
279
|
return;
|
|
@@ -265,9 +322,26 @@ export function runBackgroundLauncher(dependencies) {
|
|
|
265
322
|
// openWorkerLog. `home` is validated non-empty above, so the fallback is
|
|
266
323
|
// always inside a directory this user owns.
|
|
267
324
|
const fallbackLogPath = path.join(home, '.hq', 'logs', 'qmd-handoff.log');
|
|
325
|
+
const platform = dependencies.platform ?? process.platform;
|
|
326
|
+
const cwd = dependencies.cwd ?? (() => process.cwd());
|
|
327
|
+
const requestedLogPath = workerLogPath(dependencies.env);
|
|
328
|
+
const logPath = resolveNativeAbsolutePath(requestedLogPath, { platform, cwd });
|
|
329
|
+
if (!logPath) {
|
|
330
|
+
try {
|
|
331
|
+
(dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(`hq: background reindex log ${requestedLogPath} is not a usable absolute path on this platform; writing worker output to ${fallbackLogPath} instead.\n`);
|
|
332
|
+
}
|
|
333
|
+
catch { /* the notice is best-effort and must never crash the launcher */ }
|
|
334
|
+
}
|
|
335
|
+
// Hand the worker an absolute root resolved against the caller's cwd, which
|
|
336
|
+
// still exists now; a drive-letter root on POSIX is dropped like the log path.
|
|
337
|
+
const hqRoot = resolveNativeAbsolutePath(dependencies.hqRoot, { platform, cwd });
|
|
268
338
|
return {
|
|
269
339
|
state: 'launched',
|
|
270
|
-
pid: dependencies.spawnWorker({
|
|
340
|
+
pid: dependencies.spawnWorker({
|
|
341
|
+
logPath: logPath ?? fallbackLogPath,
|
|
342
|
+
fallbackLogPath,
|
|
343
|
+
...(hqRoot ? { hqRoot } : {}),
|
|
344
|
+
}),
|
|
271
345
|
};
|
|
272
346
|
}
|
|
273
347
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
@@ -290,7 +364,12 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
290
364
|
// The cap lives inside the exit cleanup because a signal-path process.exit
|
|
291
365
|
// never unwinds to a `finally` — the shell capped in its EXIT trap for the
|
|
292
366
|
// same reason.
|
|
293
|
-
|
|
367
|
+
// The launcher always forwards an absolute --log. Anything else (a relative or
|
|
368
|
+
// drive-letter path from a direct invocation) goes to the per-user log rather
|
|
369
|
+
// than being resolved against this detached process's inherited cwd.
|
|
370
|
+
const logPath = resolveNativeAbsolutePath(workerLogPath(dependencies.env), { platform: dependencies.platform })
|
|
371
|
+
?? path.join(home, '.hq', 'logs', 'qmd-handoff.log');
|
|
372
|
+
const timeoutMs = workerStepTimeoutMs(dependencies.env);
|
|
294
373
|
let released = false;
|
|
295
374
|
const cleanup = () => {
|
|
296
375
|
if (released)
|
|
@@ -337,7 +416,7 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
337
416
|
if (await signalWindow())
|
|
338
417
|
return { state: 'terminated' };
|
|
339
418
|
try {
|
|
340
|
-
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot })));
|
|
419
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot, timeoutMs })));
|
|
341
420
|
}
|
|
342
421
|
catch (error) {
|
|
343
422
|
appendWorkerLog(logPath, errorOutput(error)); // cleanup is intentionally best-effort
|
|
@@ -345,7 +424,7 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
345
424
|
if (await signalWindow())
|
|
346
425
|
return { state: 'terminated' };
|
|
347
426
|
try {
|
|
348
|
-
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot })));
|
|
427
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot, timeoutMs })));
|
|
349
428
|
}
|
|
350
429
|
catch (error) {
|
|
351
430
|
appendWorkerLog(logPath, errorOutput(error));
|
|
@@ -376,7 +455,7 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
376
455
|
if (loadDecision.forced)
|
|
377
456
|
(dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
|
|
378
457
|
try {
|
|
379
|
-
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
|
|
458
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot, timeoutMs })));
|
|
380
459
|
recordSuccessfulEmbed(home, loadGateDependencies);
|
|
381
460
|
}
|
|
382
461
|
catch (error) {
|
|
@@ -64,6 +64,8 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
|
|
|
64
64
|
env?: NodeJS.ProcessEnv;
|
|
65
65
|
encoding: 'utf8';
|
|
66
66
|
timeout?: number;
|
|
67
|
+
/** Signal sent when `timeout` elapses (spawnSync default: SIGTERM). */
|
|
68
|
+
killSignal?: NodeJS.Signals;
|
|
67
69
|
shell?: boolean;
|
|
68
70
|
}) => {
|
|
69
71
|
status: number | null;
|
|
@@ -160,6 +162,20 @@ export declare class QmdTerminatedError extends QmdExitError {
|
|
|
160
162
|
name: string;
|
|
161
163
|
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
|
|
162
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* qmd ran past the hard wall-clock limit its caller set (`RunQmdOptions.timeoutMs`)
|
|
167
|
+
* and was KILLED by hq-cli. Unlike the parent {@link QmdTerminatedError}, the
|
|
168
|
+
* signal here originates INSIDE hq-cli: spawnSync reports `error.code ===
|
|
169
|
+
* 'ETIMEDOUT'` after delivering `killSignal`. Raised only when the caller opted
|
|
170
|
+
* into a timeout, so every untimed qmd call keeps its existing classification.
|
|
171
|
+
* The background reindex worker uses this to bound each step instead of
|
|
172
|
+
* holding the single-flight lock indefinitely.
|
|
173
|
+
*/
|
|
174
|
+
export declare class QmdTimedOutError extends QmdTerminatedError {
|
|
175
|
+
readonly timeoutMs: number;
|
|
176
|
+
name: string;
|
|
177
|
+
constructor(message: string, args: string[], stdout: string, stderr: string, signal: string, timeoutMs: number);
|
|
178
|
+
}
|
|
163
179
|
/**
|
|
164
180
|
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
165
181
|
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
@@ -511,6 +527,14 @@ export type RunQmdOptions = {
|
|
|
511
527
|
* cwd / interpreter branches are provable without touching the real disk.
|
|
512
528
|
*/
|
|
513
529
|
exists?: (candidate: string) => boolean;
|
|
530
|
+
/**
|
|
531
|
+
* Hard wall-clock limit for the qmd child, in milliseconds. When it elapses
|
|
532
|
+
* the child is sent SIGKILL and {@link QmdTimedOutError} is thrown. Omitted
|
|
533
|
+
* (the default) means no limit, preserving every interactive caller's
|
|
534
|
+
* behaviour. Applies to the real spawn path only; the legacy `runner` seam
|
|
535
|
+
* has no process to kill.
|
|
536
|
+
*/
|
|
537
|
+
timeoutMs?: number;
|
|
514
538
|
};
|
|
515
539
|
/** Run qmd with captured output and typed failures. */
|
|
516
540
|
export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
|
|
@@ -133,6 +133,23 @@ export class QmdTerminatedError extends QmdExitError {
|
|
|
133
133
|
this.signal = signal;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* qmd ran past the hard wall-clock limit its caller set (`RunQmdOptions.timeoutMs`)
|
|
138
|
+
* and was KILLED by hq-cli. Unlike the parent {@link QmdTerminatedError}, the
|
|
139
|
+
* signal here originates INSIDE hq-cli: spawnSync reports `error.code ===
|
|
140
|
+
* 'ETIMEDOUT'` after delivering `killSignal`. Raised only when the caller opted
|
|
141
|
+
* into a timeout, so every untimed qmd call keeps its existing classification.
|
|
142
|
+
* The background reindex worker uses this to bound each step instead of
|
|
143
|
+
* holding the single-flight lock indefinitely.
|
|
144
|
+
*/
|
|
145
|
+
export class QmdTimedOutError extends QmdTerminatedError {
|
|
146
|
+
timeoutMs;
|
|
147
|
+
name = 'QmdTimedOutError';
|
|
148
|
+
constructor(message, args, stdout, stderr, signal, timeoutMs) {
|
|
149
|
+
super(message, args, null, stdout, stderr, signal);
|
|
150
|
+
this.timeoutMs = timeoutMs;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
136
153
|
/**
|
|
137
154
|
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
138
155
|
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
@@ -557,6 +574,7 @@ const defaultSpawn = (cmd, args, options) => {
|
|
|
557
574
|
env: options.env,
|
|
558
575
|
encoding: 'utf8',
|
|
559
576
|
timeout: options.timeout,
|
|
577
|
+
killSignal: options.killSignal,
|
|
560
578
|
shell: options.shell,
|
|
561
579
|
});
|
|
562
580
|
return {
|
|
@@ -588,6 +606,7 @@ function spawnQmd(invocation, args, options = {}) {
|
|
|
588
606
|
env,
|
|
589
607
|
encoding: 'utf8',
|
|
590
608
|
timeout: options.timeout,
|
|
609
|
+
...(options.killSignal ? { killSignal: options.killSignal } : {}),
|
|
591
610
|
shell: plan.shell,
|
|
592
611
|
});
|
|
593
612
|
}
|
|
@@ -1286,12 +1305,18 @@ export function runQmd(args, options = {}) {
|
|
|
1286
1305
|
// above never opens a store).
|
|
1287
1306
|
ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
|
|
1288
1307
|
const launcher = invocationBin(invocation);
|
|
1308
|
+
const timeoutMs = options.timeoutMs !== undefined && options.timeoutMs > 0 ? options.timeoutMs : undefined;
|
|
1289
1309
|
const result = spawnQmd(invocation, args, {
|
|
1290
1310
|
cwd: options.cwd,
|
|
1291
1311
|
env: options.env ?? process.env,
|
|
1292
1312
|
platform: options.platform,
|
|
1293
1313
|
spawn: options.spawn,
|
|
1314
|
+
...(timeoutMs !== undefined ? { timeout: timeoutMs, killSignal: 'SIGKILL' } : {}),
|
|
1294
1315
|
});
|
|
1316
|
+
if (timeoutMs !== undefined && result.error?.code === 'ETIMEDOUT') {
|
|
1317
|
+
const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
|
|
1318
|
+
throw new QmdTimedOutError(`qmd ${subcommand} exceeded its ${timeoutMs}ms limit and was killed`, args, result.stdout ?? '', result.stderr ?? '', String(result.signal ?? 'SIGKILL'), timeoutMs);
|
|
1319
|
+
}
|
|
1295
1320
|
// Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
|
|
1296
1321
|
// right component — the working directory, the Node interpreter, or a
|
|
1297
1322
|
// genuinely missing qmd — instead of blaming the launcher for all three
|