@indigoai-us/hq-cli 5.108.2 → 5.108.4

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.
@@ -6,15 +6,20 @@
6
6
  * - Publishes retained online ONLY to own presence topics
7
7
  * - Offline is server-only (IoT lifecycle → PresenceIngestFunction)
8
8
  * - Never subscribes to thread topics
9
- * - Reconnects with jittered backoff 1s–60s on close / network change
9
+ * - Reconnects with monotonic full-jitter backoff 1s–60s on close / network
10
+ * - Server credential refusals use a long retry (default 10m ±20%)
10
11
  */
11
12
  import mqtt from "mqtt";
12
- import { CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, realTimerHost, } from "./credentials.js";
13
+ import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
13
14
  import { presignIotWssUrl } from "./presign.js";
14
- /** Full-jitter capped exponential backoff (1s base → 60s cap by default). */
15
- export function backoffDelayMs(attempt, baseMs, maxMs, random) {
15
+ /**
16
+ * Full-jitter capped exponential backoff (1s base 60s cap by default),
17
+ * floored at `previousMs` so consecutive failures never shrink the delay.
18
+ */
19
+ export function backoffDelayMs(attempt, baseMs, maxMs, random, previousMs = 0) {
16
20
  const cap = Math.min(maxMs, baseMs * 2 ** attempt);
17
- return Math.max(0, random() * cap);
21
+ const raw = Math.max(0, random() * cap);
22
+ return Math.min(maxMs, Math.max(previousMs, raw));
18
23
  }
19
24
  export function buildPresencePayload(input) {
20
25
  return {
@@ -43,15 +48,20 @@ export class PresenceClient {
43
48
  bundle = null;
44
49
  state = "idle";
45
50
  attempt = 0;
51
+ lastBackoffMs = 0;
46
52
  reconnectHandle = null;
47
53
  stopped = false;
48
54
  generation = 0;
49
55
  publishedTopics = [];
56
+ /** Last refusal code we logged via onError (episode dedupe). */
57
+ loggedRefusalKey = null;
58
+ refusal = null;
50
59
  mqttConnect;
51
60
  timers;
52
61
  random;
53
62
  baseBackoffMs;
54
63
  maxBackoffMs;
64
+ refusedRetryMs;
55
65
  renewal;
56
66
  now;
57
67
  constructor(options) {
@@ -63,8 +73,10 @@ export class PresenceClient {
63
73
  this.random = options.random ?? Math.random;
64
74
  this.baseBackoffMs = options.baseBackoffMs ?? 1_000;
65
75
  this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
76
+ this.refusedRetryMs =
77
+ options.refusedRetryMs ?? defaultRefusedRetryMs(options.env ?? process.env);
66
78
  this.now = options.now ?? (() => new Date());
67
- this.renewal = new CredentialRenewalManager(options.fetchCredentials, (bundle) => void this.onCredentialsRenewed(bundle), (err) => options.onError?.(err), this.timers);
79
+ this.renewal = new CredentialRenewalManager(options.fetchCredentials, (bundle) => void this.onCredentialsRenewed(bundle), (err, info) => this.handleRenewalError(err, info), this.timers, 30_000, this.refusedRetryMs, this.random);
68
80
  }
69
81
  getConnectionState() {
70
82
  return this.state;
@@ -72,6 +84,9 @@ export class PresenceClient {
72
84
  getBundle() {
73
85
  return this.bundle;
74
86
  }
87
+ getRefusal() {
88
+ return this.refusal;
89
+ }
75
90
  /** Topics this client has published to (for doctor / tests). */
76
91
  getPublishedTopics() {
77
92
  return this.publishedTopics;
@@ -99,13 +114,21 @@ export class PresenceClient {
99
114
  this.renewal.stop();
100
115
  this.clearReconnect();
101
116
  this.teardownClient(false);
117
+ this.clearRefusal(/* logClear */ false);
102
118
  this.setState("closed");
103
119
  }
104
120
  /** Force a reconnect cycle (network change / sleep-wake). */
105
121
  reconnectNow() {
106
122
  if (this.stopped)
107
123
  return;
108
- this.attempt = 0;
124
+ // Do not bypass an active refusal / Retry-After wait (network nudges).
125
+ if (this.refusal) {
126
+ const nextMs = Date.parse(this.refusal.nextRetryAt);
127
+ if (!Number.isNaN(nextMs) && nextMs > this.timers.now()) {
128
+ return;
129
+ }
130
+ }
131
+ // Backoff counters reset only on MQTT connect — not here.
109
132
  this.clearReconnect();
110
133
  void this.connectOnce();
111
134
  }
@@ -128,12 +151,51 @@ export class PresenceClient {
128
151
  }
129
152
  async onCredentialsRenewed(bundle) {
130
153
  this.bundle = bundle;
154
+ // Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
155
+ this.clearRefusal(true);
131
156
  if (this.stopped)
132
157
  return;
133
- // Reconnect with the new presigned URL, then re-publish online.
134
- this.attempt = 0;
158
+ // Reconnect with the new presigned URL; backoff resets only on MQTT connect.
135
159
  await this.connectOnce();
136
160
  }
161
+ handleRenewalError(err, info) {
162
+ if (err instanceof CredentialVendError && err.kind === "refused") {
163
+ this.noteRefusal(err, info?.nextRetryAt);
164
+ return;
165
+ }
166
+ this.options.onError?.(err);
167
+ }
168
+ refusalKey(err) {
169
+ return err.code ?? `HTTP_${err.status}`;
170
+ }
171
+ noteRefusal(err, nextRetryAt) {
172
+ const code = this.refusalKey(err);
173
+ const keyChanged = this.loggedRefusalKey !== code;
174
+ if (keyChanged) {
175
+ this.loggedRefusalKey = code;
176
+ this.options.onError?.(err);
177
+ }
178
+ const refusal = {
179
+ code,
180
+ status: err.status,
181
+ nextRetryAt: nextRetryAt ??
182
+ this.refusal?.nextRetryAt ??
183
+ new Date(this.timers.now()).toISOString(),
184
+ };
185
+ this.refusal = refusal;
186
+ this.options.onRefusal?.(refusal);
187
+ }
188
+ clearRefusal(logClear) {
189
+ if (this.loggedRefusalKey === null && this.refusal === null)
190
+ return;
191
+ const previous = this.loggedRefusalKey;
192
+ this.loggedRefusalKey = null;
193
+ this.refusal = null;
194
+ if (logClear && previous) {
195
+ this.options.onError?.(new Error(`presence refusal cleared (was ${previous})`));
196
+ }
197
+ this.options.onRefusal?.(null);
198
+ }
137
199
  async connectOnce() {
138
200
  if (this.stopped)
139
201
  return;
@@ -143,6 +205,8 @@ export class PresenceClient {
143
205
  try {
144
206
  if (!this.bundle) {
145
207
  this.bundle = await this.options.fetchCredentials();
208
+ // Successful vend ends a refusal episode (even before MQTT connects).
209
+ this.clearRefusal(true);
146
210
  this.renewal.schedule(this.bundle);
147
211
  }
148
212
  const bundle = this.bundle;
@@ -170,6 +234,7 @@ export class PresenceClient {
170
234
  if (this.stopped || generation !== this.generation)
171
235
  return;
172
236
  this.attempt = 0;
237
+ this.lastBackoffMs = 0;
173
238
  this.setState("connected");
174
239
  void this.publishAll("online").then(() => {
175
240
  this.options.onOnline?.(bundle.companies.map((c) => c.companyUid));
@@ -187,6 +252,10 @@ export class PresenceClient {
187
252
  });
188
253
  }
189
254
  catch (err) {
255
+ if (err instanceof CredentialVendError && err.kind === "refused") {
256
+ this.scheduleReconnect({ refused: err });
257
+ return;
258
+ }
190
259
  this.options.onError?.(err);
191
260
  this.scheduleReconnect();
192
261
  }
@@ -224,21 +293,43 @@ export class PresenceClient {
224
293
  });
225
294
  }
226
295
  }
227
- scheduleReconnect() {
296
+ scheduleReconnect(opts) {
228
297
  if (this.stopped)
229
298
  return;
230
299
  this.setState("reconnecting");
231
300
  this.clearReconnect();
232
- const delay = backoffDelayMs(this.attempt, this.baseBackoffMs, this.maxBackoffMs, this.random);
233
- this.attempt += 1;
234
- this.reconnectHandle = this.timers.setTimeout(() => {
301
+ let delay;
302
+ if (opts?.refused) {
303
+ const refused = opts.refused;
304
+ if (refused.retryAfterMs !== undefined) {
305
+ delay = Math.max(0, refused.retryAfterMs);
306
+ }
307
+ else {
308
+ delay = refusedRetryDelayMs(this.refusedRetryMs, this.random);
309
+ }
310
+ delay = clampRetryDelayMs(delay);
311
+ const nextRetryAt = new Date(this.timers.now() + delay).toISOString();
312
+ this.noteRefusal(refused, nextRetryAt);
313
+ }
314
+ else {
315
+ delay = backoffDelayMs(this.attempt, this.baseBackoffMs, this.maxBackoffMs, this.random, this.lastBackoffMs);
316
+ this.lastBackoffMs = delay;
317
+ this.attempt += 1;
318
+ // Do not clear refusal here — MQTT close must retain doctor/status refusal
319
+ // until a subsequent credential vend succeeds.
320
+ }
321
+ this.reconnectHandle = scheduleBoundedTimeout(this.timers, () => {
235
322
  this.reconnectHandle = null;
323
+ // Drop stale creds so the next attempt re-vends after a refusal/failure.
324
+ if (opts?.refused) {
325
+ this.bundle = null;
326
+ }
236
327
  void this.connectOnce();
237
328
  }, delay);
238
329
  }
239
330
  clearReconnect() {
240
331
  if (this.reconnectHandle !== null) {
241
- this.timers.clearTimeout(this.reconnectHandle);
332
+ this.reconnectHandle.clear();
242
333
  this.reconnectHandle = null;
243
334
  }
244
335
  }
@@ -126,6 +126,7 @@ export async function runMeshDaemon(deps = {}) {
126
126
  timers,
127
127
  random: deps.random,
128
128
  now,
129
+ env,
129
130
  onState: (state) => {
130
131
  patchDaemonState(dir, { mqttState: state }, now);
131
132
  },
@@ -134,9 +135,18 @@ export async function runMeshDaemon(deps = {}) {
134
135
  mqttState: "connected",
135
136
  companiesOnline: companies,
136
137
  actorUid: presence?.getBundle()?.actorUid,
138
+ presenceRefusal: null,
137
139
  }, now);
138
140
  log(dir, `presence online companies=${companies.length}`);
139
141
  },
142
+ onRefusal: (refusal) => {
143
+ patchDaemonState(dir, {
144
+ presenceRefusal: refusal,
145
+ lastErrorCode: refusal
146
+ ? `refused:${refusal.code}`.slice(0, 120)
147
+ : undefined,
148
+ }, now);
149
+ },
140
150
  onError: (err) => {
141
151
  const msg = err instanceof Error ? err.message : String(err);
142
152
  patchDaemonState(dir, { lastErrorCode: msg.slice(0, 120) }, now);
@@ -3,6 +3,11 @@
3
3
  */
4
4
  import type { MqttConnectionState } from "./presence.js";
5
5
  import type { FlushSummary } from "../flush.js";
6
+ export interface PresenceRefusalState {
7
+ code: string;
8
+ status: number;
9
+ nextRetryAt: string;
10
+ }
6
11
  export interface DaemonStateFile {
7
12
  v: 1;
8
13
  pid: number;
@@ -15,6 +20,8 @@ export interface DaemonStateFile {
15
20
  ok: boolean;
16
21
  };
17
22
  lastErrorCode?: string;
23
+ /** Set while credential vend is refused (FEATURE_DISABLED / unsupported / 403). */
24
+ presenceRefusal?: PresenceRefusalState | null;
18
25
  updatedAt: string;
19
26
  }
20
27
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
@@ -38,6 +38,25 @@ export type QmdInvocation = {
38
38
  prefixArgs: string[];
39
39
  execDir?: string;
40
40
  };
41
+ /**
42
+ * Bounded, hq-DERIVED evidence stamped onto a CAPTURED qmd spawn-level failure
43
+ * (the interpreter-missing and residual QmdBinaryMissingError cases). The
44
+ * reporting host (HQ-CLI-1A) shipped no platform log, so spawnSync's ENOENT
45
+ * could not be pinned to a missing command vs. a missing working directory. The
46
+ * boundary forwards this into a bounded Sentry context so the NEXT occurrence
47
+ * carries the evidence this one lacked. Every field is hq-derived — the errno
48
+ * code, the resolved `cwd`, `process.execPath`, the bundled launcher, and their
49
+ * existence at failure time — never caller argv or query text.
50
+ */
51
+ export type QmdSpawnDiagnostics = {
52
+ errnoCode: string | null;
53
+ cwd: string | null;
54
+ cwdExists: boolean | null;
55
+ command: string;
56
+ commandExists: boolean;
57
+ launcher: string;
58
+ launcherExists: boolean;
59
+ };
41
60
  /** Low-level spawn seam (defaults to `spawnSync`); injected in tests. */
42
61
  export type QmdSpawn = (cmd: string, args: string[], options: {
43
62
  cwd?: string;
@@ -59,6 +78,34 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
59
78
  export declare function withNodeDirOnPath(env: NodeJS.ProcessEnv | undefined, execDir: string | undefined, platform?: NodeJS.Platform): NodeJS.ProcessEnv | undefined;
60
79
  export declare class QmdBinaryMissingError extends Error {
61
80
  name: string;
81
+ /**
82
+ * Present only when the failure came through the real spawn path (attribution
83
+ * available): the errno code and per-component existence booleans captured at
84
+ * failure time, forwarded to a bounded Sentry context by the boundary so the
85
+ * next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
86
+ * Absent on the legacy injected-runner path and the resolver's opaque throws,
87
+ * so their existing shapes are unchanged.
88
+ */
89
+ readonly spawnDiagnostics?: QmdSpawnDiagnostics;
90
+ constructor(message: string, spawnDiagnostics?: QmdSpawnDiagnostics);
91
+ }
92
+ /**
93
+ * qmd could not be SPAWNED because its working directory does not exist:
94
+ * spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
95
+ * every index call site) before it could exec the command. On Linux that ENOENT
96
+ * is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
97
+ * COMMAND either way — so finishRunQmd disambiguates by existence and raises
98
+ * THIS typed carrier only when the working directory itself is absent. Carries
99
+ * the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
100
+ * `errnoCode`; a missing working directory is the caller's filesystem, so the
101
+ * boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
102
+ */
103
+ export declare class QmdWorkdirMissingError extends Error {
104
+ readonly workdir: string;
105
+ readonly command: string;
106
+ readonly errnoCode: string;
107
+ name: string;
108
+ constructor(message: string, workdir: string, command: string, errnoCode: string);
62
109
  }
63
110
  export declare class QmdExitError extends Error {
64
111
  readonly args: string[];
@@ -469,6 +516,12 @@ export type RunQmdOptions = {
469
516
  execPath?: string;
470
517
  /** Invocation resolver seam (default {@link resolveQmdInvocation}). */
471
518
  resolveInvocation?: (options: ResolveQmdInvocationOptions) => QmdInvocation;
519
+ /**
520
+ * Existence-check seam threaded into finishRunQmd's spawn-failure
521
+ * disambiguation (default `fs.existsSync`); injected in tests so the missing
522
+ * cwd / interpreter branches are provable without touching the real disk.
523
+ */
524
+ exists?: (candidate: string) => boolean;
472
525
  };
473
526
  /** Run qmd with captured output and typed failures. */
474
527
  export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
@@ -30,6 +30,43 @@ export function withNodeDirOnPath(env, execDir, platform = process.platform) {
30
30
  }
31
31
  export class QmdBinaryMissingError extends Error {
32
32
  name = 'QmdBinaryMissingError';
33
+ /**
34
+ * Present only when the failure came through the real spawn path (attribution
35
+ * available): the errno code and per-component existence booleans captured at
36
+ * failure time, forwarded to a bounded Sentry context by the boundary so the
37
+ * next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
38
+ * Absent on the legacy injected-runner path and the resolver's opaque throws,
39
+ * so their existing shapes are unchanged.
40
+ */
41
+ spawnDiagnostics;
42
+ constructor(message, spawnDiagnostics) {
43
+ super(message);
44
+ if (spawnDiagnostics)
45
+ this.spawnDiagnostics = spawnDiagnostics;
46
+ }
47
+ }
48
+ /**
49
+ * qmd could not be SPAWNED because its working directory does not exist:
50
+ * spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
51
+ * every index call site) before it could exec the command. On Linux that ENOENT
52
+ * is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
53
+ * COMMAND either way — so finishRunQmd disambiguates by existence and raises
54
+ * THIS typed carrier only when the working directory itself is absent. Carries
55
+ * the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
56
+ * `errnoCode`; a missing working directory is the caller's filesystem, so the
57
+ * boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
58
+ */
59
+ export class QmdWorkdirMissingError extends Error {
60
+ workdir;
61
+ command;
62
+ errnoCode;
63
+ name = 'QmdWorkdirMissingError';
64
+ constructor(message, workdir, command, errnoCode) {
65
+ super(message);
66
+ this.workdir = workdir;
67
+ this.command = command;
68
+ this.errnoCode = errnoCode;
69
+ }
33
70
  }
34
71
  export class QmdExitError extends Error {
35
72
  args;
@@ -966,7 +1003,7 @@ function describeQmdTermination(stdout, stderr) {
966
1003
  return '';
967
1004
  }
968
1005
  /** Normalise a spawn result and raise the typed qmd failures. */
969
- function finishRunQmd(result, bin, args) {
1006
+ function finishRunQmd(result, bin, args, attribution) {
970
1007
  const normalized = {
971
1008
  status: result.status,
972
1009
  stdout: result.stdout ?? '',
@@ -975,6 +1012,54 @@ function finishRunQmd(result, bin, args) {
975
1012
  signal: result.signal,
976
1013
  };
977
1014
  if (normalized.error) {
1015
+ // A spawn-LEVEL failure: the child could not even be exec'd. When the real
1016
+ // spawn path supplied attribution we can attribute it to the right
1017
+ // component; the legacy injected-runner path passes none and keeps today's
1018
+ // exact class and message.
1019
+ if (attribution) {
1020
+ const spawnError = normalized.error;
1021
+ const errnoCode = typeof spawnError.code === 'string' ? spawnError.code : null;
1022
+ const exists = attribution.exists ?? ((candidate) => fs.existsSync(candidate));
1023
+ const cwd = attribution.cwd ?? null;
1024
+ const cwdExists = cwd !== null ? exists(cwd) : null;
1025
+ const commandExists = exists(attribution.command);
1026
+ const launcherExists = exists(attribution.launcher);
1027
+ // (a) MISSING WORKING DIRECTORY. On Linux spawnSync reports ENOENT with
1028
+ // `error.path` set to the COMMAND both when the command is absent AND when
1029
+ // chdir into `cwd` fails, so this cwd check MUST precede the command check
1030
+ // below — a path-based test alone would keep blaming the interpreter for a
1031
+ // directory that is simply gone (HQ-CLI-1A). The directory is the caller's
1032
+ // filesystem, so it is raised as its own carrier and the boundary skips
1033
+ // capture.
1034
+ if (errnoCode === 'ENOENT' && cwd !== null && cwdExists === false) {
1035
+ throw new QmdWorkdirMissingError(`qmd could not run: its working directory (${cwd}) does not exist`, cwd, attribution.command, errnoCode);
1036
+ }
1037
+ const diagnostics = {
1038
+ errnoCode,
1039
+ cwd,
1040
+ cwdExists,
1041
+ command: attribution.command,
1042
+ commandExists,
1043
+ launcher: attribution.launcher,
1044
+ launcherExists,
1045
+ };
1046
+ // (b) MISSING INTERPRETER. The bundled qmd is launched as `<node> <qmd
1047
+ // launcher>`; when the interpreter (`command`, distinct from the launcher)
1048
+ // is what could not be executed, name IT rather than the launcher the
1049
+ // reported message wrongly blamed. Still captured — an absent interpreter
1050
+ // is a genuine defect — but now correct and self-describing.
1051
+ if (errnoCode === 'ENOENT' &&
1052
+ attribution.command !== attribution.launcher &&
1053
+ commandExists === false) {
1054
+ throw new QmdBinaryMissingError(`Unable to execute qmd: its Node interpreter (${attribution.command}) could not be executed: ${normalized.error.message}`, diagnostics);
1055
+ }
1056
+ // (c) RESIDUAL: an ENOENT the fix cannot attribute (a genuinely missing
1057
+ // qmd, or a race where the directory/interpreter reappeared) or any other
1058
+ // spawn errno (EACCES/EINVAL/…). Keep today's class and message byte-for-
1059
+ // byte, and attach the bounded diagnostics so the next occurrence carries
1060
+ // the per-component existence evidence.
1061
+ throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`, diagnostics);
1062
+ }
978
1063
  throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
979
1064
  }
980
1065
  if (normalized.status === 0)
@@ -1157,13 +1242,24 @@ export function runQmd(args, options = {}) {
1157
1242
  // idempotent; only the real spawn path needs it (the injected-runner path
1158
1243
  // above never opens a store).
1159
1244
  ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
1245
+ const launcher = invocationBin(invocation);
1160
1246
  const result = spawnQmd(invocation, args, {
1161
1247
  cwd: options.cwd,
1162
1248
  env: options.env ?? process.env,
1163
1249
  platform: options.platform,
1164
1250
  spawn: options.spawn,
1165
1251
  });
1166
- return finishRunQmd(result, invocationBin(invocation), args);
1252
+ // Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
1253
+ // right component — the working directory, the Node interpreter, or a
1254
+ // genuinely missing qmd — instead of blaming the launcher for all three
1255
+ // (HQ-CLI-1A). `command` is the process actually spawned; `launcher` is qmd's
1256
+ // file identity.
1257
+ return finishRunQmd(result, launcher, args, {
1258
+ command: invocation.command,
1259
+ launcher,
1260
+ cwd: options.cwd,
1261
+ exists: options.exists,
1262
+ });
1167
1263
  }
1168
1264
  function containsIndexedMarkdown(directory) {
1169
1265
  if (!fs.existsSync(directory))
package/dist/main.js CHANGED
@@ -75,6 +75,7 @@ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
75
75
  import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
76
76
  import { qmdQueryDocumentMessage } from "./utils/qmd-query-document-error.js";
77
77
  import { qmdModelDownloadMessage } from "./utils/qmd-model-download-error.js";
78
+ import { qmdWorkdirMissingMessage } from "./utils/qmd-workdir-missing-error.js";
78
79
  import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
79
80
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
80
81
  import { isVarlockEnvError } from "./run/env-graph-guard.js";
@@ -379,6 +380,26 @@ const defaultTopLevelErrorDependencies = {
379
380
  process.exitCode = code;
380
381
  },
381
382
  };
383
+ /**
384
+ * Bounded Sentry context for a CAPTURED qmd spawn-level failure (the
385
+ * interpreter-missing and residual QmdBinaryMissingError cases). finishRunQmd
386
+ * stamps the errno code and per-component existence booleans onto the error at
387
+ * failure time; because the reporting host (HQ-CLI-1A) shipped no platform log,
388
+ * this is the evidence the next occurrence carries. Read STRUCTURALLY off the
389
+ * error's own `spawnDiagnostics` field (the boundary reconstructs typed carriers
390
+ * structurally in tests). Only hq-DERIVED paths (the resolved cwd,
391
+ * process.execPath, the bundled launcher) and a bounded errno code appear —
392
+ * never caller argv or query text. Returns undefined when the error carries no
393
+ * such diagnostics, so the generic capture stays a bare captureException(err).
394
+ */
395
+ function qmdSpawnFailureCaptureContext(err) {
396
+ if (err === null || typeof err !== "object")
397
+ return undefined;
398
+ const diagnostics = err.spawnDiagnostics;
399
+ if (diagnostics === null || typeof diagnostics !== "object")
400
+ return undefined;
401
+ return { contexts: { qmd_spawn_failure: diagnostics } };
402
+ }
382
403
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
383
404
  export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
384
405
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
@@ -628,6 +649,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
628
649
  const modelDownloadMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg
629
650
  ? null
630
651
  : qmdModelDownloadMessage(err);
652
+ // A qmd spawn-level failure whose ENOENT was a MISSING WORKING DIRECTORY
653
+ // (the resolved hq root the qmd child was handed as `cwd` does not exist).
654
+ // finishRunQmd types it QmdWorkdirMissingError, carrying the hq-derived
655
+ // directory; the boundary prints a self-describing remedy naming that
656
+ // directory and skips capture — a working directory that does not exist is
657
+ // the caller's filesystem, the same disposition as the store-missing /
658
+ // store-unopenable branches. Before this branch it was MISATTRIBUTED to a
659
+ // missing qmd (QmdBinaryMissingError, remedy `Install @tobilu/qmd`) and,
660
+ // with no boundary branch for it, captured as a crash (HQ-CLI-1A, Sentry
661
+ // 7705642434). Evaluated immediately AFTER the store-unopenable /
662
+ // query-document / model-download checks and BEFORE the environmental /
663
+ // transport / generic branches; the carrier's name is disjoint from every
664
+ // neighbour and it carries no `.code` environmentalFsErrorMessage could
665
+ // read, so ordering changes no existing branch.
666
+ const workdirMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg
667
+ ? null
668
+ : qmdWorkdirMissingMessage(err);
631
669
  // An hq STATE-TREE write that failed for an environmental errno
632
670
  // (permission/quota/read-only/parent-gone) is typed at the write site as an
633
671
  // HqStateWriteError carrying the exact hq-DERIVED path it tried to write.
@@ -637,10 +675,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
637
675
  // remedy and skip capture. Placed with the environmental-fs family — BEFORE
638
676
  // the generic envMsg computation — so this typed carrier wins even though
639
677
  // it also carries an errno `code` environmentalFsErrorMessage could read.
640
- const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg
678
+ const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg
641
679
  ? null
642
680
  : hqStateWriteErrorMessage(err);
643
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || stateWriteMsg
681
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
644
682
  ? null
645
683
  : environmentalFsErrorMessage(err);
646
684
  // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
@@ -657,7 +695,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
657
695
  // environmental-fs check, before network-transport — is pinned by tests.
658
696
  // The `in-process-async-holder` reason is deliberately NOT suppressed here
659
697
  // (see sync-state-lock-error.ts); it stays captured.
660
- const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || stateWriteMsg || envMsg
698
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || envMsg
661
699
  ? null
662
700
  : syncStateLockMessage(err);
663
701
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -679,6 +717,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
679
717
  storeUnopenableMsg ||
680
718
  queryDocumentMsg ||
681
719
  modelDownloadMsg ||
720
+ workdirMissingMsg ||
682
721
  stateWriteMsg ||
683
722
  envMsg ||
684
723
  lockMsg
@@ -711,6 +750,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
711
750
  else if (modelDownloadMsg) {
712
751
  deps.stderr.write(`hq: ${modelDownloadMsg}\n`);
713
752
  }
753
+ else if (workdirMissingMsg) {
754
+ deps.stderr.write(`hq: ${workdirMissingMsg}\n`);
755
+ }
714
756
  else if (stateWriteMsg) {
715
757
  deps.stderr.write(`hq: ${stateWriteMsg}\n`);
716
758
  }
@@ -724,7 +766,17 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
724
766
  deps.stderr.write(`hq: ${transportMsg}\n`);
725
767
  }
726
768
  else {
727
- deps.sentry.captureException(err);
769
+ // A genuinely unclassified fault is still captured exactly once. When it
770
+ // is a qmd spawn-level failure the fix could not attribute, attach the
771
+ // bounded existence context so the next occurrence carries the evidence
772
+ // the reported HQ-CLI-1A event lacked; every other error captures bare.
773
+ const spawnContext = qmdSpawnFailureCaptureContext(err);
774
+ if (spawnContext) {
775
+ deps.sentry.captureException(err, spawnContext);
776
+ }
777
+ else {
778
+ deps.sentry.captureException(err);
779
+ }
728
780
  // Always emit something. Printing only when unexpectedCliErrorMessage()
729
781
  // returned a value meant every error class except IntegrationsCliError
730
782
  // exited 1 with zero bytes on stdout AND stderr — a silent failure the
@@ -0,0 +1,18 @@
1
+ /**
2
+ * True when `err` is a qmd working-directory-missing failure: it carries the
3
+ * QmdWorkdirMissingError name and an hq-populated `workdir`. Accepts either the
4
+ * thrown error or a bare `{ name, workdir }` probe object. A true result means
5
+ * the caller should print the classified remedy, exit non-zero, and SKIP Sentry
6
+ * capture.
7
+ */
8
+ export declare function isQmdWorkdirMissingError(err: unknown): boolean;
9
+ /**
10
+ * If `err` is a qmd working-directory-missing failure, return the actionable
11
+ * remedy; otherwise return `null`. Mirrors qmdStoreMissingMessage /
12
+ * qmdStoreUnopenableMessage so the top-level handler can branch on it the same
13
+ * way: a non-null result means print-and-skip-Sentry, null means "handle as
14
+ * usual". The working directory is read STRUCTURALLY from the error's own
15
+ * hq-populated `workdir` field, NEVER from qmd's output.
16
+ */
17
+ export declare function qmdWorkdirMissingMessage(err: unknown): string | null;
18
+ //# sourceMappingURL=qmd-workdir-missing-error.d.ts.map