@indigoai-us/hq-cli 5.336.0 → 5.337.0

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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.337.0] — 2026-10-03
6
+ - QMD index updates now share the cross-process writer lock and skip bounded SQLite contention so a later sync can catch up.
7
+ - Self-update now checks the Windows npm prefix that stores command shims, so an unwritable prefix is detected before npm runs.
8
+
5
9
  ## [5.336.0] — 2026-10-03
6
10
  - Client-health refresh failures now retain the bounded Cognito OAuth error class for accurate auth diagnostics.
7
11
  - Fix macOS Bash 3.2 service PATH generation when nounset is enabled.
@@ -21,6 +21,9 @@ export type SearchIndexDependencies = {
21
21
  singleFlightEnabled?: () => boolean;
22
22
  /** Test seams for the host load gate consulted while the embed lock is held. */
23
23
  loadGateDependencies?: LoadGateDependencies;
24
+ /** Test seams for the bounded wait before an index-sync write. */
25
+ nowMs?: () => number;
26
+ sleepSync?: (milliseconds: number) => void;
24
27
  writeStderr?: (text: string) => void;
25
28
  };
26
29
  export type SyncSearchIndexResult = {
@@ -1,5 +1,5 @@
1
1
  import { Option } from 'commander';
2
- import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdHome, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
2
+ import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdHome, resolveQmdVersion, runQmd, QmdExitError, } 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
5
  import { checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from '../lib/search-index/load-gate.js';
@@ -22,6 +22,40 @@ const defaults = {
22
22
  runBackgroundWorker,
23
23
  backgroundStatus,
24
24
  };
25
+ const INDEX_SYNC_LOCK_WAIT_MS = 5_000;
26
+ const INDEX_SYNC_LOCK_POLL_MS = 100;
27
+ const syncSleepBuffer = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
28
+ function sleepSync(milliseconds) {
29
+ Atomics.wait(syncSleepBuffer, 0, 0, milliseconds);
30
+ }
31
+ function isSqliteBusy(error) {
32
+ return error instanceof QmdExitError &&
33
+ /\bSQLITE_BUSY\b|SqliteError:\s*database is locked\b/i.test(`${error.message}\n${error.stderr}`);
34
+ }
35
+ /** Wait for the shared writer or SQLite transaction to clear, then skip safely. */
36
+ function runWithIndexSyncLock(home, lockDependencies, dependencies, run) {
37
+ const now = dependencies.nowMs ?? Date.now;
38
+ const sleep = dependencies.sleepSync ?? sleepSync;
39
+ const deadline = now() + INDEX_SYNC_LOCK_WAIT_MS;
40
+ const maxAttempts = Math.ceil(INDEX_SYNC_LOCK_WAIT_MS / INDEX_SYNC_LOCK_POLL_MS) + 1;
41
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
42
+ try {
43
+ const outcome = runWithEmbedLock(home, lockDependencies, run);
44
+ if (outcome !== 'busy')
45
+ return outcome;
46
+ }
47
+ catch (error) {
48
+ if (isSqliteBusy(error))
49
+ return 'busy';
50
+ throw error;
51
+ }
52
+ const remaining = deadline - now();
53
+ if (remaining <= 0)
54
+ return 'busy';
55
+ sleep(Math.min(INDEX_SYNC_LOCK_POLL_MS, remaining));
56
+ }
57
+ return 'busy';
58
+ }
25
59
  /** Incrementally update qmd, embedding only when an operator explicitly asks. */
26
60
  export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
27
61
  const updateIndex = () => {
@@ -60,12 +94,12 @@ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
60
94
  return undefined;
61
95
  };
62
96
  if (singleFlightEnabled) {
63
- const outcome = runWithEmbedLock(home, lockDependencies, () => {
97
+ const outcome = runWithIndexSyncLock(home, lockDependencies, dependencies, () => {
64
98
  updateIndex();
65
99
  return embed ? runEmbed() : undefined;
66
100
  });
67
101
  if (outcome === 'busy') {
68
- writeStderr('hq: qmd reindex is already running; skipping this pass.\n');
102
+ writeStderr('hq: qmd index update is busy; skipping this pass.\n');
69
103
  return { status: 'skipped', reason: 'busy' };
70
104
  }
71
105
  if (outcome === 'unavailable') {
@@ -74,7 +108,17 @@ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
74
108
  }
75
109
  return { status: 'updated', embedded };
76
110
  }
77
- updateIndex();
111
+ const updateOutcome = runWithIndexSyncLock(home, lockDependencies, dependencies, () => {
112
+ updateIndex();
113
+ });
114
+ if (updateOutcome === 'busy') {
115
+ writeStderr('hq: qmd index update is busy; skipping this pass.\n');
116
+ return { status: 'skipped', reason: 'busy' };
117
+ }
118
+ if (updateOutcome === 'unavailable') {
119
+ writeStderr('hq: cannot access the qmd reindex lock; skipping this pass.\n');
120
+ return { status: 'skipped', reason: 'unavailable' };
121
+ }
78
122
  if (!embed)
79
123
  return { status: 'updated', embedded: false };
80
124
  const outcome = runWithEmbedLock(home, lockDependencies, runEmbed);
@@ -122,11 +122,28 @@ export function qmdReindexAfterSync(args = [], options = {}) {
122
122
  }
123
123
  return 0;
124
124
  }
125
- updateIndex();
126
- if (!embed)
127
- return 0;
128
125
  const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
129
126
  const home = resolveQmdHome(lockDependencies.env);
127
+ try {
128
+ const outcome = runWithEmbedLock(home, lockDependencies, () => { updateIndex(); });
129
+ if (outcome === "busy") {
130
+ line(stderr, "hq: qmd reindex is already running; skipping this post-sync pass.");
131
+ return 0;
132
+ }
133
+ if (outcome === "unavailable") {
134
+ line(stderr, "hq: cannot access the qmd reindex lock; skipping this post-sync pass.");
135
+ return 0;
136
+ }
137
+ }
138
+ catch (error) {
139
+ if (error instanceof LoadGateConfigurationError)
140
+ line(stderr, `hq: ${error.message}`);
141
+ warnOnNativeStartupFailure(error);
142
+ /* embeddings are deferred-cost and sync must not wait on search freshness */
143
+ return 0;
144
+ }
145
+ if (!embed)
146
+ return 0;
130
147
  try {
131
148
  const outcome = runWithEmbedLock(home, lockDependencies, () => runEmbed(home, lockDependencies));
132
149
  if (outcome === "busy") {
@@ -1,5 +1,5 @@
1
1
  export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
2
2
  export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
3
- /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
3
+ /** Keep whole update-to-embed passes single-flight only for explicitly enabled canaries. */
4
4
  export declare function qmdReindexSingleFlightEnabled(): boolean;
5
5
  //# sourceMappingURL=reindex-single-flight-flag.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { resolveProcessFlagGate } from "../flag-registry.js";
2
2
  export const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
3
3
  export const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
4
- /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
4
+ /** Keep whole update-to-embed passes single-flight only for explicitly enabled canaries. */
5
5
  export function qmdReindexSingleFlightEnabled() {
6
6
  return resolveProcessFlagGate(QMD_REINDEX_SINGLE_FLIGHT_FLAG, { fallback: QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK }, () => QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK);
7
7
  }
@@ -178,7 +178,7 @@ export declare function isPrefixWritable(prefix: string, access?: (target: strin
178
178
  * When one of those directories has not been created yet, check its nearest
179
179
  * existing ancestor: npm can create a child only if that ancestor is writable.
180
180
  */
181
- export declare function canWriteNpmInstall(prefix: string, packageRoot: string, access?: (target: string, mode: number) => void): boolean;
181
+ export declare function canWriteNpmInstall(prefix: string, packageRoot: string, access?: (target: string, mode: number) => void, platform?: NodeJS.Platform): boolean;
182
182
  /** Claim the one local warning/report for this installed version and prefix. */
183
183
  export declare function claimNonWritableNpmPrefixNotice(install: Pick<RunningInstall, "manager" | "prefix" | "packageRoot">, fromVersion: string, homeDir?: string, kind?: "user" | "report"): boolean;
184
184
  /** Emit one path-free warning event for a handled non-writable npm prefix. */
@@ -432,8 +432,8 @@ export function isPrefixWritable(prefix, access = (target, mode) => accessSync(t
432
432
  * When one of those directories has not been created yet, check its nearest
433
433
  * existing ancestor: npm can create a child only if that ancestor is writable.
434
434
  */
435
- export function canWriteNpmInstall(prefix, packageRoot, access = (target, mode) => accessSync(target, mode)) {
436
- let nodeModulesRoot = path.join(prefix, process.platform === "win32" ? "node_modules" : "lib/node_modules");
435
+ export function canWriteNpmInstall(prefix, packageRoot, access = (target, mode) => accessSync(target, mode), platform = process.platform) {
436
+ let nodeModulesRoot = path.join(prefix, platform === "win32" ? "node_modules" : "lib/node_modules");
437
437
  let cursor = path.resolve(packageRoot);
438
438
  while (true) {
439
439
  if (path.basename(cursor).toLowerCase() === "node_modules") {
@@ -446,11 +446,11 @@ export function canWriteNpmInstall(prefix, packageRoot, access = (target, mode)
446
446
  cursor = parent;
447
447
  }
448
448
  const mode = fsConstants.W_OK | fsConstants.X_OK;
449
- // npm needs write access to the directories it replaces or links into. The
450
- // prefix root itself need not be writable when those directories already
451
- // exist; when one is missing, the nearest-existing-ancestor check below
452
- // still verifies permission to create it.
453
- const targets = [path.join(prefix, "bin"), nodeModulesRoot, path.dirname(packageRoot)];
449
+ // POSIX npm writes command shims under <prefix>/bin; Windows writes them
450
+ // directly under <prefix>. Check the actual shim directory alongside the
451
+ // package directories npm replaces or links into.
452
+ const shimDirectory = platform === "win32" ? prefix : path.join(prefix, "bin");
453
+ const targets = [shimDirectory, nodeModulesRoot, path.dirname(packageRoot)];
454
454
  return targets.every((target) => {
455
455
  let candidate = path.resolve(target);
456
456
  while (true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.336.0",
3
+ "version": "5.337.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {