@indigoai-us/hq-cli 5.163.0 → 5.165.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 +20 -0
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +17 -1
- package/dist/sentry.js +5 -1
- package/dist/startup-registration.d.ts +3 -1
- package/dist/startup-registration.js +25 -4
- package/dist/utils/install-tree-torn.d.ts +29 -10
- package/dist/utils/install-tree-torn.js +262 -29
- package/dist/utils/sentry-fingerprint.d.ts +7 -1
- package/dist/utils/sentry-fingerprint.js +95 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.165.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Codex lane starts and resumes now pass the assigned worker policy directory to
|
|
10
|
+
policy hooks.
|
|
11
|
+
|
|
12
|
+
## [5.164.0] — 2026-09-24
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- When a startup import fails, hq-cli checks for an update lock, a retired package
|
|
17
|
+
directory, or install-tree changes for 1.5 seconds. If none appears, it exits
|
|
18
|
+
with `npm install -g @indigoai-us/hq-cli@latest` instead of waiting 90 seconds.
|
|
19
|
+
Sentry context replaces the home-directory prefix with `~`.
|
|
20
|
+
- Sentry keeps the `guarded`, `unsettled`, `reexec-failed`, and `lock-held`
|
|
21
|
+
outcomes, adds `stale_tree` and `tree_state`, and tags `updater_source` when
|
|
22
|
+
known. The fingerprint remains `["InstallTreeTornError", "outcome:<token>"]`,
|
|
23
|
+
so the four existing outcome groups keep their keys.
|
|
24
|
+
|
|
5
25
|
## [5.163.0] — 2026-09-24
|
|
6
26
|
|
|
7
27
|
- Workforce plan refusals in the CLI keep direct checkout for eligible companies.
|
|
@@ -222,6 +222,8 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
|
|
|
222
222
|
fastMode?: boolean;
|
|
223
223
|
/** Per-spawn worker profile instructions and materialized settings. */
|
|
224
224
|
profile?: ProviderProfileLaunch;
|
|
225
|
+
/** Worker profile path, forwarded to Codex hook subprocesses when supported. */
|
|
226
|
+
workerPolicyDir?: string;
|
|
225
227
|
/** Continue a recorded session. Only providers in PROVIDER_SESSION_RESUME accept this. */
|
|
226
228
|
resume?: ProviderResumeInput;
|
|
227
229
|
/**
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -522,6 +522,19 @@ function appendProviderProfileFlags(provider, args, profile) {
|
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
|
+
/** Make the worker policy directory available to Codex hook subprocesses. */
|
|
526
|
+
function appendCodexWorkerPolicyDirFlag(provider, args, workerPolicyDir) {
|
|
527
|
+
if (provider !== "codex" || workerPolicyDir === undefined)
|
|
528
|
+
return args;
|
|
529
|
+
if (!workerPolicyDir.trim()) {
|
|
530
|
+
throw new LanesError("invalid_worker_policy_dir", "Worker policy directory must not be empty");
|
|
531
|
+
}
|
|
532
|
+
const setting = `shell_environment_policy.set.HQ_POLICY_WORKER_DIR=${JSON.stringify(workerPolicyDir)}`;
|
|
533
|
+
const separator = args.indexOf("--");
|
|
534
|
+
if (separator === -1)
|
|
535
|
+
return [...args, "-c", setting];
|
|
536
|
+
return [...args.slice(0, separator), "-c", setting, ...args.slice(separator)];
|
|
537
|
+
}
|
|
525
538
|
export const PID_LIVENESS_DECISIONS = [
|
|
526
539
|
"esrch",
|
|
527
540
|
"starttime_mismatch",
|
|
@@ -1138,7 +1151,7 @@ export function buildProviderCommand(provider, input) {
|
|
|
1138
1151
|
}
|
|
1139
1152
|
return {
|
|
1140
1153
|
command,
|
|
1141
|
-
args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.model), input.effort), input.fastMode),
|
|
1154
|
+
args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
|
|
1142
1155
|
};
|
|
1143
1156
|
}
|
|
1144
1157
|
export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir) {
|
|
@@ -1757,6 +1770,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1757
1770
|
effort: effort.effort,
|
|
1758
1771
|
fastMode,
|
|
1759
1772
|
profile: profileLaunch,
|
|
1773
|
+
workerPolicyDir,
|
|
1760
1774
|
resume,
|
|
1761
1775
|
});
|
|
1762
1776
|
// A systemd PATH mismatch is an environment defect, not a lane failure.
|
|
@@ -1806,6 +1820,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1806
1820
|
effort: effort.effort,
|
|
1807
1821
|
fastMode,
|
|
1808
1822
|
profile: profileLaunch,
|
|
1823
|
+
workerPolicyDir,
|
|
1809
1824
|
resume: { session_id: resume.session_id, prompt: COMPACTION_PROMPT },
|
|
1810
1825
|
});
|
|
1811
1826
|
compaction = compactLaneSession({
|
|
@@ -2128,6 +2143,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2128
2143
|
effort: effort.effort,
|
|
2129
2144
|
fastMode,
|
|
2130
2145
|
profile: profileLaunch,
|
|
2146
|
+
workerPolicyDir,
|
|
2131
2147
|
...(mode === "resume" && sessionId !== null
|
|
2132
2148
|
? { resume: { session_id: sessionId, prompt: repairPrompt } }
|
|
2133
2149
|
: { freshTurnPrompt: repairPrompt }),
|
package/dist/sentry.js
CHANGED
|
@@ -7,7 +7,7 @@ import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
|
7
7
|
import { isEpipe } from "./utils/epipe.js";
|
|
8
8
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
9
9
|
import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
|
|
10
|
-
import { sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
|
|
10
|
+
import { installTreeTornSentryTagsFor, sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
|
|
11
11
|
/**
|
|
12
12
|
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
13
13
|
* reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
|
|
@@ -84,6 +84,10 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
84
84
|
if (fingerprint)
|
|
85
85
|
event.fingerprint = fingerprint;
|
|
86
86
|
}
|
|
87
|
+
const installTreeTags = installTreeTornSentryTagsFor(hint?.originalException);
|
|
88
|
+
if (installTreeTags) {
|
|
89
|
+
event.tags = { ...event.tags, ...installTreeTags };
|
|
90
|
+
}
|
|
87
91
|
return beforeSend(event, hint);
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
@@ -23,6 +23,8 @@ import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type
|
|
|
23
23
|
* shadow each other.
|
|
24
24
|
*/
|
|
25
25
|
export declare const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
|
|
26
|
+
/** Carries the writer-state observed by the parent into the guarded re-exec. */
|
|
27
|
+
export declare const INSTALL_TREE_RECOVERY_TREE_STATE_ENV = "HQ_INSTALL_TREE_RECOVERY_TREE_STATE";
|
|
26
28
|
/** Operator override for the settle wait's deadline. */
|
|
27
29
|
export declare const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
|
|
28
30
|
/** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
|
|
@@ -32,7 +34,7 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
|
|
|
32
34
|
* actually happens. It tells a human why the command paused and is the e2e's
|
|
33
35
|
* synchronization point; it is never a remedy and never sets an error exit code.
|
|
34
36
|
*/
|
|
35
|
-
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is
|
|
37
|
+
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: checking whether the hq-cli install tree is still changing before retrying\u2026";
|
|
36
38
|
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
37
39
|
export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
40
|
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
@@ -27,6 +27,8 @@ import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, In
|
|
|
27
27
|
* shadow each other.
|
|
28
28
|
*/
|
|
29
29
|
export const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
|
|
30
|
+
/** Carries the writer-state observed by the parent into the guarded re-exec. */
|
|
31
|
+
export const INSTALL_TREE_RECOVERY_TREE_STATE_ENV = "HQ_INSTALL_TREE_RECOVERY_TREE_STATE";
|
|
30
32
|
/** Operator override for the settle wait's deadline. */
|
|
31
33
|
export const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
|
|
32
34
|
/** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
|
|
@@ -36,7 +38,7 @@ export const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90_000;
|
|
|
36
38
|
* actually happens. It tells a human why the command paused and is the e2e's
|
|
37
39
|
* synchronization point; it is never a remedy and never sets an error exit code.
|
|
38
40
|
*/
|
|
39
|
-
export const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is
|
|
41
|
+
export const INSTALL_TREE_WAIT_NOTICE = "hq: checking whether the hq-cli install tree is still changing before retrying…";
|
|
40
42
|
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
41
43
|
export function resolveSettleTimeoutMs(env) {
|
|
42
44
|
const raw = env[INSTALL_SETTLE_TIMEOUT_ENV];
|
|
@@ -121,10 +123,12 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
121
123
|
classified,
|
|
122
124
|
packageRoot,
|
|
123
125
|
outcome: "guarded",
|
|
126
|
+
treeState: env[INSTALL_TREE_RECOVERY_TREE_STATE_ENV] === "stale" ? "stale" : "in_flight",
|
|
124
127
|
attempt: 2,
|
|
125
128
|
waitedMs: 0,
|
|
126
129
|
sawLock: false,
|
|
127
130
|
sawRetired: false,
|
|
131
|
+
sawTreeChange: false,
|
|
128
132
|
});
|
|
129
133
|
}
|
|
130
134
|
stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
|
|
@@ -137,6 +141,7 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
137
141
|
onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
|
|
138
142
|
});
|
|
139
143
|
if (!result.settled) {
|
|
144
|
+
const treeState = result.staleTree === true ? "stale" : "in_flight";
|
|
140
145
|
throw new InstallTreeTornError({
|
|
141
146
|
cause: err,
|
|
142
147
|
classified,
|
|
@@ -145,12 +150,15 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
145
150
|
// writer held the shared lock past its own ceiling — report 'lock-held'
|
|
146
151
|
// (its remedy tells the user to let the update finish, never to reinstall
|
|
147
152
|
// over it). Otherwise the tree genuinely never settled with no writer
|
|
148
|
-
// visible
|
|
149
|
-
|
|
153
|
+
// visible. The settle probe only uses `stale_tree` when it could observe
|
|
154
|
+
// an unchanged tree with no active writer or retired sibling.
|
|
155
|
+
outcome: result.lockHeldAtEnd ? "lock-held" : result.staleTree ? "stale_tree" : "unsettled",
|
|
156
|
+
treeState,
|
|
150
157
|
attempt: 1,
|
|
151
158
|
waitedMs: result.waitedMs,
|
|
152
159
|
sawLock: result.sawLock,
|
|
153
160
|
sawRetired: result.sawRetired,
|
|
161
|
+
sawTreeChange: result.sawTreeChange,
|
|
154
162
|
lockHeldAtEnd: result.lockHeldAtEnd,
|
|
155
163
|
lockedMs: result.lockedMs,
|
|
156
164
|
lockTool: result.lockTool,
|
|
@@ -158,17 +166,30 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
158
166
|
}
|
|
159
167
|
const spawn = deps.spawn ?? spawnSync;
|
|
160
168
|
const execArgv = deps.execArgv ?? process.execArgv;
|
|
161
|
-
const
|
|
169
|
+
const treeState = result.sawLock || result.sawRetired || result.sawTreeChange
|
|
170
|
+
? "in_flight"
|
|
171
|
+
: "stale";
|
|
172
|
+
const child = spawn(process.execPath, [...execArgv, entryPath, ...argv.slice(2)], {
|
|
173
|
+
stdio: "inherit",
|
|
174
|
+
env: {
|
|
175
|
+
...env,
|
|
176
|
+
[INSTALL_TREE_RECOVERY_GUARD_ENV]: "1",
|
|
177
|
+
[INSTALL_TREE_RECOVERY_TREE_STATE_ENV]: treeState,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
162
180
|
if (child.error) {
|
|
163
181
|
throw new InstallTreeTornError({
|
|
164
182
|
cause: err,
|
|
165
183
|
classified,
|
|
166
184
|
packageRoot,
|
|
167
185
|
outcome: "reexec-failed",
|
|
186
|
+
treeState,
|
|
168
187
|
attempt: 1,
|
|
169
188
|
waitedMs: result.waitedMs,
|
|
170
189
|
sawLock: result.sawLock,
|
|
171
190
|
sawRetired: result.sawRetired,
|
|
191
|
+
sawTreeChange: result.sawTreeChange,
|
|
192
|
+
lockTool: result.lockTool,
|
|
172
193
|
});
|
|
173
194
|
}
|
|
174
195
|
// A signal-killed child has status null; surface it as a failure exit rather
|
|
@@ -179,6 +179,8 @@ export interface InstallTreeFs {
|
|
|
179
179
|
existsSync(p: string): boolean;
|
|
180
180
|
statSync(p: string): {
|
|
181
181
|
isDirectory(): boolean;
|
|
182
|
+
size?: number;
|
|
183
|
+
mtimeMs?: number;
|
|
182
184
|
};
|
|
183
185
|
readFileSync(p: string, encoding: "utf-8"): string;
|
|
184
186
|
readdirSync(p: string): string[];
|
|
@@ -225,6 +227,8 @@ export interface WaitForInstallTreeSettledArgs {
|
|
|
225
227
|
isPidAlive?: (pid: number) => boolean;
|
|
226
228
|
pollMs?: number;
|
|
227
229
|
quietMs?: number;
|
|
230
|
+
/** Short observation window for an unchanged, lock-free broken tree. */
|
|
231
|
+
staleProbeMs?: number;
|
|
228
232
|
deadlineMs: number;
|
|
229
233
|
/**
|
|
230
234
|
* Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
|
|
@@ -256,7 +260,12 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
256
260
|
lockedMs: number;
|
|
257
261
|
/** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
|
|
258
262
|
lockTool: string;
|
|
263
|
+
/** Whether a relevant install-tree path changed during this wait. */
|
|
264
|
+
sawTreeChange?: boolean;
|
|
265
|
+
/** True when the lock-free tree stayed unchanged for `staleProbeMs`. */
|
|
266
|
+
staleTree?: boolean;
|
|
259
267
|
}
|
|
268
|
+
export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
|
|
260
269
|
/**
|
|
261
270
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
262
271
|
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
@@ -265,14 +274,14 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
265
274
|
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
266
275
|
* its condition "not ready" rather than throwing.
|
|
267
276
|
*
|
|
268
|
-
* The wait is LOCK-AWARE and bounded
|
|
269
|
-
*
|
|
270
|
-
*
|
|
277
|
+
* The wait is LOCK-AWARE and bounded, so a live cooperating writer (the desktop
|
|
278
|
+
* app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
|
|
279
|
+
* reinstall look stale:
|
|
271
280
|
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
272
|
-
* by `deadlineMs`.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
281
|
+
* by `deadlineMs`. If the target is still missing, no lock or retired sibling
|
|
282
|
+
* is present, and the observed tree stays unchanged for `staleProbeMs`, the
|
|
283
|
+
* wait returns `staleTree` early. Any observed tree change keeps the ordinary
|
|
284
|
+
* settle deadline in force. `deadlineMs === 0` evaluates readiness once.
|
|
276
285
|
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
277
286
|
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
278
287
|
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
@@ -285,7 +294,8 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
285
294
|
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
286
295
|
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
287
296
|
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
288
|
-
* lockWaitMs`),
|
|
297
|
+
* lockWaitMs`), and the unchanged-tree path normally returns after the much
|
|
298
|
+
* shorter `staleProbeMs` observation window.
|
|
289
299
|
*/
|
|
290
300
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
291
301
|
export interface InstallRewriteSignalArgs {
|
|
@@ -310,7 +320,8 @@ export interface InstallRewriteSignalArgs {
|
|
|
310
320
|
*/
|
|
311
321
|
export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
|
|
312
322
|
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
313
|
-
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
|
|
323
|
+
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree";
|
|
324
|
+
export type InstallTreeState = "in_flight" | "stale";
|
|
314
325
|
export type InstallTreeTornDiagnostics = {
|
|
315
326
|
dialect: ModuleErrorDialect;
|
|
316
327
|
code: ModuleNotFoundCode;
|
|
@@ -318,10 +329,12 @@ export type InstallTreeTornDiagnostics = {
|
|
|
318
329
|
importer: string;
|
|
319
330
|
packageRoot: string;
|
|
320
331
|
outcome: InstallTreeTornOutcome;
|
|
332
|
+
treeState: InstallTreeState;
|
|
321
333
|
attempt: 1 | 2;
|
|
322
334
|
waitedMs: number;
|
|
323
335
|
sawLock: boolean;
|
|
324
336
|
sawRetired: boolean;
|
|
337
|
+
sawTreeChange: boolean;
|
|
325
338
|
/** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
|
|
326
339
|
lockHeldAtEnd: boolean;
|
|
327
340
|
/** Cumulative wall time the wait spent under a fresh foreign lock. */
|
|
@@ -342,6 +355,8 @@ export interface InstallTreeTornInit {
|
|
|
342
355
|
waitedMs: number;
|
|
343
356
|
sawLock: boolean;
|
|
344
357
|
sawRetired: boolean;
|
|
358
|
+
sawTreeChange?: boolean;
|
|
359
|
+
treeState?: InstallTreeState;
|
|
345
360
|
/** Whether a fresh foreign lock was held when the wait gave up (default false). */
|
|
346
361
|
lockHeldAtEnd?: boolean;
|
|
347
362
|
/** Cumulative time under a fresh foreign lock (default 0). */
|
|
@@ -362,11 +377,13 @@ export declare class InstallTreeTornError extends Error {
|
|
|
362
377
|
}
|
|
363
378
|
export declare function isInstallTreeTornError(err: unknown): err is InstallTreeTornError;
|
|
364
379
|
/** The exact Sentry `contexts` payload for a captured torn-install failure. */
|
|
365
|
-
export declare function installTreeTornCaptureContext(err: InstallTreeTornError): {
|
|
380
|
+
export declare function installTreeTornCaptureContext(err: InstallTreeTornError, homeDirectory?: string): {
|
|
366
381
|
contexts: {
|
|
367
382
|
install_tree_torn: InstallTreeTornDiagnostics;
|
|
368
383
|
};
|
|
369
384
|
};
|
|
385
|
+
/** Replace a home-directory prefix without matching similarly-named sibling folders. */
|
|
386
|
+
export declare function scrubHomeDirectoryPrefix(value: string, homeDirectory?: string): string;
|
|
370
387
|
/**
|
|
371
388
|
* The fixed, input-free operator remedy. It names the likely writers (so a
|
|
372
389
|
* human knows this was not their command's fault) and the reinstall commands,
|
|
@@ -374,6 +391,8 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
|
|
|
374
391
|
* appended by {@link installTreeTornStderrLine}.
|
|
375
392
|
*/
|
|
376
393
|
export declare const INSTALL_TREE_TORN_REMEDY: string;
|
|
394
|
+
/** Fixed repair guidance for an unchanged tree with no active updater signal. */
|
|
395
|
+
export declare const INSTALL_TREE_STALE_REMEDY: string;
|
|
377
396
|
/**
|
|
378
397
|
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
379
398
|
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* are only READ (their exported symbols), never modified.
|
|
31
31
|
*/
|
|
32
32
|
import * as nodeFs from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
33
34
|
import * as path from "node:path";
|
|
34
35
|
import { fileURLToPath } from "node:url";
|
|
35
36
|
import { CLI_NAME } from "../cli-version.js";
|
|
@@ -637,6 +638,125 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
637
638
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
|
|
638
639
|
return manifest.name === CLI_NAME;
|
|
639
640
|
}
|
|
641
|
+
function isWithinPath(candidate, root) {
|
|
642
|
+
const relative = path.relative(root, candidate);
|
|
643
|
+
return relative === "" ||
|
|
644
|
+
(!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
645
|
+
}
|
|
646
|
+
/** Paths whose presence, metadata, or immediate entries show activity for this import. */
|
|
647
|
+
function installTreeActivityPaths(target, packageRoot) {
|
|
648
|
+
const root = packageRoot ? path.resolve(packageRoot) : null;
|
|
649
|
+
const paths = new Set();
|
|
650
|
+
const directories = new Set();
|
|
651
|
+
const addPath = (candidate) => {
|
|
652
|
+
const resolved = path.resolve(candidate);
|
|
653
|
+
paths.add(resolved);
|
|
654
|
+
directories.add(path.dirname(resolved));
|
|
655
|
+
if (!root || !isWithinPath(resolved, root))
|
|
656
|
+
return;
|
|
657
|
+
let current = path.dirname(resolved);
|
|
658
|
+
for (;;) {
|
|
659
|
+
paths.add(current);
|
|
660
|
+
directories.add(current);
|
|
661
|
+
if (current === root)
|
|
662
|
+
break;
|
|
663
|
+
const parent = path.dirname(current);
|
|
664
|
+
if (parent === current || !isWithinPath(parent, root))
|
|
665
|
+
break;
|
|
666
|
+
current = parent;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
if (root) {
|
|
670
|
+
for (const candidate of [
|
|
671
|
+
root,
|
|
672
|
+
path.join(root, "package.json"),
|
|
673
|
+
path.join(root, "dist"),
|
|
674
|
+
path.join(root, "node_modules"),
|
|
675
|
+
]) {
|
|
676
|
+
addPath(candidate);
|
|
677
|
+
directories.add(candidate);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (target.kind === "path" || target.kind === "exists") {
|
|
681
|
+
addPath(target.path);
|
|
682
|
+
}
|
|
683
|
+
else if (target.kind === "relative") {
|
|
684
|
+
const base = path.resolve(target.from, target.specifier);
|
|
685
|
+
const candidates = [
|
|
686
|
+
base,
|
|
687
|
+
...CJS_FILE_EXTENSIONS.map((ext) => base + ext),
|
|
688
|
+
...CJS_FILE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)),
|
|
689
|
+
];
|
|
690
|
+
for (const candidate of candidates)
|
|
691
|
+
addPath(candidate);
|
|
692
|
+
}
|
|
693
|
+
else if (target.kind === "package") {
|
|
694
|
+
let from = path.resolve(target.from);
|
|
695
|
+
let ancestorCount = 0;
|
|
696
|
+
for (;;) {
|
|
697
|
+
if (root && !isWithinPath(from, root))
|
|
698
|
+
break;
|
|
699
|
+
addPath(path.join(from, "node_modules", target.name));
|
|
700
|
+
if (from === root)
|
|
701
|
+
break;
|
|
702
|
+
ancestorCount += 1;
|
|
703
|
+
if (!root && ancestorCount >= 12)
|
|
704
|
+
break;
|
|
705
|
+
const parent = path.dirname(from);
|
|
706
|
+
if (parent === from)
|
|
707
|
+
break;
|
|
708
|
+
from = parent;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
paths: [...paths].sort(),
|
|
713
|
+
directories: [...directories].sort(),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/** A local-only snapshot of the missing target and directories an installer may change. */
|
|
717
|
+
function installTreeActivitySignature(target, packageRoot, fs) {
|
|
718
|
+
const activity = installTreeActivityPaths(target, packageRoot);
|
|
719
|
+
const snapshots = [];
|
|
720
|
+
let observed = true;
|
|
721
|
+
const errorCode = (err) => {
|
|
722
|
+
if (err !== null && typeof err === "object" && "code" in err && typeof err.code === "string") {
|
|
723
|
+
return err.code;
|
|
724
|
+
}
|
|
725
|
+
return "error";
|
|
726
|
+
};
|
|
727
|
+
for (const candidate of activity.paths) {
|
|
728
|
+
try {
|
|
729
|
+
if (!fs.existsSync(candidate)) {
|
|
730
|
+
snapshots.push([candidate, "missing"]);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const stat = fs.statSync(candidate);
|
|
734
|
+
snapshots.push([
|
|
735
|
+
candidate,
|
|
736
|
+
stat.isDirectory() ? "directory" : "file",
|
|
737
|
+
stat.size ?? null,
|
|
738
|
+
stat.mtimeMs ?? null,
|
|
739
|
+
]);
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
observed = false;
|
|
743
|
+
snapshots.push([candidate, `unreadable:${errorCode(err)}`]);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (const directory of activity.directories) {
|
|
747
|
+
try {
|
|
748
|
+
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory())
|
|
749
|
+
continue;
|
|
750
|
+
snapshots.push([directory, "entries", fs.readdirSync(directory).sort()]);
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
observed = false;
|
|
754
|
+
snapshots.push([directory, `entries-unreadable:${errorCode(err)}`]);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return { value: JSON.stringify(snapshots), observed };
|
|
758
|
+
}
|
|
759
|
+
export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
|
|
640
760
|
/**
|
|
641
761
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
642
762
|
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
@@ -645,14 +765,14 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
645
765
|
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
646
766
|
* its condition "not ready" rather than throwing.
|
|
647
767
|
*
|
|
648
|
-
* The wait is LOCK-AWARE and bounded
|
|
649
|
-
*
|
|
650
|
-
*
|
|
768
|
+
* The wait is LOCK-AWARE and bounded, so a live cooperating writer (the desktop
|
|
769
|
+
* app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
|
|
770
|
+
* reinstall look stale:
|
|
651
771
|
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
652
|
-
* by `deadlineMs`.
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
772
|
+
* by `deadlineMs`. If the target is still missing, no lock or retired sibling
|
|
773
|
+
* is present, and the observed tree stays unchanged for `staleProbeMs`, the
|
|
774
|
+
* wait returns `staleTree` early. Any observed tree change keeps the ordinary
|
|
775
|
+
* settle deadline in force. `deadlineMs === 0` evaluates readiness once.
|
|
656
776
|
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
657
777
|
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
658
778
|
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
@@ -665,7 +785,8 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
665
785
|
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
666
786
|
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
667
787
|
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
668
|
-
* lockWaitMs`),
|
|
788
|
+
* lockWaitMs`), and the unchanged-tree path normally returns after the much
|
|
789
|
+
* shorter `staleProbeMs` observation window.
|
|
669
790
|
*/
|
|
670
791
|
export async function waitForInstallTreeSettled(args) {
|
|
671
792
|
const now = args.now ?? Date.now;
|
|
@@ -676,7 +797,11 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
676
797
|
const quietMs = args.quietMs ?? 1500;
|
|
677
798
|
const deadlineMs = args.deadlineMs;
|
|
678
799
|
const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
|
|
800
|
+
const staleProbeMs = Math.max(0, args.staleProbeMs ?? DEFAULT_INSTALL_TREE_STALE_PROBE_MS);
|
|
679
801
|
const start = now();
|
|
802
|
+
let lastActivitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
|
|
803
|
+
let staleObservationStartedAt = lastActivitySnapshot.observed ? start : null;
|
|
804
|
+
let sawTreeChange = false;
|
|
680
805
|
let readySince = null;
|
|
681
806
|
let sawLock = false;
|
|
682
807
|
let sawRetired = false;
|
|
@@ -700,6 +825,32 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
700
825
|
sawLock = true;
|
|
701
826
|
lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
|
|
702
827
|
}
|
|
828
|
+
if (!sawTreeChange) {
|
|
829
|
+
const activitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
|
|
830
|
+
if (!activitySnapshot.observed) {
|
|
831
|
+
staleObservationStartedAt = null;
|
|
832
|
+
}
|
|
833
|
+
else if (!lastActivitySnapshot.observed) {
|
|
834
|
+
staleObservationStartedAt = t;
|
|
835
|
+
}
|
|
836
|
+
else if (activitySnapshot.value !== lastActivitySnapshot.value) {
|
|
837
|
+
sawTreeChange = true;
|
|
838
|
+
staleObservationStartedAt = null;
|
|
839
|
+
}
|
|
840
|
+
lastActivitySnapshot = activitySnapshot;
|
|
841
|
+
}
|
|
842
|
+
let retiredPresent = false;
|
|
843
|
+
let retiredCheckFailed = false;
|
|
844
|
+
if (args.packageRoot) {
|
|
845
|
+
try {
|
|
846
|
+
retiredPresent = retiredSiblingPresent(args.packageRoot, fs);
|
|
847
|
+
if (retiredPresent)
|
|
848
|
+
sawRetired = true;
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
retiredCheckFailed = true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
703
854
|
let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
|
|
704
855
|
if (ready && args.packageRoot) {
|
|
705
856
|
try {
|
|
@@ -709,17 +860,8 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
709
860
|
catch {
|
|
710
861
|
ready = false;
|
|
711
862
|
}
|
|
712
|
-
if (ready)
|
|
713
|
-
|
|
714
|
-
if (retiredSiblingPresent(args.packageRoot, fs)) {
|
|
715
|
-
sawRetired = true;
|
|
716
|
-
ready = false;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
catch {
|
|
720
|
-
ready = false;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
863
|
+
if (ready && (retiredPresent || retiredCheckFailed))
|
|
864
|
+
ready = false;
|
|
723
865
|
}
|
|
724
866
|
if (ready) {
|
|
725
867
|
if (readySince === null)
|
|
@@ -731,7 +873,38 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
731
873
|
const waitedMs = t - start;
|
|
732
874
|
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
733
875
|
// Settling requires no foreign lock, so it is never held at the end here.
|
|
734
|
-
return {
|
|
876
|
+
return {
|
|
877
|
+
settled: true,
|
|
878
|
+
waitedMs,
|
|
879
|
+
sawLock,
|
|
880
|
+
sawRetired,
|
|
881
|
+
lockHeldAtEnd: false,
|
|
882
|
+
lockedMs,
|
|
883
|
+
lockTool,
|
|
884
|
+
sawTreeChange,
|
|
885
|
+
staleTree: false,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (!ready &&
|
|
889
|
+
!foreignLockHeld &&
|
|
890
|
+
!retiredPresent &&
|
|
891
|
+
!retiredCheckFailed &&
|
|
892
|
+
!sawLock &&
|
|
893
|
+
!sawRetired &&
|
|
894
|
+
!sawTreeChange &&
|
|
895
|
+
staleObservationStartedAt !== null &&
|
|
896
|
+
t - staleObservationStartedAt >= staleProbeMs) {
|
|
897
|
+
return {
|
|
898
|
+
settled: false,
|
|
899
|
+
waitedMs,
|
|
900
|
+
sawLock,
|
|
901
|
+
sawRetired,
|
|
902
|
+
lockHeldAtEnd: false,
|
|
903
|
+
lockedMs,
|
|
904
|
+
lockTool,
|
|
905
|
+
sawTreeChange,
|
|
906
|
+
staleTree: true,
|
|
907
|
+
};
|
|
735
908
|
}
|
|
736
909
|
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
737
910
|
// OR a writer is STILL holding the lock and has held it past its own staleness
|
|
@@ -742,7 +915,17 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
742
915
|
// `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
|
|
743
916
|
// matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
|
|
744
917
|
if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
|
|
745
|
-
return {
|
|
918
|
+
return {
|
|
919
|
+
settled: false,
|
|
920
|
+
waitedMs,
|
|
921
|
+
sawLock,
|
|
922
|
+
sawRetired,
|
|
923
|
+
lockHeldAtEnd: foreignLockHeld,
|
|
924
|
+
lockedMs,
|
|
925
|
+
lockTool,
|
|
926
|
+
sawTreeChange,
|
|
927
|
+
staleTree: false,
|
|
928
|
+
};
|
|
746
929
|
}
|
|
747
930
|
// The first poll at which the wait has reached `deadlineMs` while a lock is
|
|
748
931
|
// still held is exactly where the pre-lock-aware wait gave up — announce the
|
|
@@ -808,7 +991,7 @@ export function installRewriteInProgress(args) {
|
|
|
808
991
|
}
|
|
809
992
|
}
|
|
810
993
|
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
811
|
-
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree
|
|
994
|
+
const INSTALL_TREE_TORN_MESSAGE = "hq-cli could not load a module from its install tree and automatic recovery did not complete";
|
|
812
995
|
/**
|
|
813
996
|
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
814
997
|
* diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
|
|
@@ -822,6 +1005,8 @@ export class InstallTreeTornError extends Error {
|
|
|
822
1005
|
super(INSTALL_TREE_TORN_MESSAGE);
|
|
823
1006
|
this.name = "InstallTreeTornError";
|
|
824
1007
|
this.cause = init.cause;
|
|
1008
|
+
const sawTreeChange = init.sawTreeChange ?? false;
|
|
1009
|
+
const treeState = init.treeState ?? (init.outcome === "stale_tree" ? "stale" : "in_flight");
|
|
825
1010
|
this.diagnostics = {
|
|
826
1011
|
dialect: init.classified.dialect,
|
|
827
1012
|
code: init.classified.code,
|
|
@@ -829,10 +1014,12 @@ export class InstallTreeTornError extends Error {
|
|
|
829
1014
|
importer: boundedDiagnosticValue(init.classified.importer, IMPORTER_BYTES),
|
|
830
1015
|
packageRoot: boundedDiagnosticValue(init.packageRoot ?? "", PACKAGE_ROOT_BYTES),
|
|
831
1016
|
outcome: init.outcome,
|
|
1017
|
+
treeState,
|
|
832
1018
|
attempt: init.attempt,
|
|
833
1019
|
waitedMs: init.waitedMs,
|
|
834
1020
|
sawLock: init.sawLock,
|
|
835
1021
|
sawRetired: init.sawRetired,
|
|
1022
|
+
sawTreeChange,
|
|
836
1023
|
lockHeldAtEnd: init.lockHeldAtEnd ?? false,
|
|
837
1024
|
lockedMs: init.lockedMs ?? 0,
|
|
838
1025
|
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
@@ -846,8 +1033,45 @@ export function isInstallTreeTornError(err) {
|
|
|
846
1033
|
return err instanceof InstallTreeTornError;
|
|
847
1034
|
}
|
|
848
1035
|
/** The exact Sentry `contexts` payload for a captured torn-install failure. */
|
|
849
|
-
export function installTreeTornCaptureContext(err) {
|
|
850
|
-
|
|
1036
|
+
export function installTreeTornCaptureContext(err, homeDirectory = homedir()) {
|
|
1037
|
+
const diagnostics = err.diagnostics;
|
|
1038
|
+
return {
|
|
1039
|
+
contexts: {
|
|
1040
|
+
install_tree_torn: {
|
|
1041
|
+
...diagnostics,
|
|
1042
|
+
specifier: scrubHomeDirectoryPrefix(diagnostics.specifier, homeDirectory),
|
|
1043
|
+
importer: scrubHomeDirectoryPrefix(diagnostics.importer, homeDirectory),
|
|
1044
|
+
packageRoot: scrubHomeDirectoryPrefix(diagnostics.packageRoot, homeDirectory),
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function looksLikeWindowsPath(value) {
|
|
1050
|
+
return /^[a-zA-Z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
1051
|
+
}
|
|
1052
|
+
/** Replace a home-directory prefix without matching similarly-named sibling folders. */
|
|
1053
|
+
export function scrubHomeDirectoryPrefix(value, homeDirectory = homedir()) {
|
|
1054
|
+
if (!value || !homeDirectory)
|
|
1055
|
+
return value;
|
|
1056
|
+
if (looksLikeWindowsPath(value) || looksLikeWindowsPath(homeDirectory)) {
|
|
1057
|
+
const home = path.win32.normalize(homeDirectory).replace(/[\\/]+$/, "");
|
|
1058
|
+
const normalized = path.win32.normalize(value);
|
|
1059
|
+
const foldedHome = home.toLowerCase();
|
|
1060
|
+
const foldedValue = normalized.toLowerCase();
|
|
1061
|
+
if (foldedValue === foldedHome)
|
|
1062
|
+
return "~";
|
|
1063
|
+
const prefix = `${foldedHome}\\`;
|
|
1064
|
+
if (foldedValue.startsWith(prefix))
|
|
1065
|
+
return `~\\${normalized.slice(home.length + 1)}`;
|
|
1066
|
+
return value;
|
|
1067
|
+
}
|
|
1068
|
+
const home = path.posix.normalize(homeDirectory).replace(/\/+$/, "");
|
|
1069
|
+
const normalized = path.posix.normalize(value);
|
|
1070
|
+
if (normalized === home)
|
|
1071
|
+
return "~";
|
|
1072
|
+
if (normalized.startsWith(`${home}/`))
|
|
1073
|
+
return `~/${normalized.slice(home.length + 1)}`;
|
|
1074
|
+
return value;
|
|
851
1075
|
}
|
|
852
1076
|
/**
|
|
853
1077
|
* The fixed, input-free operator remedy. It names the likely writers (so a
|
|
@@ -855,10 +1079,15 @@ export function installTreeTornCaptureContext(err) {
|
|
|
855
1079
|
* and interpolates NOTHING — the only per-event value is the bounded specifier
|
|
856
1080
|
* appended by {@link installTreeTornStderrLine}.
|
|
857
1081
|
*/
|
|
858
|
-
export const INSTALL_TREE_TORN_REMEDY = "
|
|
859
|
-
"
|
|
860
|
-
"
|
|
861
|
-
"
|
|
1082
|
+
export const INSTALL_TREE_TORN_REMEDY = "hq-cli could not finish automatic install-tree recovery. Re-run your command; " +
|
|
1083
|
+
"if it still fails, reinstall with `npm install -g @indigoai-us/hq-cli@latest` " +
|
|
1084
|
+
"(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
|
|
1085
|
+
"when that manager owns your install).";
|
|
1086
|
+
/** Fixed repair guidance for an unchanged tree with no active updater signal. */
|
|
1087
|
+
export const INSTALL_TREE_STALE_REMEDY = "hq-cli's install tree is incomplete and no active updater was detected. " +
|
|
1088
|
+
"Reinstall with `npm install -g @indigoai-us/hq-cli@latest` " +
|
|
1089
|
+
"(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
|
|
1090
|
+
"when that manager owns your install), then run the command again.";
|
|
862
1091
|
/**
|
|
863
1092
|
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
864
1093
|
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
@@ -877,7 +1106,11 @@ export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop ap
|
|
|
877
1106
|
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
878
1107
|
*/
|
|
879
1108
|
export function installTreeTornStderrLine(err) {
|
|
880
|
-
const remedy = err.diagnostics.outcome === "lock-held"
|
|
1109
|
+
const remedy = err.diagnostics.outcome === "lock-held"
|
|
1110
|
+
? INSTALL_TREE_LOCK_HELD_REMEDY
|
|
1111
|
+
: err.diagnostics.outcome === "stale_tree"
|
|
1112
|
+
? INSTALL_TREE_STALE_REMEDY
|
|
1113
|
+
: INSTALL_TREE_TORN_REMEDY;
|
|
881
1114
|
const specifier = err.diagnostics.specifier;
|
|
882
1115
|
return specifier ? `${remedy} (missing: ${specifier})` : remedy;
|
|
883
1116
|
}
|
|
@@ -9,6 +9,12 @@ export interface SentryMcpCollisionContext {
|
|
|
9
9
|
}
|
|
10
10
|
/** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
|
|
11
11
|
export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCollisionContext | null;
|
|
12
|
+
/**
|
|
13
|
+
* Bounded, aggregatable tags for an unrecovered install-tree failure. Phase,
|
|
14
|
+
* outcome, state, and OS are closed values. Paths and raw lock-tool strings stay
|
|
15
|
+
* in context; only a known updater source becomes a tag.
|
|
16
|
+
*/
|
|
17
|
+
export declare function installTreeTornSentryTagsFor(err: unknown): Record<string, string> | null;
|
|
12
18
|
/**
|
|
13
19
|
* Return a bounded `event.fingerprint` array for `err`, or `null` when `err`
|
|
14
20
|
* carries no discriminator from the closed allowlist (in which case the caller
|
|
@@ -23,7 +29,7 @@ export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCol
|
|
|
23
29
|
* The InstallTreeTornError branch is the deliberate exception: it returns a
|
|
24
30
|
* fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
|
|
25
31
|
* DEFAULT_GROUPING lead, because its chained loader cause makes the default
|
|
26
|
-
* component unbounded
|
|
32
|
+
* component unbounded.
|
|
27
33
|
*
|
|
28
34
|
* `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
|
|
29
35
|
* code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
// in three different groups (hashes a4289a5e530e166ebdedea9d34dcad0a,
|
|
39
39
|
// b563c1b891bd975bdc605efc86034b44, b4ee254473ea249758617de1a98848fa —
|
|
40
40
|
// HQ-CLI-21/22/23). So that branch returns a FULLY EXPLICIT key with no
|
|
41
|
-
// default lead: ["InstallTreeTornError", "outcome:<token>"].
|
|
41
|
+
// default lead: ["InstallTreeTornError", "outcome:<token>"]. This PR keeps
|
|
42
|
+
// the existing explicit key so the new stale-tree outcome extends the current
|
|
43
|
+
// groups without splitting them.
|
|
42
44
|
//
|
|
43
45
|
// Genuinely different failure classes therefore form distinct, PREDICTABLE
|
|
44
46
|
// groups, and everything with no bounded discriminator is left to default
|
|
@@ -156,17 +158,96 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
156
158
|
"QmdWorkdirMissingError",
|
|
157
159
|
// A torn-install failure the bounded settle-wait + single re-exec could not
|
|
158
160
|
// recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
|
|
159
|
-
// by the
|
|
161
|
+
// by the normalized recovery result, so the family groups into two stable buckets.
|
|
160
162
|
"InstallTreeTornError",
|
|
161
163
|
]);
|
|
162
164
|
/**
|
|
163
|
-
* The
|
|
164
|
-
*
|
|
165
|
-
* the carrier's message is fixed, so this outcome IS the only discriminator.
|
|
165
|
+
* The closed loader dialects accepted for the install_tree_phase tag. Anything
|
|
166
|
+
* outside this set collapses to unknown, so tags stay safe to aggregate.
|
|
166
167
|
*/
|
|
168
|
+
const INSTALL_TREE_PHASES = new Set([
|
|
169
|
+
"cjs-enoent",
|
|
170
|
+
"cjs-package",
|
|
171
|
+
"cjs-path",
|
|
172
|
+
"cjs-relative",
|
|
173
|
+
"esm-enoent",
|
|
174
|
+
"esm-package",
|
|
175
|
+
"esm-path",
|
|
176
|
+
"eval-throw",
|
|
177
|
+
"resolve-enoent",
|
|
178
|
+
"unknown",
|
|
179
|
+
]);
|
|
180
|
+
const KNOWN_INSTALL_TREE_UPDATERS = new Map([
|
|
181
|
+
["hq-cli-self-update", "cli-self-update"],
|
|
182
|
+
["hq-cli-update", "box-timer"],
|
|
183
|
+
["hq-cli-version-gate", "cli-version-gate"],
|
|
184
|
+
["hq-desktop-app-cli-update", "desktop-app"],
|
|
185
|
+
]);
|
|
167
186
|
const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
|
|
168
|
-
"guarded",
|
|
187
|
+
"guarded",
|
|
188
|
+
"unsettled",
|
|
189
|
+
"reexec-failed",
|
|
190
|
+
"lock-held",
|
|
191
|
+
"stale_tree",
|
|
169
192
|
]);
|
|
193
|
+
function installTreeRecoveryOutcomeTag(value) {
|
|
194
|
+
return typeof value === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(value)
|
|
195
|
+
? value
|
|
196
|
+
: null;
|
|
197
|
+
}
|
|
198
|
+
function installTreeStateTag(value) {
|
|
199
|
+
return value === "stale" ? "stale" : "in_flight";
|
|
200
|
+
}
|
|
201
|
+
function installTreePhaseTag(value) {
|
|
202
|
+
if (typeof value !== "string" || !INSTALL_TREE_PHASES.has(value))
|
|
203
|
+
return "unknown";
|
|
204
|
+
if (value === "esm-enoent" || value === "cjs-enoent")
|
|
205
|
+
return "loader_read";
|
|
206
|
+
if (value === "eval-throw")
|
|
207
|
+
return "module_evaluation";
|
|
208
|
+
if (value === "esm-package" || value === "cjs-package")
|
|
209
|
+
return "package_resolution";
|
|
210
|
+
if (value === "unknown")
|
|
211
|
+
return "unknown";
|
|
212
|
+
return "path_resolution";
|
|
213
|
+
}
|
|
214
|
+
function installTreeOsTag(platform) {
|
|
215
|
+
if (platform === "win32")
|
|
216
|
+
return "windows";
|
|
217
|
+
if (platform === "darwin")
|
|
218
|
+
return "macos";
|
|
219
|
+
if (platform === "linux")
|
|
220
|
+
return "linux";
|
|
221
|
+
return "other";
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Bounded, aggregatable tags for an unrecovered install-tree failure. Phase,
|
|
225
|
+
* outcome, state, and OS are closed values. Paths and raw lock-tool strings stay
|
|
226
|
+
* in context; only a known updater source becomes a tag.
|
|
227
|
+
*/
|
|
228
|
+
export function installTreeTornSentryTagsFor(err) {
|
|
229
|
+
if (err === null || typeof err !== "object")
|
|
230
|
+
return null;
|
|
231
|
+
const record = err;
|
|
232
|
+
if (record.name !== "InstallTreeTornError")
|
|
233
|
+
return null;
|
|
234
|
+
const diagnostics = record.diagnostics !== null && typeof record.diagnostics === "object"
|
|
235
|
+
? record.diagnostics
|
|
236
|
+
: {};
|
|
237
|
+
const recoveryOutcome = installTreeRecoveryOutcomeTag(diagnostics.outcome);
|
|
238
|
+
const tags = {
|
|
239
|
+
install_tree_phase: installTreePhaseTag(diagnostics.dialect),
|
|
240
|
+
tree_state: installTreeStateTag(diagnostics.treeState),
|
|
241
|
+
os: installTreeOsTag(process.platform),
|
|
242
|
+
};
|
|
243
|
+
if (recoveryOutcome)
|
|
244
|
+
tags.recovery_outcome = recoveryOutcome;
|
|
245
|
+
const lockTool = diagnostics.lockTool;
|
|
246
|
+
const updaterSource = typeof lockTool === "string" ? KNOWN_INSTALL_TREE_UPDATERS.get(lockTool) : undefined;
|
|
247
|
+
if (updaterSource)
|
|
248
|
+
tags.updater_source = updaterSource;
|
|
249
|
+
return tags;
|
|
250
|
+
}
|
|
170
251
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
171
252
|
const FALLBACK_ERROR_NAME = "other";
|
|
172
253
|
/**
|
|
@@ -241,7 +322,7 @@ function qmdDispositionToken(status, signal) {
|
|
|
241
322
|
* The InstallTreeTornError branch is the deliberate exception: it returns a
|
|
242
323
|
* fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
|
|
243
324
|
* DEFAULT_GROUPING lead, because its chained loader cause makes the default
|
|
244
|
-
* component unbounded
|
|
325
|
+
* component unbounded.
|
|
245
326
|
*
|
|
246
327
|
* `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
|
|
247
328
|
* code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
|
|
@@ -284,10 +365,9 @@ export function sentryFingerprintFor(err) {
|
|
|
284
365
|
];
|
|
285
366
|
}
|
|
286
367
|
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
287
|
-
// only bounded discriminator is the recovery
|
|
368
|
+
// only bounded discriminator is the existing recovery outcome. Keyed
|
|
288
369
|
// BEFORE the rpc/status branches so the family groups into a bounded set of
|
|
289
|
-
// predictable issues
|
|
290
|
-
// `outcome:other` fallback) rather than one fungible bucket.
|
|
370
|
+
// predictable issues rather than one fungible bucket.
|
|
291
371
|
//
|
|
292
372
|
// The key is FULLY EXPLICIT — it does NOT lead with DEFAULT_GROUPING. Leading
|
|
293
373
|
// with the placeholder salts the appended tokens with Sentry's default grouping
|
|
@@ -300,16 +380,16 @@ export function sentryFingerprintFor(err) {
|
|
|
300
380
|
// a4289a5e530e166ebdedea9d34dcad0a, b563c1b891bd975bdc605efc86034b44 and
|
|
301
381
|
// b4ee254473ea249758617de1a98848fa (HQ-CLI-21/22/23) — defeating the very bound
|
|
302
382
|
// this branch exists to enforce. The explicit key collapses them to one stable
|
|
303
|
-
// group per outcome, independent of machine, install path and
|
|
383
|
+
// group per recovery outcome, independent of machine, install path and module.
|
|
304
384
|
if (rawName === "InstallTreeTornError") {
|
|
305
385
|
const diagnostics = record.diagnostics;
|
|
306
386
|
const outcome = diagnostics !== null && typeof diagnostics === "object"
|
|
307
387
|
? diagnostics.outcome
|
|
308
388
|
: undefined;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
389
|
+
return [
|
|
390
|
+
"InstallTreeTornError",
|
|
391
|
+
`outcome:${installTreeRecoveryOutcomeTag(outcome) ?? "other"}`,
|
|
392
|
+
];
|
|
313
393
|
}
|
|
314
394
|
const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
|
|
315
395
|
const rpcCode = record.rpcCode;
|