@indigoai-us/hq-cli 5.336.0 → 5.338.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 +8 -0
- package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +81 -6
- package/assets/scaffold/core/scripts/repo-run-registry.sh +26 -4
- package/dist/commands/core-migrate-policy-triggers-native.js +132 -9
- package/dist/commands/index-cmd.d.ts +3 -0
- package/dist/commands/index-cmd.js +48 -4
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +20 -3
- package/dist/lib/search-index/reindex-single-flight-flag.d.ts +1 -1
- package/dist/lib/search-index/reindex-single-flight-flag.js +1 -1
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +7 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.338.0] — 2026-10-03
|
|
6
|
+
- Preserve policy-trigger mutual exclusion without `flock` and honor configured stale-run thresholds without `yq`.
|
|
7
|
+
- Add a manual macOS VM parity workflow for checking HQ CLI behavior in Tart.
|
|
8
|
+
|
|
9
|
+
## [5.337.0] — 2026-10-03
|
|
10
|
+
- QMD index updates now share the cross-process writer lock and skip bounded SQLite contention so a later sync can catch up.
|
|
11
|
+
- Self-update now checks the Windows npm prefix that stores command shims, so an unwritable prefix is detected before npm runs.
|
|
12
|
+
|
|
5
13
|
## [5.336.0] — 2026-10-03
|
|
6
14
|
- Client-health refresh failures now retain the bounded Cognito OAuth error class for accurate auth diagnostics.
|
|
7
15
|
- Fix macOS Bash 3.2 service PATH generation when nounset is enabled.
|
|
@@ -124,8 +124,61 @@ fi
|
|
|
124
124
|
COOLDOWN_SECONDS="${HQ_MIGRATE_POLICY_TRIGGERS_COOLDOWN_SECONDS:-3600}"
|
|
125
125
|
STAMP=""
|
|
126
126
|
LOCK=""
|
|
127
|
+
LOCK_FALLBACK_DIR=""
|
|
128
|
+
LOCK_FALLBACK_HELD=0
|
|
129
|
+
LOCK_FALLBACK_OWNER=""
|
|
130
|
+
LOCK_FALLBACK_STALE_SECONDS=86400
|
|
127
131
|
STATE_READY=0
|
|
128
132
|
|
|
133
|
+
release_fallback_lock() {
|
|
134
|
+
[ "$LOCK_FALLBACK_HELD" = "1" ] || return 0
|
|
135
|
+
[ "$(cat "$LOCK_FALLBACK_DIR/owner" 2>/dev/null)" = "$LOCK_FALLBACK_OWNER" ] || return 0
|
|
136
|
+
rm -f "$LOCK_FALLBACK_DIR/owner" 2>/dev/null || return 0
|
|
137
|
+
rmdir "$LOCK_FALLBACK_DIR" 2>/dev/null || true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fallback_lock_is_stale() {
|
|
141
|
+
local lock_dir="$1" modified now age
|
|
142
|
+
modified="$(stat -c '%Y' "$lock_dir" 2>/dev/null || stat -f '%m' "$lock_dir" 2>/dev/null)"
|
|
143
|
+
case "$modified" in ''|*[!0-9]*) return 1 ;; esac
|
|
144
|
+
now="$(date -u '+%s')"
|
|
145
|
+
age=$((now - modified))
|
|
146
|
+
[ "$age" -ge "$LOCK_FALLBACK_STALE_SECONDS" ]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
reclaim_stale_fallback_lock() {
|
|
150
|
+
local stale_dir="${LOCK_FALLBACK_DIR}.stale.$$.$RANDOM" owner_present=0 owner_value=""
|
|
151
|
+
restore_stale_dir() {
|
|
152
|
+
# Never let mv place the quarantined directory inside a replacement lock.
|
|
153
|
+
# A new holder may have created the path after our rename-aside.
|
|
154
|
+
if [ -e "$LOCK_FALLBACK_DIR" ] || [ -L "$LOCK_FALLBACK_DIR" ]; then return 1; fi
|
|
155
|
+
mv "$stale_dir" "$LOCK_FALLBACK_DIR" 2>/dev/null
|
|
156
|
+
}
|
|
157
|
+
[ -d "$LOCK_FALLBACK_DIR" ] || return 1
|
|
158
|
+
fallback_lock_is_stale "$LOCK_FALLBACK_DIR" || return 1
|
|
159
|
+
# Rename the observed lock aside atomically. Recheck the moved directory so
|
|
160
|
+
# a stale decision is never used to remove a replacement lock at the path.
|
|
161
|
+
mv "$LOCK_FALLBACK_DIR" "$stale_dir" 2>/dev/null || return 1
|
|
162
|
+
if fallback_lock_is_stale "$stale_dir"; then
|
|
163
|
+
if [ -e "$stale_dir/owner" ]; then
|
|
164
|
+
owner_present=1
|
|
165
|
+
owner_value="$(cat "$stale_dir/owner" 2>/dev/null)"
|
|
166
|
+
rm -f "$stale_dir/owner" 2>/dev/null || {
|
|
167
|
+
restore_stale_dir || true
|
|
168
|
+
return 1
|
|
169
|
+
}
|
|
170
|
+
fi
|
|
171
|
+
if rmdir "$stale_dir" 2>/dev/null; then
|
|
172
|
+
return 0
|
|
173
|
+
fi
|
|
174
|
+
if [ "$owner_present" = "1" ]; then
|
|
175
|
+
(set -o noclobber; printf '%s\n' "$owner_value" >"$stale_dir/owner") 2>/dev/null || true
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
restore_stale_dir || true
|
|
179
|
+
return 1
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
# Hash the resolved scope rather than using a shared per-root stamp: the
|
|
130
183
|
# default scan deliberately includes tenant-specific directories.
|
|
131
184
|
scope_key() {
|
|
@@ -192,12 +245,34 @@ fi
|
|
|
192
245
|
# A scope-local advisory lock prevents concurrent SessionStart hooks from both
|
|
193
246
|
# observing the same absent/stale stamp. Recheck after acquiring it because a
|
|
194
247
|
# prior holder may have completed while this invocation waited to acquire it.
|
|
195
|
-
if [ "$STATE_READY" = "1" ] && [ "$DRY" != "1" ]
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
248
|
+
if [ "$STATE_READY" = "1" ] && [ "$DRY" != "1" ]; then
|
|
249
|
+
if command -v flock >/dev/null 2>&1; then
|
|
250
|
+
if ! exec 9>"$LOCK"; then
|
|
251
|
+
exit 0
|
|
252
|
+
fi
|
|
253
|
+
if ! flock -n 9; then
|
|
254
|
+
exit 0
|
|
255
|
+
fi
|
|
256
|
+
else
|
|
257
|
+
# Atomic directory creation preserves mutual exclusion on stock macOS,
|
|
258
|
+
# which does not ship util-linux flock. An abandoned directory is only
|
|
259
|
+
# reclaimed after a 24-hour age threshold, well above this bounded scan.
|
|
260
|
+
LOCK_FALLBACK_DIR="${LOCK}.d"
|
|
261
|
+
if ! mkdir "$LOCK_FALLBACK_DIR" 2>/dev/null; then
|
|
262
|
+
if reclaim_stale_fallback_lock; then
|
|
263
|
+
mkdir "$LOCK_FALLBACK_DIR" 2>/dev/null || exit 0
|
|
264
|
+
else
|
|
265
|
+
exit 0
|
|
266
|
+
fi
|
|
267
|
+
fi
|
|
268
|
+
LOCK_FALLBACK_HELD=1
|
|
269
|
+
LOCK_FALLBACK_OWNER="$$:$RANDOM:$(date -u '+%s')"
|
|
270
|
+
if ! printf '%s\n' "$LOCK_FALLBACK_OWNER" >"$LOCK_FALLBACK_DIR/owner"; then
|
|
271
|
+
rmdir "$LOCK_FALLBACK_DIR" 2>/dev/null || true
|
|
272
|
+
LOCK_FALLBACK_HELD=0
|
|
273
|
+
exit 0
|
|
274
|
+
fi
|
|
275
|
+
trap release_fallback_lock EXIT
|
|
201
276
|
fi
|
|
202
277
|
if cooldown_blocks_run; then
|
|
203
278
|
exit 0
|
|
@@ -127,10 +127,32 @@ _is_pid_alive() {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
_stale_minutes() {
|
|
130
|
-
if [[ -f "$ORCH_YAML" ]]
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
if [[ -f "$ORCH_YAML" ]]; then
|
|
131
|
+
if command -v yq >/dev/null 2>&1; then
|
|
132
|
+
local v
|
|
133
|
+
v=$(yq eval '.repo_coordination.stale_heartbeat_minutes // 15' "$ORCH_YAML" 2>/dev/null)
|
|
134
|
+
[[ -n "$v" && "$v" != "null" ]] && { echo "$v"; return; }
|
|
135
|
+
else
|
|
136
|
+
# Parse the one scalar this helper needs when yq is unavailable. Do not
|
|
137
|
+
# silently replace a configured timeout with the 15-minute default.
|
|
138
|
+
awk '
|
|
139
|
+
/^repo_coordination:[[:space:]]*(#.*)?$/ { in_repo=1; next }
|
|
140
|
+
in_repo && /^[^[:space:]#]/ { exit }
|
|
141
|
+
in_repo && /^[[:space:]]+stale_heartbeat_minutes:[[:space:]]*/ {
|
|
142
|
+
sub(/^[[:space:]]+stale_heartbeat_minutes:[[:space:]]*/, "")
|
|
143
|
+
sub(/[[:space:]]+#.*$/, "")
|
|
144
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
|
|
145
|
+
gsub(/^["\047]|["\047]$/, "")
|
|
146
|
+
if ($0 == "" || $0 == "null" || $0 == "~") print 15
|
|
147
|
+
else if ($0 ~ /^[0-9]+$/) print $0
|
|
148
|
+
else print 15
|
|
149
|
+
found=1
|
|
150
|
+
exit
|
|
151
|
+
}
|
|
152
|
+
END { if (!found) print 15 }
|
|
153
|
+
' "$ORCH_YAML"
|
|
154
|
+
return
|
|
155
|
+
fi
|
|
134
156
|
fi
|
|
135
157
|
echo 15
|
|
136
158
|
}
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { evaluateTrigger } from "../utils/core-eval-trigger.js";
|
|
8
8
|
import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
|
|
9
9
|
export const MIGRATE_POLICY_TRIGGERS_ASSET = "core/scripts/migrate-policy-triggers.sh";
|
|
10
|
+
const FALLBACK_LOCK_STALE_SECONDS = 24 * 60 * 60;
|
|
10
11
|
const ON_LIVE = "[PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent]";
|
|
11
12
|
const ON_START = "[SessionStart]";
|
|
12
13
|
const TAG_STOP = new Set(("infrastructure consolidated ux safety hq hq-core hq-cli hq-packages " +
|
|
@@ -173,7 +174,7 @@ function unreadablePolicyDiagnostic(filename, error, pathValue) {
|
|
|
173
174
|
const message = code === "EACCES" || code === "EPERM" ? "Permission denied" : "No such file or directory";
|
|
174
175
|
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold/core/scripts/migrate-policy-triggers.sh");
|
|
175
176
|
const awk = awkCannotOpenFileDiagnostic(filename, message, 1, process.platform, pathValue);
|
|
176
|
-
return `${script}: line
|
|
177
|
+
return `${script}: line 389: ${filename}: ${message}\n${awk.repeat(3)}`;
|
|
177
178
|
}
|
|
178
179
|
function shellGlobMd(fileSystem, directory) {
|
|
179
180
|
try {
|
|
@@ -255,20 +256,141 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
255
256
|
if (cooldownBlocks())
|
|
256
257
|
return { code: 0, stdout, stderr };
|
|
257
258
|
let lockFd;
|
|
258
|
-
|
|
259
|
+
let fallbackLockDir;
|
|
260
|
+
let fallbackLockOwner;
|
|
261
|
+
const releaseFallbackLock = () => {
|
|
262
|
+
if (!fallbackLockDir)
|
|
263
|
+
return;
|
|
259
264
|
try {
|
|
260
|
-
|
|
265
|
+
if (fileSystem.readFileSync(path.join(fallbackLockDir, "owner"), "utf8").trim() === fallbackLockOwner) {
|
|
266
|
+
fileSystem.unlinkSync(path.join(fallbackLockDir, "owner"));
|
|
267
|
+
fileSystem.rmdirSync(fallbackLockDir);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch { /* preserve the lock if ownership cannot be confirmed */ }
|
|
271
|
+
fallbackLockDir = undefined;
|
|
272
|
+
};
|
|
273
|
+
const reclaimStaleFallbackLock = (directory) => {
|
|
274
|
+
const restoreQuarantined = (staleDirectory) => {
|
|
275
|
+
try {
|
|
276
|
+
fileSystem.lstatSync(directory);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code !== "ENOENT")
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
fileSystem.renameSync(staleDirectory, directory);
|
|
285
|
+
}
|
|
286
|
+
catch { /* preserve the quarantine if restoration fails */ }
|
|
287
|
+
};
|
|
288
|
+
const age = (candidate) => {
|
|
289
|
+
try {
|
|
290
|
+
return Math.floor(deps.now().getTime() / 1000) - Math.floor(fileSystem.statSync(candidate).mtimeMs / 1000);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
if ((age(directory) ?? -1) < FALLBACK_LOCK_STALE_SECONDS)
|
|
297
|
+
return false;
|
|
298
|
+
const staleDirectory = `${directory}.stale.${process.pid}.${Math.random().toString(16).slice(2)}`;
|
|
299
|
+
try {
|
|
300
|
+
fileSystem.renameSync(directory, staleDirectory);
|
|
261
301
|
}
|
|
262
302
|
catch {
|
|
263
|
-
return
|
|
303
|
+
return false;
|
|
264
304
|
}
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
305
|
+
const staleAge = age(staleDirectory);
|
|
306
|
+
if (staleAge !== undefined && staleAge >= FALLBACK_LOCK_STALE_SECONDS) {
|
|
307
|
+
let ownerBytes;
|
|
308
|
+
try {
|
|
309
|
+
ownerBytes = fileSystem.readFileSync(path.join(staleDirectory, "owner"), "utf8");
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error.code !== "ENOENT") {
|
|
313
|
+
restoreQuarantined(staleDirectory);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (ownerBytes !== undefined) {
|
|
318
|
+
try {
|
|
319
|
+
fileSystem.unlinkSync(path.join(staleDirectory, "owner"));
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
restoreQuarantined(staleDirectory);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
fileSystem.rmdirSync(staleDirectory);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
if (ownerBytes !== undefined) {
|
|
332
|
+
try {
|
|
333
|
+
fileSystem.writeFileSync(path.join(staleDirectory, "owner"), ownerBytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
334
|
+
}
|
|
335
|
+
catch { /* keep the quarantined directory rather than overwrite a changed marker */ }
|
|
336
|
+
}
|
|
337
|
+
restoreQuarantined(staleDirectory);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
restoreQuarantined(staleDirectory);
|
|
342
|
+
return false;
|
|
343
|
+
};
|
|
344
|
+
if (stateReady && !dryRun) {
|
|
345
|
+
if (executableInPath("flock", deps.env, fileSystem)) {
|
|
346
|
+
try {
|
|
347
|
+
lockFd = fileSystem.openSync(lock, "w");
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return { code: 0, stdout, stderr };
|
|
351
|
+
}
|
|
352
|
+
const lockResult = (deps.runFlock ?? runNonblockingFlock)(lockFd, deps.cwd, deps.env.PATH ?? "");
|
|
353
|
+
if (lockResult.error || lockResult.status !== 0) {
|
|
354
|
+
fileSystem.closeSync(lockFd);
|
|
355
|
+
return { code: 0, stdout, stderr };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
fallbackLockDir = `${lock}.d`;
|
|
360
|
+
try {
|
|
361
|
+
fileSystem.mkdirSync(fallbackLockDir);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
if (!reclaimStaleFallbackLock(fallbackLockDir)) {
|
|
365
|
+
fallbackLockDir = undefined;
|
|
366
|
+
return { code: 0, stdout, stderr };
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
fileSystem.mkdirSync(fallbackLockDir);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
fallbackLockDir = undefined;
|
|
373
|
+
return { code: 0, stdout, stderr };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
fallbackLockOwner = `${process.pid}:${Math.random().toString(36).slice(2)}:${nowSeconds()}`;
|
|
377
|
+
try {
|
|
378
|
+
fileSystem.writeFileSync(path.join(fallbackLockDir, "owner"), `${fallbackLockOwner}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
try {
|
|
382
|
+
fileSystem.rmdirSync(fallbackLockDir);
|
|
383
|
+
}
|
|
384
|
+
catch { /* preserve a lock if cleanup is unsafe */ }
|
|
385
|
+
fallbackLockDir = undefined;
|
|
386
|
+
fallbackLockOwner = undefined;
|
|
387
|
+
return { code: 0, stdout, stderr };
|
|
388
|
+
}
|
|
269
389
|
}
|
|
270
390
|
if (cooldownBlocks()) {
|
|
271
|
-
|
|
391
|
+
if (lockFd !== undefined)
|
|
392
|
+
fileSystem.closeSync(lockFd);
|
|
393
|
+
releaseFallbackLock();
|
|
272
394
|
return { code: 0, stdout, stderr };
|
|
273
395
|
}
|
|
274
396
|
}
|
|
@@ -363,6 +485,7 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
363
485
|
finally {
|
|
364
486
|
if (lockFd !== undefined)
|
|
365
487
|
fileSystem.closeSync(lockFd);
|
|
488
|
+
releaseFallbackLock();
|
|
366
489
|
}
|
|
367
490
|
return { code: errorCode, stdout, stderr };
|
|
368
491
|
}
|
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
const targets = [
|
|
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) {
|