@indigoai-us/hq-cli 5.109.10 → 5.109.11

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.11] — 2026-09-12
6
+
7
+ ### Fixed
8
+
9
+ - A torn-install recovery no longer gives up after 90 seconds while another hq
10
+ updater is still legitimately installing hq-cli. When a command starts just as
11
+ the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
12
+ rewriting the install, the recovery waits for that writer to finish — bounded by
13
+ the shared install lock's own 10-minute staleness window rather than the 90-second
14
+ settle deadline — and then re-runs once on the healed tree, so the common case
15
+ (an install that finishes within the window) no longer fails at all. A short
16
+ second line explains the pause while it waits. If a writer holds the lock past
17
+ that ceiling, the command still fails once, but with a "let the update finish,
18
+ then re-run — do not reinstall while it is running" remedy and a distinct
19
+ `lock-held` outcome, instead of advice to reinstall over a live installer
20
+ (Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
21
+ `HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
22
+
5
23
  ## [5.109.10] — 2026-09-12
6
24
 
7
25
  ### Fixed
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
35
35
  export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
36
36
  /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
37
  export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
39
+ export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
40
+ /**
41
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
42
+ * lock held by a live cooperating writer — the update-lock contract's own
43
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
44
+ */
45
+ export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
46
+ /**
47
+ * The single dim line emitted once when the wait continues PAST the settle
48
+ * deadline because a live updater still holds the shared install lock — so a human
49
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
50
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
51
+ * synchronize on it.
52
+ */
53
+ export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
54
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
55
+ export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
38
56
  /** Minimal view of a `spawnSync` result the seam relies on. */
39
57
  export interface RecoverySpawnResult {
40
58
  status: number | null;
@@ -19,7 +19,7 @@
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { resolveRunningInstall } from "./utils/version-gate.js";
21
21
  import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
22
- import { updateLockPath } from "./utils/update-lock.js";
22
+ import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
23
23
  import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
24
24
  /**
25
25
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
46
46
  return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
47
47
  return Number.parseInt(raw.trim(), 10);
48
48
  }
49
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
50
+ export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
51
+ /**
52
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
53
+ * lock held by a live cooperating writer — the update-lock contract's own
54
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
55
+ */
56
+ export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
57
+ /**
58
+ * The single dim line emitted once when the wait continues PAST the settle
59
+ * deadline because a live updater still holds the shared install lock — so a human
60
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
61
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
62
+ * synchronize on it.
63
+ */
64
+ export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
65
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
66
+ export function resolveSettleLockTimeoutMs(env) {
67
+ const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
68
+ if (typeof raw !== "string" || raw.trim() === "")
69
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
70
+ if (!/^\d+$/.test(raw.trim()))
71
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
72
+ return Number.parseInt(raw.trim(), 10);
73
+ }
49
74
  /**
50
75
  * Register commands, recovering once from a torn-install module-resolution
51
76
  * failure. On a clean registration this returns `{}` and touches nothing else.
@@ -90,17 +115,27 @@ export async function registerCommandsWithRecovery(args) {
90
115
  packageRoot,
91
116
  lockPath,
92
117
  deadlineMs: resolveSettleTimeoutMs(env),
118
+ lockWaitMs: resolveSettleLockTimeoutMs(env),
119
+ onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
93
120
  });
94
121
  if (!result.settled) {
95
122
  throw new InstallTreeTornError({
96
123
  cause: err,
97
124
  classified,
98
125
  packageRoot,
99
- outcome: "unsettled",
126
+ // A fresh foreign lock still held at the end means a live cooperating
127
+ // writer held the shared lock past its own ceiling — report 'lock-held'
128
+ // (its remedy tells the user to let the update finish, never to reinstall
129
+ // over it). Otherwise the tree genuinely never settled with no writer
130
+ // visible, which keeps meaning 'unsettled'.
131
+ outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
100
132
  attempt: 1,
101
133
  waitedMs: result.waitedMs,
102
134
  sawLock: result.sawLock,
103
135
  sawRetired: result.sawRetired,
136
+ lockHeldAtEnd: result.lockHeldAtEnd,
137
+ lockedMs: result.lockedMs,
138
+ lockTool: result.lockTool,
104
139
  });
105
140
  }
106
141
  const spawn = deps.spawn ?? spawnSync;
@@ -137,6 +137,15 @@ export interface InstallTreeFs {
137
137
  * Any filesystem error reads as "not present" rather than throwing.
138
138
  */
139
139
  export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
140
+ /**
141
+ * Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
142
+ * foreign lock held by a live pid. Equal to the shared update-lock contract's own
143
+ * staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
144
+ * this package runs 10 minutes"), so the wait trusts a live cooperating writer for
145
+ * exactly as long as the contract legitimises holding the lock, and never longer.
146
+ * Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
147
+ */
148
+ export declare const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS: number;
140
149
  export interface WaitForInstallTreeSettledArgs {
141
150
  target: ModuleErrorTarget;
142
151
  /** The running install's package dir (from resolveRunningInstall), or null. */
@@ -150,6 +159,22 @@ export interface WaitForInstallTreeSettledArgs {
150
159
  pollMs?: number;
151
160
  quietMs?: number;
152
161
  deadlineMs: number;
162
+ /**
163
+ * Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
164
+ * live pid. Locked time does NOT consume `deadlineMs` (the budget for the tree
165
+ * to settle once no cooperating writer is visible); it is bounded separately by
166
+ * this ceiling so the wait can never hang. Defaults to
167
+ * {@link DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS}.
168
+ */
169
+ lockWaitMs?: number;
170
+ /**
171
+ * Invoked exactly once — at the first poll where the total wait has reached
172
+ * `deadlineMs` while a fresh foreign lock is still held (the moment the
173
+ * pre-lock-aware wait would have given up). Lets the seam print a one-line
174
+ * notice that the pause is a live updater, not a hang. Never invoked when no
175
+ * lock is seen or the lock clears before the deadline.
176
+ */
177
+ onLockExtended?: () => void;
153
178
  }
154
179
  export interface WaitForInstallTreeSettledResult {
155
180
  settled: boolean;
@@ -158,20 +183,46 @@ export interface WaitForInstallTreeSettledResult {
158
183
  sawLock: boolean;
159
184
  /** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
160
185
  sawRetired: boolean;
186
+ /** A fresh foreign lock was held on the FINAL poll of an unsettled wait. */
187
+ lockHeldAtEnd: boolean;
188
+ /** Cumulative wall time observed under a fresh foreign lock held by a live pid. */
189
+ lockedMs: number;
190
+ /** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
191
+ lockTool: string;
161
192
  }
162
193
  /**
163
194
  * Poll until the install tree has been continuously READY for `quietMs`, or the
164
- * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
165
- * (ii) the missing target is present, and — when `packageRoot` is known —
166
- * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
167
- * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
168
- * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
169
- * evaluates readiness exactly once (no waiting); otherwise the whole wait is
170
- * bounded, so the caller can never hang.
195
+ * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
196
+ * pid, (ii) the missing target is present, and — when `packageRoot` is known —
197
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
198
+ * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
199
+ * its condition "not ready" rather than throwing.
200
+ *
201
+ * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
202
+ * cooperating writer (the desktop app, the box's update timer, another `hq`)
203
+ * cannot make a legitimately-in-progress reinstall look like a torn install:
204
+ * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
205
+ * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
206
+ * visible; with no lock ever seen, unlocked time equals total wait time and
207
+ * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
208
+ * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
209
+ * - LOCKED time — wall time observed under a fresh foreign lock held by a live
210
+ * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
211
+ * 10-minute staleness window) and does NOT consume the settle budget. When
212
+ * that ceiling is reached with the lock still held, the result is unsettled
213
+ * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
214
+ * outcome and remedy rather than telling the user to reinstall over a live
215
+ * writer.
216
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
217
+ * unlocked bucket by whether a fresh foreign lock was held when the interval
218
+ * began, so a starved event loop only makes the wait coarser, never unbounded. A
219
+ * stale lock (dead pid, older than the staleness window, unparseable) or our own
220
+ * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
221
+ * lockWaitMs`), so the caller can never hang.
171
222
  */
172
223
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
173
224
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
174
- export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
225
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
175
226
  export type InstallTreeTornDiagnostics = {
176
227
  dialect: ModuleErrorDialect;
177
228
  code: ModuleNotFoundCode;
@@ -183,6 +234,12 @@ export type InstallTreeTornDiagnostics = {
183
234
  waitedMs: number;
184
235
  sawLock: boolean;
185
236
  sawRetired: boolean;
237
+ /** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
238
+ lockHeldAtEnd: boolean;
239
+ /** Cumulative wall time the wait spent under a fresh foreign lock. */
240
+ lockedMs: number;
241
+ /** The bounded `tool` of the last fresh foreign lock seen, or "". */
242
+ lockTool: string;
186
243
  node: string;
187
244
  };
188
245
  export interface InstallTreeTornInit {
@@ -195,6 +252,12 @@ export interface InstallTreeTornInit {
195
252
  waitedMs: number;
196
253
  sawLock: boolean;
197
254
  sawRetired: boolean;
255
+ /** Whether a fresh foreign lock was held when the wait gave up (default false). */
256
+ lockHeldAtEnd?: boolean;
257
+ /** Cumulative time under a fresh foreign lock (default 0). */
258
+ lockedMs?: number;
259
+ /** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
260
+ lockTool?: string;
198
261
  }
199
262
  /**
200
263
  * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
@@ -221,10 +284,20 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
221
284
  * appended by {@link installTreeTornStderrLine}.
222
285
  */
223
286
  export declare const INSTALL_TREE_TORN_REMEDY: string;
287
+ /**
288
+ * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
289
+ * held the shared update lock past its own staleness ceiling. Telling the user to
290
+ * `npm i -g` here would collide with a live installer mid-rename — the exact race
291
+ * update-lock.ts exists to prevent — so this remedy tells them to let the running
292
+ * update finish and NEVER to reinstall over it. Interpolates NOTHING.
293
+ */
294
+ export declare const INSTALL_TREE_LOCK_HELD_REMEDY: string;
224
295
  /**
225
296
  * The single actionable stderr line for a captured torn-install failure: the
226
- * fixed remedy plus the bounded, hq-derived missing specifier — the only
227
- * interpolated value, never argv.
297
+ * outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
298
+ * interpolated value, never argv. A 'lock-held' outcome selects the "let the
299
+ * update finish" remedy (never the reinstall advice); every other outcome keeps
300
+ * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
228
301
  */
229
302
  export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
230
303
  //# sourceMappingURL=install-tree-torn.d.ts.map
@@ -34,11 +34,13 @@ import * as path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { CLI_NAME } from "../cli-version.js";
36
36
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
37
- import { isLockStale } from "./update-lock.js";
37
+ import { isLockStale, UPDATE_LOCK_STALE_MS } from "./update-lock.js";
38
38
  /** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
39
39
  const SPECIFIER_BYTES = 256;
40
40
  const IMPORTER_BYTES = 256;
41
41
  const PACKAGE_ROOT_BYTES = 256;
42
+ /** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
43
+ const LOCK_TOOL_BYTES = 64;
42
44
  const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
43
45
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
44
46
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
@@ -332,6 +334,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
332
334
  return false;
333
335
  }
334
336
  }
337
+ /**
338
+ * Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
339
+ * foreign lock held by a live pid. Equal to the shared update-lock contract's own
340
+ * staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
341
+ * this package runs 10 minutes"), so the wait trusts a live cooperating writer for
342
+ * exactly as long as the contract legitimises holding the lock, and never longer.
343
+ * Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
344
+ */
345
+ export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
335
346
  /** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
336
347
  function cliNameParts() {
337
348
  const slash = CLI_NAME.indexOf("/");
@@ -339,26 +350,32 @@ function cliNameParts() {
339
350
  return { scope: null, leaf: CLI_NAME };
340
351
  return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
341
352
  }
342
- /** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
343
- function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
353
+ /**
354
+ * The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
355
+ * blocks readiness AND extends the wait under the lock ceiling; the lock file is
356
+ * only ever READ. Freshness is decided by {@link isLockStale} exactly as before
357
+ * (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
358
+ * and our own pid never counts.
359
+ */
360
+ function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
344
361
  let raw;
345
362
  try {
346
363
  raw = fs.readFileSync(lockPath, "utf-8");
347
364
  }
348
365
  catch {
349
- return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
366
+ return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
350
367
  }
351
368
  if (isLockStale(raw, nowMs, isPidAlive))
352
- return false;
369
+ return null;
353
370
  try {
354
371
  const info = JSON.parse(raw);
355
372
  if (info.pid === process.pid)
356
- return false; // our own lock never blocks us
373
+ return null; // our own lock never blocks us
374
+ return info;
357
375
  }
358
376
  catch {
359
- return false; // isLockStale already treats unparseable as stale, unreachable
377
+ return null; // isLockStale already treats unparseable as stale, unreachable
360
378
  }
361
- return true;
362
379
  }
363
380
  /** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
364
381
  function retiredSiblingPresent(packageRoot, fs) {
@@ -375,13 +392,33 @@ function packageRootHealthy(packageRoot, fs) {
375
392
  }
376
393
  /**
377
394
  * Poll until the install tree has been continuously READY for `quietMs`, or the
378
- * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
379
- * (ii) the missing target is present, and — when `packageRoot` is known —
380
- * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
381
- * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
382
- * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
383
- * evaluates readiness exactly once (no waiting); otherwise the whole wait is
384
- * bounded, so the caller can never hang.
395
+ * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
396
+ * pid, (ii) the missing target is present, and — when `packageRoot` is known —
397
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
398
+ * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
399
+ * its condition "not ready" rather than throwing.
400
+ *
401
+ * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
402
+ * cooperating writer (the desktop app, the box's update timer, another `hq`)
403
+ * cannot make a legitimately-in-progress reinstall look like a torn install:
404
+ * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
405
+ * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
406
+ * visible; with no lock ever seen, unlocked time equals total wait time and
407
+ * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
408
+ * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
409
+ * - LOCKED time — wall time observed under a fresh foreign lock held by a live
410
+ * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
411
+ * 10-minute staleness window) and does NOT consume the settle budget. When
412
+ * that ceiling is reached with the lock still held, the result is unsettled
413
+ * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
414
+ * outcome and remedy rather than telling the user to reinstall over a live
415
+ * writer.
416
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
417
+ * unlocked bucket by whether a fresh foreign lock was held when the interval
418
+ * began, so a starved event loop only makes the wait coarser, never unbounded. A
419
+ * stale lock (dead pid, older than the staleness window, unparseable) or our own
420
+ * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
421
+ * lockWaitMs`), so the caller can never hang.
385
422
  */
386
423
  export async function waitForInstallTreeSettled(args) {
387
424
  const now = args.now ?? Date.now;
@@ -391,16 +428,32 @@ export async function waitForInstallTreeSettled(args) {
391
428
  const pollMs = args.pollMs ?? 250;
392
429
  const quietMs = args.quietMs ?? 1500;
393
430
  const deadlineMs = args.deadlineMs;
431
+ const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
394
432
  const start = now();
395
433
  let readySince = null;
396
434
  let sawLock = false;
397
435
  let sawRetired = false;
436
+ let lockedMs = 0;
437
+ let unlockedMs = 0;
438
+ let lockTool = "";
439
+ let prevT = start;
440
+ let prevForeignLockHeld = false;
441
+ let lockExtendedFired = false;
398
442
  for (;;) {
399
443
  const t = now();
400
- const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
401
- if (foreignLock)
444
+ // Attribute the interval that just elapsed [prevT, t) to the bucket for the
445
+ // lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
446
+ if (prevForeignLockHeld)
447
+ lockedMs += t - prevT;
448
+ else
449
+ unlockedMs += t - prevT;
450
+ const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
451
+ const foreignLockHeld = lock !== null;
452
+ if (foreignLockHeld) {
402
453
  sawLock = true;
403
- let ready = !foreignLock && installTargetPresent(args.target, fs);
454
+ lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
455
+ }
456
+ let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
404
457
  if (ready && args.packageRoot) {
405
458
  try {
406
459
  if (!packageRootHealthy(args.packageRoot, fs))
@@ -430,11 +483,29 @@ export async function waitForInstallTreeSettled(args) {
430
483
  }
431
484
  const waitedMs = t - start;
432
485
  if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
433
- return { settled: true, waitedMs, sawLock, sawRetired };
486
+ // Settling requires no foreign lock, so it is never held at the end here.
487
+ return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
488
+ }
489
+ // Give up when the TREE has had a full unlocked budget with no writer visible,
490
+ // OR a writer is STILL holding the lock and has held it past its own staleness
491
+ // ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
492
+ // ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
493
+ // lock") disables only the extension and does not collapse the ordinary
494
+ // deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
495
+ // `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
496
+ // matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
497
+ if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
498
+ return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
434
499
  }
435
- if (waitedMs >= deadlineMs) {
436
- return { settled: false, waitedMs, sawLock, sawRetired };
500
+ // The first poll at which the wait has reached `deadlineMs` while a lock is
501
+ // still held is exactly where the pre-lock-aware wait gave up — announce the
502
+ // extension once, and only when we are genuinely going to keep waiting.
503
+ if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
504
+ lockExtendedFired = true;
505
+ args.onLockExtended?.();
437
506
  }
507
+ prevT = t;
508
+ prevForeignLockHeld = foreignLockHeld;
438
509
  await sleep(pollMs);
439
510
  }
440
511
  }
@@ -464,6 +535,9 @@ export class InstallTreeTornError extends Error {
464
535
  waitedMs: init.waitedMs,
465
536
  sawLock: init.sawLock,
466
537
  sawRetired: init.sawRetired,
538
+ lockHeldAtEnd: init.lockHeldAtEnd ?? false,
539
+ lockedMs: init.lockedMs ?? 0,
540
+ lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
467
541
  node: process.version,
468
542
  };
469
543
  Object.setPrototypeOf(this, new.target.prototype);
@@ -486,15 +560,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
486
560
  "timer, the desktop app, or another hq command) while this command started. " +
487
561
  "Re-run your command; if it keeps failing, reinstall with " +
488
562
  "`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
563
+ /**
564
+ * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
565
+ * held the shared update lock past its own staleness ceiling. Telling the user to
566
+ * `npm i -g` here would collide with a live installer mid-rename — the exact race
567
+ * update-lock.ts exists to prevent — so this remedy tells them to let the running
568
+ * update finish and NEVER to reinstall over it. Interpolates NOTHING.
569
+ */
570
+ export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
571
+ "hq command) was still installing hq-cli when this command stopped waiting. Let " +
572
+ "that update finish, then re-run your command; do not reinstall while it is running.";
489
573
  /**
490
574
  * The single actionable stderr line for a captured torn-install failure: the
491
- * fixed remedy plus the bounded, hq-derived missing specifier — the only
492
- * interpolated value, never argv.
575
+ * outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
576
+ * interpolated value, never argv. A 'lock-held' outcome selects the "let the
577
+ * update finish" remedy (never the reinstall advice); every other outcome keeps
578
+ * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
493
579
  */
494
580
  export function installTreeTornStderrLine(err) {
581
+ const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
495
582
  const specifier = err.diagnostics.specifier;
496
- return specifier
497
- ? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
498
- : INSTALL_TREE_TORN_REMEDY;
583
+ return specifier ? `${remedy} (missing: ${specifier})` : remedy;
499
584
  }
500
585
  //# sourceMappingURL=install-tree-torn.js.map
@@ -92,8 +92,8 @@ const KNOWN_ERROR_NAMES = new Set([
92
92
  // (HQ-CLI-1A).
93
93
  "QmdWorkdirMissingError",
94
94
  // A torn-install failure the bounded settle-wait + single re-exec could not
95
- // recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
96
- // bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
95
+ // recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
96
+ // by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
97
97
  "InstallTreeTornError",
98
98
  ]);
99
99
  /**
@@ -102,7 +102,7 @@ const KNOWN_ERROR_NAMES = new Set([
102
102
  * the carrier's message is fixed, so this outcome IS the only discriminator.
103
103
  */
104
104
  const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
105
- "guarded", "unsettled", "reexec-failed",
105
+ "guarded", "unsettled", "reexec-failed", "lock-held",
106
106
  ]);
107
107
  /** Fixed bucket for any error name outside the closed allowlist. */
108
108
  const FALLBACK_ERROR_NAME = "other";
@@ -214,8 +214,8 @@ export function sentryFingerprintFor(err) {
214
214
  }
215
215
  // A torn-install failure carries neither an rpcCode nor an HTTP status; its
216
216
  // only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
217
- // BEFORE the rpc/status branches so the family groups into ≤3 predictable
218
- // issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
217
+ // BEFORE the rpc/status branches so the family groups into ≤4 predictable issues
218
+ // (guarded / unsettled / reexec-failed / lock-held) rather than one fungible bucket.
219
219
  if (rawName === "InstallTreeTornError") {
220
220
  const diagnostics = record.diagnostics;
221
221
  const outcome = diagnostics !== null && typeof diagnostics === "object"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.10",
3
+ "version": "5.109.11",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {