@indigoai-us/hq-cli 5.321.0 → 5.322.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.322.0] — 2026-10-02
6
+
7
+ - Retried idempotent vault API GETs once when API Gateway returned its generic Lambda-invoke 504 response without a function request ID.
8
+
9
+ - Defer CLI-update daemon restarts until the sync runner finishes its pass, coalesce pending restart requests, and bound mesh daemon shutdown steps with clearer SIGTERM escalation diagnostics.
10
+
5
11
  ## [5.321.0] — 2026-10-02
6
12
 
7
13
  - Project catalog cache keys include their source directory, and runtime-probe tests isolate the host CLI and scope checks to hooks.
@@ -632,7 +632,7 @@ async function runServiceSelfUpdate(d) {
632
632
  tools: TOOL_NAMES.map((name) => hostToolUpdater(name, env, d.home)),
633
633
  updateHqCloud: async () => updateManagedHqCloud(paths.root, env),
634
634
  hqCloudUnits: hqCloudUnits(detected.type, enabledServices(detected.type, readDaemonConfig(paths.configPath))),
635
- requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon" }),
635
+ requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon", deferUntilSyncIdle: true }),
636
636
  requestRestart: (unit) => writeControlRequest(paths.requestsDir, { action: "restart", unit }),
637
637
  log: d.out,
638
638
  });
@@ -22,6 +22,7 @@ export type ControlRequest = {
22
22
  /** Restart the whole daemon, for example after the updater installed a new CLI. */
23
23
  | {
24
24
  action: "restart-daemon";
25
+ deferUntilSyncIdle?: boolean;
25
26
  };
26
27
  export declare function writeControlRequest(dir: string, request: ControlRequest, now?: () => number): string;
27
28
  export declare function takeControlRequests(dir: string): ControlRequest[];
@@ -18,7 +18,7 @@ function isControlRequest(value) {
18
18
  if (v.action === "trigger" || v.action === "stop")
19
19
  return typeof v.unit === "string";
20
20
  if (v.action === "restart-daemon")
21
- return true;
21
+ return v.deferUntilSyncIdle === undefined || typeof v.deferUntilSyncIdle === "boolean";
22
22
  if (v.action === "reconcile")
23
23
  return true;
24
24
  return false;
@@ -594,7 +594,7 @@ export async function runDaemon(opts) {
594
594
  }
595
595
  };
596
596
  // Set once the shutdown path exists below; a restart request before then is dropped.
597
- let restartDaemon = () => { };
597
+ let restartDaemon = () => false;
598
598
  const supervisor = createSupervisor({
599
599
  machineType,
600
600
  host: opts.host,
@@ -730,12 +730,12 @@ export async function runDaemon(opts) {
730
730
  restartDaemon = () => {
731
731
  if (!restartsForParent(opts)) {
732
732
  log("warn", "a restart was requested, but no service manager would start this daemon again; restart it yourself");
733
- return;
733
+ return false;
734
734
  }
735
735
  if (stopping || restartPending)
736
- return;
736
+ return true;
737
737
  restartPending = true;
738
- void handoffWanted()
738
+ return handoffWanted()
739
739
  .catch((err) => {
740
740
  log("warn", `could not decide on a handoff; restarting every service: ${err.message}`);
741
741
  return false;
@@ -745,6 +745,7 @@ export async function runDaemon(opts) {
745
745
  handOver();
746
746
  else
747
747
  shutdown("restarting onto the updated CLI", RESTART_EXIT_CODE);
748
+ return true;
748
749
  });
749
750
  };
750
751
  process.on("SIGTERM", () => shutdown("received SIGTERM"));
@@ -202,7 +202,7 @@ export async function stopOrphans(orphans, deps) {
202
202
  remaining = remaining.filter(same);
203
203
  }
204
204
  for (const r of remaining) {
205
- deps.log("warn", `${r.id} (pid ${r.pid}) ignored SIGTERM; sending SIGKILL`);
205
+ deps.log("warn", `${r.id} (pid ${r.pid}) did not exit within ${graceMs / 1_000} s of SIGTERM; sending SIGKILL`);
206
206
  send(r, "SIGKILL");
207
207
  }
208
208
  }
@@ -26,6 +26,7 @@ import type { MachineType } from "./machine-type.js";
26
26
  import type { ControlRequest } from "./requests.js";
27
27
  import { syncPassResult } from "./sync-runner.js";
28
28
  export declare const STOP_ESCALATION_MS = 10000;
29
+ export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
29
30
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
30
31
  export declare const HANDOFF_STAGGER_MS = 15000;
31
32
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
@@ -94,7 +95,7 @@ export interface SupervisorDeps {
94
95
  runInternal: (task: string) => Promise<boolean>;
95
96
  takeRequests: () => ControlRequest[];
96
97
  /** Restart the whole daemon process (a `restart-daemon` request). */
97
- restartDaemon?: () => void;
98
+ restartDaemon?: () => void | boolean | Promise<boolean | void>;
98
99
  writeState: (state: DaemonState) => void;
99
100
  log: (level: "info" | "warn" | "error", msg: string) => void;
100
101
  now?: () => number;
@@ -27,6 +27,7 @@ const BACKOFF_BASE_MS = 1_000;
27
27
  const BACKOFF_MAX_MS = 60_000;
28
28
  const HEALTHY_RUN_MS = 60_000;
29
29
  export const STOP_ESCALATION_MS = 10_000;
30
+ export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
30
31
  const MESH_OWNER_RECHECK_MS = 15_000;
31
32
  const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
32
33
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
@@ -61,6 +62,13 @@ export function planKey(plan, cliVersion) {
61
62
  function errorMessage(err) {
62
63
  return err instanceof Error ? err.message : String(err);
63
64
  }
65
+ function lastProtocolHint(slot) {
66
+ if (slot.lastProtocolStatus)
67
+ return ` (last status: ${slot.lastProtocolStatus})`;
68
+ if (slot.lastProtocolLine)
69
+ return ` (last protocol line: ${slot.lastProtocolLine})`;
70
+ return "";
71
+ }
64
72
  export function createSupervisor(deps) {
65
73
  const now = deps.now ?? Date.now;
66
74
  const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
@@ -81,6 +89,9 @@ export function createSupervisor(deps) {
81
89
  const live = new Set();
82
90
  let drained = null;
83
91
  let onDrained = null;
92
+ let deferredCliRestart = false;
93
+ let deferredCliRestartTimer;
94
+ let cliRestartInProgress = false;
84
95
  const snapshot = () => ({
85
96
  version: 1,
86
97
  pid: deps.pid,
@@ -108,6 +119,71 @@ export function createSupervisor(deps) {
108
119
  clearT(slot.heartbeatTimer);
109
120
  slot.heartbeatTimer = undefined;
110
121
  };
122
+ const clearDeferredCliRestart = () => {
123
+ deferredCliRestart = false;
124
+ if (deferredCliRestartTimer !== undefined)
125
+ clearT(deferredCliRestartTimer);
126
+ deferredCliRestartTimer = undefined;
127
+ };
128
+ const runDeferredCliRestart = () => {
129
+ if (!deferredCliRestart)
130
+ return;
131
+ clearDeferredCliRestart();
132
+ cliRestartInProgress = true;
133
+ const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
134
+ if (sync)
135
+ terminate(sync);
136
+ deps.log("info", "sync runner is between passes; restarting daemon onto the updated CLI");
137
+ const releaseHold = () => {
138
+ cliRestartInProgress = false;
139
+ const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
140
+ if (!currentSync)
141
+ return;
142
+ if (currentSync.child)
143
+ terminate(currentSync, () => activate(currentSync));
144
+ else
145
+ activate(currentSync);
146
+ };
147
+ try {
148
+ const result = deps.restartDaemon?.();
149
+ if (result === false)
150
+ releaseHold();
151
+ else if (result && typeof result === "object" && "then" in result) {
152
+ void Promise.resolve(result).then((accepted) => {
153
+ if (accepted === false)
154
+ releaseHold();
155
+ }).catch((err) => {
156
+ deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
157
+ releaseHold();
158
+ });
159
+ }
160
+ }
161
+ catch (err) {
162
+ deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
163
+ releaseHold();
164
+ }
165
+ };
166
+ const requestCliUpdateRestart = () => {
167
+ if (!deps.restartDaemon)
168
+ return;
169
+ const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
170
+ if (!sync) {
171
+ void deps.restartDaemon();
172
+ return;
173
+ }
174
+ if (sync.syncPassActive === false) {
175
+ deferredCliRestart = true;
176
+ runDeferredCliRestart();
177
+ return;
178
+ }
179
+ if (deferredCliRestart) {
180
+ deps.log("info", "coalescing CLI-update restart while waiting for sync to become idle");
181
+ return;
182
+ }
183
+ deferredCliRestart = true;
184
+ deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
185
+ deferredCliRestartTimer = setT(runDeferredCliRestart, CLI_UPDATE_SYNC_IDLE_MAX_MS);
186
+ };
111
187
  const setBlocked = (slot) => {
112
188
  const block = slot.plan.blocked;
113
189
  slot.state.status = block.state;
@@ -136,6 +212,12 @@ export function createSupervisor(deps) {
136
212
  then?.();
137
213
  return;
138
214
  }
215
+ if (slot.expectingExit) {
216
+ // Keep the original stop deadline and signal; only update the action
217
+ // that runs after exit (for example, start the changed command).
218
+ slot.afterExit = then;
219
+ return;
220
+ }
139
221
  slot.expectingExit = true;
140
222
  slot.afterExit = then;
141
223
  if (slot.killTimer !== undefined)
@@ -145,7 +227,7 @@ export function createSupervisor(deps) {
145
227
  slot.killTimer = undefined;
146
228
  if (slot.child !== child)
147
229
  return;
148
- deps.log("warn", `${slot.plan.id} ignored SIGTERM; sending SIGKILL`);
230
+ deps.log("warn", `${slot.plan.id} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
149
231
  signalChild(slot, child, "SIGKILL");
150
232
  }, STOP_ESCALATION_MS);
151
233
  };
@@ -211,6 +293,25 @@ export function createSupervisor(deps) {
211
293
  }
212
294
  if (!event)
213
295
  return;
296
+ slot.lastProtocolLine = line.length > 240 ? `${line.slice(0, 237)}...` : line;
297
+ if (slot.plan.service === "sync") {
298
+ if (event.type === "fanout-plan")
299
+ slot.syncPassActive = true;
300
+ else if (event.type === "all-complete")
301
+ slot.syncPassActive = false;
302
+ else if (event.type === "status" && typeof event.state === "string") {
303
+ slot.lastProtocolStatus = event.state;
304
+ if (event.state === "stopped" || event.state === "waiting-for-lock")
305
+ slot.syncPassActive = false;
306
+ else if (slot.syncPassActive === undefined && (event.state === "starting" || event.state === "running")) {
307
+ // Before the first pass boundary, status heartbeats cannot prove
308
+ // that startup work has not already entered a pass.
309
+ slot.syncPassActive = true;
310
+ }
311
+ }
312
+ if (deferredCliRestart && slot.syncPassActive === false)
313
+ runDeferredCliRestart();
314
+ }
214
315
  const at = now();
215
316
  slot.state.lastHeartbeatAt = new Date(at).toISOString();
216
317
  const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
@@ -358,6 +459,15 @@ export function createSupervisor(deps) {
358
459
  const startLong = (slot) => {
359
460
  if (stopped)
360
461
  return;
462
+ if (cliRestartInProgress && slot.plan.service === "sync") {
463
+ slot.state.status = "waiting";
464
+ slot.state.reason = "daemon restart pending";
465
+ delete slot.state.nextRunAt;
466
+ return;
467
+ }
468
+ delete slot.lastProtocolLine;
469
+ delete slot.lastProtocolStatus;
470
+ delete slot.syncPassActive;
361
471
  let child;
362
472
  try {
363
473
  child = deps.spawn(slot.plan);
@@ -550,7 +660,10 @@ export function createSupervisor(deps) {
550
660
  deps.log("info", `restarting ${plan.id}: its command changed`);
551
661
  existing.attempts = 0;
552
662
  existing.state.restarts = 0;
553
- terminate(existing, () => activate(existing));
663
+ if (cliRestartInProgress && plan.service === "sync")
664
+ terminate(existing);
665
+ else
666
+ terminate(existing, () => activate(existing));
554
667
  }
555
668
  }
556
669
  slots = next;
@@ -650,11 +763,19 @@ export function createSupervisor(deps) {
650
763
  return;
651
764
  }
652
765
  if (req.action === "restart-daemon") {
653
- deps.log("info", "restarting the daemon (requested)");
654
- if (deps.restartDaemon)
655
- deps.restartDaemon();
656
- else
766
+ if (req.deferUntilSyncIdle && deps.restartDaemon) {
767
+ deps.log("info", "CLI-update daemon restart requested");
768
+ requestCliUpdateRestart();
769
+ }
770
+ else if (deps.restartDaemon) {
771
+ deps.log("info", "restarting the daemon (requested)");
772
+ clearDeferredCliRestart();
773
+ void deps.restartDaemon();
774
+ }
775
+ else {
776
+ deps.log("info", "restarting the daemon (requested)");
657
777
  deps.log("warn", "this daemon cannot restart itself; restart it to load the new version");
778
+ }
658
779
  return;
659
780
  }
660
781
  // A bot created since the last plan read is asked for before the timer
@@ -756,7 +877,11 @@ export function createSupervisor(deps) {
756
877
  clearT(requestTimer);
757
878
  if (handoffTimer !== undefined)
758
879
  clearT(handoffTimer);
880
+ clearDeferredCliRestart();
881
+ const childOwners = new Map();
759
882
  for (const slot of slots.values()) {
883
+ if (slot.child)
884
+ childOwners.set(slot.child, slot);
760
885
  clearTimer(slot);
761
886
  clearHeartbeatTimer(slot);
762
887
  slot.child = undefined;
@@ -784,7 +909,8 @@ export function createSupervisor(deps) {
784
909
  }
785
910
  const escalation = setT(() => {
786
911
  for (const child of live) {
787
- deps.log("warn", `pid ${child.pid ?? "?"} ignored SIGTERM; sending SIGKILL`);
912
+ const owner = childOwners.get(child);
913
+ deps.log("warn", `${owner?.plan.id ?? `pid ${child.pid ?? "?"}`} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${owner ? lastProtocolHint(owner) : ""}`);
788
914
  try {
789
915
  child.kill("SIGKILL");
790
916
  }
@@ -818,6 +944,7 @@ export function createSupervisor(deps) {
818
944
  clearT(requestTimer);
819
945
  if (handoffTimer !== undefined)
820
946
  clearT(handoffTimer);
947
+ clearDeferredCliRestart();
821
948
  for (const slot of slots.values()) {
822
949
  clearTimer(slot);
823
950
  if (slot.killTimer !== undefined)
@@ -24,6 +24,8 @@ export declare const FLUSH_INTERVAL_MS = 10000;
24
24
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
25
25
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
26
26
  export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
27
+ /** Three sequential async shutdown steps must finish within six seconds. */
28
+ export declare const SHUTDOWN_STEP_TIMEOUT_MS = 2000;
27
29
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
28
30
  export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
29
31
  /**
@@ -45,6 +45,8 @@ export const FLUSH_INTERVAL_MS = 10_000;
45
45
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
46
46
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
47
47
  export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
48
+ /** Three sequential async shutdown steps must finish within six seconds. */
49
+ export const SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
48
50
  /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
49
51
  export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
50
52
  /**
@@ -871,29 +873,48 @@ export async function runMeshDaemon(deps = {}) {
871
873
  catch {
872
874
  /* ignore */
873
875
  }
874
- // Final flush, then clean MQTT DISCONNECT so the broker emits the
875
- // lifecycle event; offline is published server-side only.
876
- await doFlushCycle("shutdown", { force: true });
877
- if (workCache) {
876
+ const stopStep = async (name, action) => {
877
+ let deadline;
878
+ let timedOut = false;
879
+ const timeout = new Promise((resolve) => {
880
+ deadline = timers.setTimeout(() => {
881
+ timedOut = true;
882
+ resolve();
883
+ }, SHUTDOWN_STEP_TIMEOUT_MS);
884
+ });
878
885
  try {
879
- await workCache.stop();
886
+ await Promise.race([Promise.resolve().then(action), timeout]);
887
+ if (timedOut)
888
+ log(dir, `${name} did not finish within ${SHUTDOWN_STEP_TIMEOUT_MS / 1_000} s; continuing shutdown`);
880
889
  }
881
890
  catch (err) {
882
891
  const msg = err instanceof Error ? err.message : String(err);
883
- log(dir, `work cache stop error: ${msg.slice(0, 120)}`);
892
+ log(dir, `${name} error: ${msg.slice(0, 120)}`);
893
+ }
894
+ finally {
895
+ if (deadline !== undefined)
896
+ timers.clearTimeout(deadline);
884
897
  }
898
+ };
899
+ // Final flush, then clean MQTT DISCONNECT so the broker emits the
900
+ // lifecycle event; offline is published server-side only.
901
+ await stopStep("shutdown flush", () => doFlushCycle("shutdown", { force: true }));
902
+ if (workCache) {
903
+ await stopStep("work cache stop", () => workCache.stop());
885
904
  }
886
905
  if (presence) {
887
- try {
888
- await presence.stop();
889
- }
890
- catch (err) {
891
- const msg = err instanceof Error ? err.message : String(err);
892
- log(dir, `presence stop error: ${msg.slice(0, 120)}`);
893
- }
906
+ await stopStep("presence stop", () => presence.stop());
907
+ }
908
+ try {
909
+ patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
910
+ }
911
+ catch (err) {
912
+ const msg = err instanceof Error ? err.message : String(err);
913
+ log(dir, `daemon shutdown state update error: ${msg.slice(0, 120)}`);
914
+ }
915
+ finally {
916
+ releasePidLock(dir, lockDeps);
894
917
  }
895
- patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
896
- releasePidLock(dir, lockDeps);
897
918
  log(dir, "daemon stopped");
898
919
  };
899
920
  const onSignal = () => {
@@ -23,6 +23,57 @@ import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
23
23
  * whole-process watchdog.
24
24
  */
25
25
  const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
26
+ const LAMBDA_INVOKE_RETRY_MAX_DELAY_MS = 100;
27
+ function isLambdaInvokeServiceError(response, bodyText) {
28
+ if (response.status !== 504) {
29
+ return false;
30
+ }
31
+ try {
32
+ const body = JSON.parse(bodyText);
33
+ return (typeof body === 'object' &&
34
+ body !== null &&
35
+ !Array.isArray(body) &&
36
+ Object.keys(body).length === 1 &&
37
+ body.message === 'Internal server error');
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ async function fetchWithLambdaInvokeRetry(fetchOnce, retryEligible, signal) {
44
+ const first = await fetchOnce();
45
+ if (!retryEligible || first.status !== 504)
46
+ return first;
47
+ const bodyText = await first.text();
48
+ if (!isLambdaInvokeServiceError(first, bodyText)) {
49
+ return new Response(bodyText, {
50
+ status: first.status,
51
+ statusText: first.statusText,
52
+ headers: first.headers,
53
+ });
54
+ }
55
+ const delay = 25 + Math.floor(Math.random() * (LAMBDA_INVOKE_RETRY_MAX_DELAY_MS - 24));
56
+ await jitteredDelayWithAbort(delay, signal);
57
+ return fetchOnce();
58
+ }
59
+ function jitteredDelayWithAbort(ms, signal) {
60
+ if (signal?.aborted) {
61
+ return Promise.reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
62
+ }
63
+ return new Promise((resolve, reject) => {
64
+ const cleanup = () => signal?.removeEventListener('abort', abort);
65
+ const timer = setTimeout(() => {
66
+ cleanup();
67
+ resolve();
68
+ }, ms);
69
+ const abort = () => {
70
+ clearTimeout(timer);
71
+ cleanup();
72
+ reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'));
73
+ };
74
+ signal?.addEventListener('abort', abort, { once: true });
75
+ });
76
+ }
26
77
  /**
27
78
  * The identity-lookup abort bound, in milliseconds. Defaults to the shipped
28
79
  * IDENTITY_LOOKUP_TIMEOUT_MS (15s). An optional HQ_IDENTITY_LOOKUP_TIMEOUT_MS
@@ -192,41 +243,40 @@ export async function vaultApiFetch(opts) {
192
243
  level: "info",
193
244
  data: { url: safeUrl, method },
194
245
  });
195
- let response;
196
- try {
197
- response = await fetch(url.toString(), {
198
- method,
199
- headers: {
200
- Authorization: `Bearer ${opts.token}`,
201
- 'Content-Type': 'application/json',
202
- 'x-hq-client-name': HQ_CLIENT_NAME,
203
- // US-003: version alongside the client family, mirroring the desktop
204
- // app's client_info.rs headers, so the server can see client-version
205
- // skew on EVERY authenticated request, not only health heartbeats.
206
- // Fixed build constant, never caller-supplied; attribution only.
207
- 'x-hq-client-version': CLI_VERSION,
208
- ...opts.headers,
209
- },
210
- body: opts.body ? JSON.stringify(opts.body) : undefined,
211
- signal: opts.signal,
212
- });
213
- }
214
- catch (err) {
215
- // A transport failure never reaches the server, so the non-2xx breadcrumb
216
- // below never fires and the run's last HTTP trace is the request that
217
- // silently vanished (exactly what made HQ-CLI-G's event unreadable). Leave
218
- // a bounded, non-secret trail — the already-redacted safeUrl plus the
219
- // recognized transport code — then RE-THROW the original error untouched,
220
- // so `main.ts`'s classifier stays the single decision point.
221
- const code = networkTransportErrorCode(err);
222
- Sentry.addBreadcrumb({
223
- category: "http",
224
- message: `${method} ${path} → transport error`,
225
- level: "warning",
226
- data: { url: safeUrl, method, ...(code ? { code } : {}) },
227
- });
228
- throw err;
229
- }
246
+ const requestInit = {
247
+ method,
248
+ headers: {
249
+ Authorization: `Bearer ${opts.token}`,
250
+ 'Content-Type': 'application/json',
251
+ 'x-hq-client-name': HQ_CLIENT_NAME,
252
+ // US-003: version alongside the client family, mirroring the desktop
253
+ // app's client_info.rs headers, so the server can see client-version
254
+ // skew on EVERY authenticated request, not only health heartbeats.
255
+ // Fixed build constant, never caller-supplied; attribution only.
256
+ 'x-hq-client-version': CLI_VERSION,
257
+ ...opts.headers,
258
+ },
259
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
260
+ signal: opts.signal,
261
+ };
262
+ const fetchWithTransportBreadcrumb = async () => {
263
+ try {
264
+ return await fetch(url.toString(), requestInit);
265
+ }
266
+ catch (err) {
267
+ // Keep the original transport failure and its bounded, redacted trail on
268
+ // both the first request and the single retry.
269
+ const code = networkTransportErrorCode(err);
270
+ Sentry.addBreadcrumb({
271
+ category: "http",
272
+ message: `${method} ${path} → transport error`,
273
+ level: "warning",
274
+ data: { url: safeUrl, method, ...(code ? { code } : {}) },
275
+ });
276
+ throw err;
277
+ }
278
+ };
279
+ const response = await fetchWithLambdaInvokeRetry(fetchWithTransportBreadcrumb, method.toUpperCase() === 'GET' && opts.body === undefined, opts.signal);
230
280
  if (!response.ok) {
231
281
  Sentry.addBreadcrumb({
232
282
  category: "http",
@@ -287,10 +337,10 @@ export async function vaultApiFetchPublic(opts) {
287
337
  level: 'info',
288
338
  data: { url: safeUrl, method: 'GET' },
289
339
  });
290
- const response = await fetch(url.toString(), {
340
+ const response = await fetchWithLambdaInvokeRetry(() => fetch(url.toString(), {
291
341
  method: 'GET',
292
342
  headers: { 'Content-Type': 'application/json' },
293
- });
343
+ }), true);
294
344
  if (!response.ok) {
295
345
  Sentry.addBreadcrumb({
296
346
  category: 'http',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.321.0",
3
+ "version": "5.322.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {