@indigoai-us/hq-cli 5.108.3 → 5.108.5

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,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.5] — 2026-09-04
6
+
7
+ ### Added
8
+
9
+ - `hq meetings import <file>` imports normalized historical meeting transcripts
10
+ through HQ's managed source pipeline, with immutable idempotent replay and
11
+ explicit conflict detection when an external source ID is reused with
12
+ different content.
13
+
14
+ ## [5.108.4] — 2026-09-04
15
+
16
+ ### Fixed
17
+
18
+ - Mesh daemon presence reconnect backoff no longer shrinks between failures:
19
+ consecutive errors grow the delay monotonically (full jitter floored at the
20
+ previous delay) up to the 60 s cap, and the attempt counter resets only after
21
+ a successful MQTT connect. Server refusals (`404 FEATURE_DISABLED`,
22
+ `409 REALTIME_CONTRACT_UNSUPPORTED`, and `403`) use a long retry of 10
23
+ minutes ±20% jitter (override with `HQ_MESH_DAEMON_REFUSED_RETRY_MS`; a
24
+ `Retry-After` header wins), log one line per refusal episode, and surface the
25
+ refused state plus next retry time on `hq mesh daemon doctor` and
26
+ `hq mesh daemon status`.
27
+
28
+ ### Changed
29
+
30
+ - Clarification: hq-core hooks from the matching Work Mesh Live release require
31
+ hq-cli **5.108.2** or newer (spool enqueue format and flush semantics). The
32
+ 5.108.2 release notes incorrectly said 5.109.0.
33
+
5
34
  ## [5.108.3] — 2026-09-04
6
35
 
7
36
  ## [5.108.2] — 2026-09-04
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
4
+ import { vaultApiFetch, getCompanyUid, resolveCallerPersonUid, } from "../utils/vault-api.js";
4
5
  function formatDuration(seconds) {
5
6
  const h = Math.floor(seconds / 3600);
6
7
  const m = Math.floor((seconds % 3600) / 60);
@@ -216,6 +217,59 @@ export function registerMeetingsCommand(program) {
216
217
  }
217
218
  });
218
219
  // ── hq meetings invite <meeting-url> ──────────────────────────────
220
+ meetings
221
+ .command("import <file>")
222
+ .description("Import a normalized historical transcript JSON file")
223
+ .action(async (file) => {
224
+ try {
225
+ const companySlug = meetings.opts().company;
226
+ if (!companySlug) {
227
+ throw new Error("--company <slug> is required for historical imports");
228
+ }
229
+ const bytes = await readFile(file);
230
+ if (bytes.byteLength > 5 * 1024 * 1024) {
231
+ throw new Error("Historical meeting import exceeds 5 MiB");
232
+ }
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(bytes.toString("utf8"));
236
+ }
237
+ catch {
238
+ throw new Error("Historical meeting import file must contain valid JSON");
239
+ }
240
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
241
+ throw new Error("Historical meeting import file must contain a JSON object");
242
+ }
243
+ const token = await ensureCognitoToken();
244
+ const companyId = await getCompanyUid(token, companySlug);
245
+ const recorderPersonUid = await resolveCallerPersonUid(token);
246
+ const res = await vaultApiFetch({
247
+ token,
248
+ method: "POST",
249
+ path: "/v1/meetings/import",
250
+ body: {
251
+ ...parsed,
252
+ companyId,
253
+ recorderPersonUid,
254
+ },
255
+ });
256
+ if (!res.ok)
257
+ await handleApiError(res, meetings.opts().json);
258
+ const result = (await res.json());
259
+ if (meetings.opts().json) {
260
+ console.log(JSON.stringify(result, null, 2));
261
+ return;
262
+ }
263
+ console.log(chalk.green(`\n✓ Historical meeting ${result.outcome}: ${result.meetingId}`));
264
+ if (result.state)
265
+ console.log(chalk.dim(` State: ${result.state}`));
266
+ console.log();
267
+ }
268
+ catch (err) {
269
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
270
+ process.exit(1);
271
+ }
272
+ });
219
273
  meetings
220
274
  .command("invite <meetingUrl>")
221
275
  .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
@@ -18,7 +18,7 @@ import { CLI_KIND_TO_SCHEMA, EnqueueValidationError, enqueueSessionEvent, } from
18
18
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
19
19
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
20
20
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
21
- import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, detectPlatform, formatDaemonDoctor, installDaemonService, runMeshDaemon, uninstallDaemonService, } from "../lib/mesh/live/daemon/index.js";
21
+ import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
22
22
  import { workContextRoot } from "../lib/work-context/paths.js";
23
23
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
24
24
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
@@ -873,6 +873,8 @@ async function runDaemonStatus(opts) {
873
873
  platform: detectPlatform(),
874
874
  paths: buildInstallPaths({}),
875
875
  });
876
+ const state = readDaemonState(daemonDir());
877
+ const presenceRefusal = state?.presenceRefusal ?? null;
876
878
  if (opts.json) {
877
879
  console.log(JSON.stringify({
878
880
  ok: true,
@@ -881,10 +883,18 @@ async function runDaemonStatus(opts) {
881
883
  running: result.running ?? false,
882
884
  dest: result.dest,
883
885
  message: result.message,
886
+ mqttState: state?.mqttState,
887
+ presenceRefusal,
884
888
  }, null, 2));
885
889
  return;
886
890
  }
887
891
  console.log(result.message);
892
+ if (presenceRefusal) {
893
+ console.log(`presence refusal: ${presenceRefusal.code} (HTTP ${presenceRefusal.status}); next retry at ${presenceRefusal.nextRetryAt}`);
894
+ }
895
+ else if (state?.mqttState) {
896
+ console.log(`mqtt: ${state.mqttState}`);
897
+ }
888
898
  }
889
899
  async function runDaemonDoctorCmd(opts) {
890
900
  const report = collectDaemonDoctor();
@@ -8,6 +8,43 @@ export declare const MQTT_KEEPALIVE_SECONDS = 15;
8
8
  /** Renew at 80% of credential lifetime. */
9
9
  export declare const CREDENTIAL_RENEWAL_FRACTION = 0.8;
10
10
  export declare const MIN_RENEWAL_DELAY_MS = 5000;
11
+ /** Long retry when the server refuses credentials (disabled / unsupported). */
12
+ export declare const DEFAULT_REFUSED_RETRY_MS: number;
13
+ export declare const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
14
+ /**
15
+ * Node's setTimeout treats delays above this as ~1ms (signed 32-bit overflow).
16
+ * Always schedule long waits via {@link scheduleBoundedTimeout}.
17
+ */
18
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
19
+ /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
20
+ export declare const MAX_RETRY_DELAY_MS: number;
21
+ export type CredentialVendFailureKind = "refused" | "transient";
22
+ /**
23
+ * Typed vend failure. `refused` = server will not vend right now
24
+ * (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
25
+ * wait a long interval instead of the short network backoff.
26
+ */
27
+ export declare class CredentialVendError extends Error {
28
+ readonly status: number;
29
+ readonly code: string | undefined;
30
+ readonly kind: CredentialVendFailureKind;
31
+ readonly retryAfterMs: number | undefined;
32
+ constructor(input: {
33
+ status: number;
34
+ code?: string;
35
+ kind: CredentialVendFailureKind;
36
+ retryAfterMs?: number;
37
+ message?: string;
38
+ });
39
+ }
40
+ export declare function parseRetryAfterMs(value: string | null | undefined, nowMs?: number): number | undefined;
41
+ export declare function extractVendErrorCode(body: unknown): string | undefined;
42
+ /** True for server "not now" responses that should use the long retry. */
43
+ export declare function isCredentialVendRefused(status: number, code: string | undefined): boolean;
44
+ export declare function classifyCredentialVendFailure(status: number, body: unknown, retryAfterHeader?: string | null, nowMs?: number): CredentialVendError;
45
+ export declare function defaultRefusedRetryMs(env?: NodeJS.ProcessEnv): number;
46
+ /** ±20% jitter around the refused-retry base interval. */
47
+ export declare function refusedRetryDelayMs(baseMs: number, random: () => number): number;
11
48
  export interface PresenceCompany {
12
49
  companyUid: string;
13
50
  presenceTopic: string;
@@ -31,6 +68,22 @@ export interface TimerHost {
31
68
  now(): number;
32
69
  }
33
70
  export declare const realTimerHost: TimerHost;
71
+ /** Clamp a retry delay to [0, MAX_RETRY_DELAY_MS]. */
72
+ export declare function clampRetryDelayMs(delayMs: number): number;
73
+ export interface BoundedTimeoutHandle {
74
+ clear(): void;
75
+ /** Absolute epoch-ms deadline this schedule is aiming for. */
76
+ deadlineMs: number;
77
+ }
78
+ /**
79
+ * Schedule `fn` after `delayMs`, chaining timers in chunks of at most
80
+ * `maxChunkMs` so the absolute deadline is preserved under Node's timeout cap.
81
+ *
82
+ * Remaining time is tracked by subtracting each armed chunk (so frozen test
83
+ * clocks still advance the chain) and also clamped to the wall-clock deadline
84
+ * when `timers.now()` moves forward.
85
+ */
86
+ export declare function scheduleBoundedTimeout(timers: TimerHost, fn: () => void, delayMs: number, maxChunkMs?: number): BoundedTimeoutHandle;
34
87
  export declare function renewalDelayMs(nowMs: number, expirationIso: string): number;
35
88
  export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
36
89
  /**
@@ -40,16 +93,24 @@ export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
40
93
  * No identity file → return the bundle unchanged (person laptop).
41
94
  */
42
95
  export declare function scopeBundleToAgentIdentity(bundle: Contract3Bundle, env?: NodeJS.ProcessEnv): Contract3Bundle;
96
+ export type CredentialVendPostResult = {
97
+ status: number;
98
+ body: unknown;
99
+ /** Optional header map or Fetch Headers; used for Retry-After. */
100
+ headers?: Headers | Record<string, string | undefined | null>;
101
+ };
43
102
  export declare function createContract3Fetcher(opts: {
44
103
  token: string;
45
104
  baseUrl?: string;
46
- post?: (path: string, body: unknown) => Promise<{
47
- status: number;
48
- body: unknown;
49
- }>;
105
+ post?: (path: string, body: unknown) => Promise<CredentialVendPostResult>;
50
106
  /** Process env for identity-file scoping (tests). */
51
107
  env?: NodeJS.ProcessEnv;
52
108
  }): CredentialsFetcher;
109
+ export interface CredentialRenewalErrorInfo {
110
+ /** Absolute ISO timestamp when the renewal retry is scheduled to fire. */
111
+ nextRetryAt: string;
112
+ delayMs: number;
113
+ }
53
114
  /**
54
115
  * Proactive renewal: at 80% of lifetime, fetch fresh creds and call onRenewed.
55
116
  * Does not drop the connection itself — caller reconnects with the new URL.
@@ -60,9 +121,11 @@ export declare class CredentialRenewalManager {
60
121
  private readonly onError;
61
122
  private readonly timers;
62
123
  private readonly retryDelayMs;
124
+ private readonly refusedRetryMs;
125
+ private readonly random;
63
126
  private handle;
64
127
  private stopped;
65
- constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown) => void, timers?: TimerHost, retryDelayMs?: number);
128
+ constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown, info?: CredentialRenewalErrorInfo) => void, timers?: TimerHost, retryDelayMs?: number, refusedRetryMs?: number, random?: () => number);
66
129
  schedule(bundle: Contract3Bundle): void;
67
130
  private renew;
68
131
  stop(): void;
@@ -10,11 +10,146 @@ export const MQTT_KEEPALIVE_SECONDS = 15;
10
10
  /** Renew at 80% of credential lifetime. */
11
11
  export const CREDENTIAL_RENEWAL_FRACTION = 0.8;
12
12
  export const MIN_RENEWAL_DELAY_MS = 5_000;
13
+ /** Long retry when the server refuses credentials (disabled / unsupported). */
14
+ export const DEFAULT_REFUSED_RETRY_MS = 10 * 60_000;
15
+ export const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
16
+ /**
17
+ * Node's setTimeout treats delays above this as ~1ms (signed 32-bit overflow).
18
+ * Always schedule long waits via {@link scheduleBoundedTimeout}.
19
+ */
20
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
21
+ /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
22
+ export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
23
+ /**
24
+ * Typed vend failure. `refused` = server will not vend right now
25
+ * (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
26
+ * wait a long interval instead of the short network backoff.
27
+ */
28
+ export class CredentialVendError extends Error {
29
+ status;
30
+ code;
31
+ kind;
32
+ retryAfterMs;
33
+ constructor(input) {
34
+ const codePart = input.code ? ` ${input.code}` : "";
35
+ super(input.message ?? `credential vend failed: HTTP ${input.status}${codePart}`);
36
+ this.name = "CredentialVendError";
37
+ this.status = input.status;
38
+ this.code = input.code;
39
+ this.kind = input.kind;
40
+ this.retryAfterMs = input.retryAfterMs;
41
+ }
42
+ }
43
+ export function parseRetryAfterMs(value, nowMs = Date.now()) {
44
+ if (!value)
45
+ return undefined;
46
+ const trimmed = value.trim();
47
+ if (!trimmed)
48
+ return undefined;
49
+ const seconds = Number(trimmed);
50
+ if (Number.isFinite(seconds) && seconds >= 0) {
51
+ return Math.round(seconds * 1000);
52
+ }
53
+ const dateMs = Date.parse(trimmed);
54
+ if (!Number.isNaN(dateMs)) {
55
+ return Math.max(0, dateMs - nowMs);
56
+ }
57
+ return undefined;
58
+ }
59
+ export function extractVendErrorCode(body) {
60
+ if (!body || typeof body !== "object" || Array.isArray(body))
61
+ return undefined;
62
+ const code = body.code;
63
+ return typeof code === "string" && code.trim() ? code.trim() : undefined;
64
+ }
65
+ /** True for server "not now" responses that should use the long retry. */
66
+ export function isCredentialVendRefused(status, code) {
67
+ if (status === 403)
68
+ return true;
69
+ if (status === 404 && code === "FEATURE_DISABLED")
70
+ return true;
71
+ if (status === 409 && code === "REALTIME_CONTRACT_UNSUPPORTED")
72
+ return true;
73
+ return false;
74
+ }
75
+ export function classifyCredentialVendFailure(status, body, retryAfterHeader, nowMs) {
76
+ const code = extractVendErrorCode(body);
77
+ const retryAfterMs = parseRetryAfterMs(retryAfterHeader, nowMs);
78
+ const kind = isCredentialVendRefused(status, code)
79
+ ? "refused"
80
+ : "transient";
81
+ return new CredentialVendError({ status, code, kind, retryAfterMs });
82
+ }
83
+ export function defaultRefusedRetryMs(env = process.env) {
84
+ const raw = env[REFUSED_RETRY_ENV]?.trim();
85
+ if (raw) {
86
+ const n = Number(raw);
87
+ if (Number.isFinite(n) && n >= 0)
88
+ return n;
89
+ }
90
+ return DEFAULT_REFUSED_RETRY_MS;
91
+ }
92
+ /** ±20% jitter around the refused-retry base interval. */
93
+ export function refusedRetryDelayMs(baseMs, random) {
94
+ const unit = Math.max(0, baseMs);
95
+ return Math.round(unit * (0.8 + random() * 0.4));
96
+ }
13
97
  export const realTimerHost = {
14
98
  setTimeout: (fn, ms) => setTimeout(fn, ms),
15
99
  clearTimeout: (h) => clearTimeout(h),
16
100
  now: () => Date.now(),
17
101
  };
102
+ /** Clamp a retry delay to [0, MAX_RETRY_DELAY_MS]. */
103
+ export function clampRetryDelayMs(delayMs) {
104
+ if (!Number.isFinite(delayMs) || delayMs <= 0)
105
+ return 0;
106
+ return Math.min(Math.floor(delayMs), MAX_RETRY_DELAY_MS);
107
+ }
108
+ /**
109
+ * Schedule `fn` after `delayMs`, chaining timers in chunks of at most
110
+ * `maxChunkMs` so the absolute deadline is preserved under Node's timeout cap.
111
+ *
112
+ * Remaining time is tracked by subtracting each armed chunk (so frozen test
113
+ * clocks still advance the chain) and also clamped to the wall-clock deadline
114
+ * when `timers.now()` moves forward.
115
+ */
116
+ export function scheduleBoundedTimeout(timers, fn, delayMs, maxChunkMs = MAX_TIMER_DELAY_MS) {
117
+ let handle = null;
118
+ let cancelled = false;
119
+ const safeDelay = Math.max(0, Number.isFinite(delayMs) ? delayMs : 0);
120
+ const chunkLimit = Math.max(1, Math.min(maxChunkMs, MAX_TIMER_DELAY_MS));
121
+ const deadlineMs = timers.now() + safeDelay;
122
+ let remainingMs = safeDelay;
123
+ const arm = () => {
124
+ const chunk = Math.min(remainingMs, chunkLimit);
125
+ handle = timers.setTimeout(() => {
126
+ handle = null;
127
+ if (cancelled)
128
+ return;
129
+ remainingMs -= chunk;
130
+ const byClock = deadlineMs - timers.now();
131
+ if (byClock < remainingMs) {
132
+ remainingMs = Math.max(0, byClock);
133
+ }
134
+ if (remainingMs <= 0) {
135
+ fn();
136
+ return;
137
+ }
138
+ arm();
139
+ }, chunk);
140
+ };
141
+ arm();
142
+ return {
143
+ deadlineMs,
144
+ clear() {
145
+ cancelled = true;
146
+ if (handle !== null) {
147
+ timers.clearTimeout(handle);
148
+ handle = null;
149
+ }
150
+ },
151
+ };
152
+ }
18
153
  export function renewalDelayMs(nowMs, expirationIso) {
19
154
  const expMs = Date.parse(expirationIso);
20
155
  if (Number.isNaN(expMs))
@@ -131,6 +266,16 @@ export function scopeBundleToAgentIdentity(bundle, env = process.env) {
131
266
  }
132
267
  return { ...bundle, companies: matched };
133
268
  }
269
+ function headerGet(headers, name) {
270
+ if (!headers)
271
+ return null;
272
+ if (typeof headers.get === "function") {
273
+ return headers.get(name);
274
+ }
275
+ const rec = headers;
276
+ const direct = rec[name] ?? rec[name.toLowerCase()];
277
+ return direct == null ? null : String(direct);
278
+ }
134
279
  export function createContract3Fetcher(opts) {
135
280
  return async () => {
136
281
  let bundle;
@@ -139,7 +284,7 @@ export function createContract3Fetcher(opts) {
139
284
  contractVersion: 3,
140
285
  });
141
286
  if (res.status < 200 || res.status >= 300) {
142
- throw new Error(`credential vend failed: HTTP ${res.status}`);
287
+ throw classifyCredentialVendFailure(res.status, res.body, headerGet(res.headers, "Retry-After"));
143
288
  }
144
289
  bundle = normalizeContract3Bundle(res.body);
145
290
  }
@@ -162,7 +307,7 @@ export function createContract3Fetcher(opts) {
162
307
  }
163
308
  }
164
309
  if (!res.ok) {
165
- throw new Error(`credential vend failed: HTTP ${res.status}`);
310
+ throw classifyCredentialVendFailure(res.status, body, res.headers.get("Retry-After"));
166
311
  }
167
312
  bundle = normalizeContract3Bundle(body);
168
313
  }
@@ -179,21 +324,25 @@ export class CredentialRenewalManager {
179
324
  onError;
180
325
  timers;
181
326
  retryDelayMs;
327
+ refusedRetryMs;
328
+ random;
182
329
  handle = null;
183
330
  stopped = false;
184
- constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000) {
331
+ constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000, refusedRetryMs = DEFAULT_REFUSED_RETRY_MS, random = Math.random) {
185
332
  this.fetchCredentials = fetchCredentials;
186
333
  this.onRenewed = onRenewed;
187
334
  this.onError = onError;
188
335
  this.timers = timers;
189
336
  this.retryDelayMs = retryDelayMs;
337
+ this.refusedRetryMs = refusedRetryMs;
338
+ this.random = random;
190
339
  }
191
340
  schedule(bundle) {
192
341
  if (this.stopped)
193
342
  return;
194
343
  this.clear();
195
344
  const delay = renewalDelayMs(this.timers.now(), bundle.expiresAt);
196
- this.handle = this.timers.setTimeout(() => void this.renew(), delay);
345
+ this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
197
346
  }
198
347
  async renew() {
199
348
  if (this.stopped)
@@ -206,11 +355,23 @@ export class CredentialRenewalManager {
206
355
  this.schedule(bundle);
207
356
  }
208
357
  catch (err) {
209
- this.onError(err);
210
358
  if (this.stopped)
211
359
  return;
212
360
  this.clear();
213
- this.handle = this.timers.setTimeout(() => void this.renew(), this.retryDelayMs);
361
+ let delay = this.retryDelayMs;
362
+ if (err instanceof CredentialVendError && err.kind === "refused") {
363
+ delay =
364
+ err.retryAfterMs !== undefined
365
+ ? Math.max(0, err.retryAfterMs)
366
+ : refusedRetryDelayMs(this.refusedRetryMs, this.random);
367
+ delay = clampRetryDelayMs(delay);
368
+ }
369
+ const nextRetryAt = new Date(this.timers.now() + delay).toISOString();
370
+ // Notify with the real deadline before arming so noteRefusal records it.
371
+ this.onError(err, { nextRetryAt, delayMs: delay });
372
+ if (this.stopped)
373
+ return;
374
+ this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
214
375
  }
215
376
  }
216
377
  stop() {
@@ -219,7 +380,7 @@ export class CredentialRenewalManager {
219
380
  }
220
381
  clear() {
221
382
  if (this.handle !== null) {
222
- this.timers.clearTimeout(this.handle);
383
+ this.handle.clear();
223
384
  this.handle = null;
224
385
  }
225
386
  }
@@ -23,6 +23,8 @@ export interface DaemonDoctorReport {
23
23
  tokenSource: CognitoTokenSource;
24
24
  /** human | agent | unknown — from token claims / machine signal. */
25
25
  actorKind: CognitoActorKind;
26
+ /** Credential vend refused (disabled / unsupported / 403). */
27
+ presenceRefusal?: DaemonStateFile["presenceRefusal"];
26
28
  }
27
29
  export interface DaemonDoctorDeps {
28
30
  home?: string;
@@ -91,6 +91,10 @@ export function collectDaemonDoctor(deps = {}) {
91
91
  if (deadLetterCount > 0) {
92
92
  unhealthyReasons.push(`dead-letter count=${deadLetterCount}`);
93
93
  }
94
+ const presenceRefusal = state?.presenceRefusal ?? null;
95
+ if (presenceRefusal) {
96
+ unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
97
+ }
94
98
  const auth = describeCognitoTokenSource({ home, env });
95
99
  return {
96
100
  running,
@@ -108,6 +112,7 @@ export function collectDaemonDoctor(deps = {}) {
108
112
  oldestSpoolAgeMs,
109
113
  tokenSource: auth.tokenSource,
110
114
  actorKind: auth.actorKind,
115
+ presenceRefusal,
111
116
  };
112
117
  }
113
118
  export function formatDaemonDoctor(report) {
@@ -125,6 +130,9 @@ export function formatDaemonDoctor(report) {
125
130
  ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
126
131
  : ""}`,
127
132
  ];
133
+ if (report.presenceRefusal) {
134
+ lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
135
+ }
128
136
  if (report.unhealthy) {
129
137
  lines.push(`health: UNHEALTHY`);
130
138
  for (const reason of report.unhealthyReasons) {
@@ -1,14 +1,14 @@
1
1
  export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
2
2
  export { acquirePidLock, defaultPidLockDeps, parsePidLock, pidLockStatus, readPidLock, releasePidLock, resolveDaemonDir, } from "./pid-lock.js";
3
3
  export type { PidLockDeps, PidLockRecord, PidLockResult } from "./pid-lock.js";
4
- export { CREDENTIAL_RENEWAL_FRACTION, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, CredentialRenewalManager, createContract3Fetcher, normalizeContract3Bundle, realTimerHost, renewalDelayMs, } from "./credentials.js";
5
- export type { Contract3Bundle, CredentialsFetcher, PresenceCompany, TimerHost, } from "./credentials.js";
4
+ export { CREDENTIAL_RENEWAL_FRACTION, DEFAULT_REFUSED_RETRY_MS, MAX_RETRY_DELAY_MS, MAX_TIMER_DELAY_MS, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, REFUSED_RETRY_ENV, CredentialRenewalManager, CredentialVendError, classifyCredentialVendFailure, clampRetryDelayMs, createContract3Fetcher, defaultRefusedRetryMs, extractVendErrorCode, isCredentialVendRefused, normalizeContract3Bundle, parseRetryAfterMs, realTimerHost, refusedRetryDelayMs, renewalDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
5
+ export type { BoundedTimeoutHandle, Contract3Bundle, CredentialRenewalErrorInfo, CredentialVendFailureKind, CredentialVendPostResult, CredentialsFetcher, PresenceCompany, TimerHost, } from "./credentials.js";
6
6
  export { amzDateOf, hex, presignIotWssUrl, rfc3986Encode } from "./presign.js";
7
7
  export type { IotCredentials } from "./presign.js";
8
8
  export { PresenceClient, backoffDelayMs, buildPresencePayload, defaultMqttConnect, isOwnPresenceTopic, } from "./presence.js";
9
- export type { MeshMqttClientLike, MqttConnectFn, MqttConnectionState, PresenceClientOptions, PresencePayload, } from "./presence.js";
9
+ export type { MeshMqttClientLike, MqttConnectFn, MqttConnectionState, PresenceClientOptions, PresencePayload, PresenceRefusal, } from "./presence.js";
10
10
  export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
11
- export type { DaemonStateFile } from "./state.js";
11
+ export type { DaemonStateFile, PresenceRefusalState } from "./state.js";
12
12
  export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
13
13
  export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
14
14
  export type { BoardReader, BoardRefreshDeps, BoardStorySnapshot } from "./board-refresh.js";
@@ -1,6 +1,6 @@
1
1
  export { DAEMON_DIRNAME, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_NAME, DAEMON_PID_NAME, DAEMON_STATE_NAME, LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, daemonPidPath, daemonStatePath, sessionBoardPath, } from "./paths.js";
2
2
  export { acquirePidLock, defaultPidLockDeps, parsePidLock, pidLockStatus, readPidLock, releasePidLock, resolveDaemonDir, } from "./pid-lock.js";
3
- export { CREDENTIAL_RENEWAL_FRACTION, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, CredentialRenewalManager, createContract3Fetcher, normalizeContract3Bundle, realTimerHost, renewalDelayMs, } from "./credentials.js";
3
+ export { CREDENTIAL_RENEWAL_FRACTION, DEFAULT_REFUSED_RETRY_MS, MAX_RETRY_DELAY_MS, MAX_TIMER_DELAY_MS, MQTT_KEEPALIVE_SECONDS, REALTIME_CREDENTIALS_PATH, REFUSED_RETRY_ENV, CredentialRenewalManager, CredentialVendError, classifyCredentialVendFailure, clampRetryDelayMs, createContract3Fetcher, defaultRefusedRetryMs, extractVendErrorCode, isCredentialVendRefused, normalizeContract3Bundle, parseRetryAfterMs, realTimerHost, refusedRetryDelayMs, renewalDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
4
4
  export { amzDateOf, hex, presignIotWssUrl, rfc3986Encode } from "./presign.js";
5
5
  export { PresenceClient, backoffDelayMs, buildPresencePayload, defaultMqttConnect, isOwnPresenceTopic, } from "./presence.js";
6
6
  export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
@@ -6,7 +6,8 @@
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 { type IClientOptions, type MqttClient } from "mqtt";
12
13
  import { type Contract3Bundle, type CredentialsFetcher, type TimerHost } from "./credentials.js";
@@ -30,6 +31,11 @@ export interface MeshMqttClientLike {
30
31
  subscribe?: unknown;
31
32
  }
32
33
  export type MqttConnectFn = (url: string, opts: IClientOptions) => MeshMqttClientLike;
34
+ export interface PresenceRefusal {
35
+ code: string;
36
+ status: number;
37
+ nextRetryAt: string;
38
+ }
33
39
  export interface PresenceClientOptions {
34
40
  fetchCredentials: CredentialsFetcher;
35
41
  mqttConnect?: MqttConnectFn;
@@ -37,14 +43,22 @@ export interface PresenceClientOptions {
37
43
  random?: () => number;
38
44
  baseBackoffMs?: number;
39
45
  maxBackoffMs?: number;
46
+ /** Base refused-retry interval (tests / HQ_MESH_DAEMON_REFUSED_RETRY_MS). */
47
+ refusedRetryMs?: number;
40
48
  /** Invoked when online is (re)published after connect. */
41
49
  onOnline?: (companies: string[]) => void;
42
50
  onState?: (state: MqttConnectionState) => void;
43
51
  onError?: (err: unknown) => void;
52
+ /** Fired when refusal appears, code changes, clears, or nextRetryAt updates. */
53
+ onRefusal?: (refusal: PresenceRefusal | null) => void;
44
54
  now?: () => Date;
55
+ env?: NodeJS.ProcessEnv;
45
56
  }
46
- /** Full-jitter capped exponential backoff (1s base → 60s cap by default). */
47
- export declare function backoffDelayMs(attempt: number, baseMs: number, maxMs: number, random: () => number): number;
57
+ /**
58
+ * Full-jitter capped exponential backoff (1s base 60s cap by default),
59
+ * floored at `previousMs` so consecutive failures never shrink the delay.
60
+ */
61
+ export declare function backoffDelayMs(attempt: number, baseMs: number, maxMs: number, random: () => number, previousMs?: number): number;
48
62
  export declare function buildPresencePayload(input: {
49
63
  status: "online" | "offline";
50
64
  actorUid: string;
@@ -62,20 +76,26 @@ export declare class PresenceClient {
62
76
  private bundle;
63
77
  private state;
64
78
  private attempt;
79
+ private lastBackoffMs;
65
80
  private reconnectHandle;
66
81
  private stopped;
67
82
  private generation;
68
83
  private publishedTopics;
84
+ /** Last refusal code we logged via onError (episode dedupe). */
85
+ private loggedRefusalKey;
86
+ private refusal;
69
87
  private readonly mqttConnect;
70
88
  private readonly timers;
71
89
  private readonly random;
72
90
  private readonly baseBackoffMs;
73
91
  private readonly maxBackoffMs;
92
+ private readonly refusedRetryMs;
74
93
  private readonly renewal;
75
94
  private readonly now;
76
95
  constructor(options: PresenceClientOptions);
77
96
  getConnectionState(): MqttConnectionState;
78
97
  getBundle(): Contract3Bundle | null;
98
+ getRefusal(): PresenceRefusal | null;
79
99
  /** Topics this client has published to (for doctor / tests). */
80
100
  getPublishedTopics(): readonly string[];
81
101
  private setState;
@@ -90,6 +110,10 @@ export declare class PresenceClient {
90
110
  reconnectNow(): void;
91
111
  private teardownClient;
92
112
  private onCredentialsRenewed;
113
+ private handleRenewalError;
114
+ private refusalKey;
115
+ private noteRefusal;
116
+ private clearRefusal;
93
117
  private connectOnce;
94
118
  /** Publish retained online to every own presence topic (connect / renew). */
95
119
  private publishAll;
@@ -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;
@@ -345,7 +345,23 @@ export function buildCliHeartbeat(input) {
345
345
  syncState = "error";
346
346
  break;
347
347
  default:
348
- syncState = lastSyncSuccessAt ? "idle" : "never_synced";
348
+ // An UNRESOLVED failure streak is a live error state, not idleness.
349
+ // Reporting `idle` here is what put "BROKEN — Runner failed" next to
350
+ // "Sync state: idle" on the support view: every later `hq` invocation
351
+ // overwrote the `error` state the failing sync had reported while
352
+ // leaving the streak that earned it untouched.
353
+ //
354
+ // Only this CLI's own `sync_success` clears the streak. The journal
355
+ // `lastSync` folded into `lastSyncSuccessAt` below deliberately does
356
+ // NOT: the engine stamps it per FILE update (hq-cloud journal.ts
357
+ // `updateEntry`), and a push stamps it before throwing its upload worker
358
+ // errors — so it means "some file moved", not "a run succeeded", and
359
+ // must never clear an alarm counter. That is why this branch keys off
360
+ // the streak rather than off the success timestamp.
361
+ if (input.state.consecutiveFailures > 0)
362
+ syncState = "error";
363
+ else
364
+ syncState = lastSyncSuccessAt ? "idle" : "never_synced";
349
365
  break;
350
366
  }
351
367
  const heartbeat = {
@@ -71,6 +71,15 @@ export type SelfUpdateAction =
71
71
  | "update-failed"
72
72
  /** Updated, but the re-exec couldn't start; continue on the current (in-memory) version. */
73
73
  | "updated-no-reexec"
74
+ /**
75
+ * The install reported success but the `hq` on PATH still resolves the old
76
+ * version (a shadowing/ghost install, a prefix/PATH mismatch, or a stale
77
+ * tarball). Re-installing the same target can never converge, so we do NOT
78
+ * re-exec, we record the target as ineffective so the startup path stops
79
+ * auto-retrying it, and the command runs on the current version. This is the
80
+ * guard against the self-update loop.
81
+ */
82
+ | "update-ineffective"
74
83
  /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
75
84
  | "reexec"
76
85
  /**
@@ -127,6 +136,17 @@ export interface SelfUpdateDeps {
127
136
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
128
137
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
129
138
  acquireLock?: () => (() => void) | null;
139
+ /**
140
+ * Read-your-writes convergence check run after a "successful" install:
141
+ * returns false only when the `hq` on PATH still resolves a build older than
142
+ * the target we just installed. Defaults to {@link checkUpdateConvergence}.
143
+ */
144
+ checkConvergence?: (targetVersion: string) => boolean;
145
+ /**
146
+ * Persist that `version` installed but never took effect, so the startup path
147
+ * stops auto-retrying it. Defaults to {@link markLatestIneffective}.
148
+ */
149
+ markIneffective?: (version: string) => void;
130
150
  /**
131
151
  * Whether a human is watching this invocation. Defaults to "stderr is a TTY",
132
152
  * which is false for exactly the callers that must not replace the CLI
@@ -60,8 +60,9 @@ import { spawnSync } from "node:child_process";
60
60
  import semver from "semver";
61
61
  import chalk from "chalk";
62
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
63
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
+ import { markLatestIneffective } from "./version-check.js";
65
66
  /**
66
67
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
67
68
  * One update + one re-exec per user invocation, ever.
@@ -278,6 +279,26 @@ async function updateAndReexec(argv, flavor, known, deps) {
278
279
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
279
280
  return { action: "update-failed", latest };
280
281
  }
282
+ // Read-your-writes: a "successful" `npm install -g` proves only that the
283
+ // prefix was rewritten, NOT that the `hq` this user's PATH resolves is the
284
+ // copy we just wrote. When a ghost/shadowing install keeps winning PATH
285
+ // resolution (or a prefix/PATH mismatch or stale tarball leaves the running
286
+ // copy behind), re-exec'ing here runs the command on the SAME stale version,
287
+ // and — because `staleAgainstCachedLatest` still sees the baked CLI_VERSION
288
+ // as behind `latest` — the next invocation installs and re-execs again,
289
+ // forever. The hard version gate already verifies convergence; the soft
290
+ // startup path did not, which is the 5.105.0 → 5.105.1 self-update loop.
291
+ //
292
+ // So verify before announcing success. On non-convergence: mark the target
293
+ // ineffective (suppresses auto-retry for a cooldown), skip the pointless
294
+ // re-exec into a stale copy, and let the command run on the current version.
295
+ const checkConvergence = deps.checkConvergence ?? checkUpdateConvergence;
296
+ const converged = checkConvergence(latestValid);
297
+ if (converged === false) {
298
+ (deps.markIneffective ?? markLatestIneffective)(latest);
299
+ console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
300
+ return { action: "update-ineffective", latest };
301
+ }
281
302
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
282
303
  const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
283
304
  if (status === null) {
@@ -10,6 +10,13 @@ declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boo
10
10
  * command, and warns only if that fails.
11
11
  */
12
12
  export declare function staleAgainstCachedLatest(now?: number): string | null;
13
+ /**
14
+ * Record that installing `version` did not move the on-PATH `hq` forward, so
15
+ * {@link staleAgainstCachedLatest} stops auto-retrying it for a cooldown. Called
16
+ * by the self-updater's convergence check. Best-effort: a write failure only
17
+ * means the loop guard is skipped this once, never a broken CLI.
18
+ */
19
+ export declare function markLatestIneffective(version: string, now?: number): void;
13
20
  export declare function refreshVersionCache(): Promise<void>;
14
21
  export declare const __test__: {
15
22
  CACHE_TTL_MS: number;
@@ -9,6 +9,12 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — catch fresh releases within the h
9
9
  const CACHE_TTL_JITTER_MS = 5 * 60 * 1000; // up to 5m early, to spread a fleet's refreshes off a single instant
10
10
  const FETCH_TIMEOUT_MS = 3_000;
11
11
  const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
12
+ /**
13
+ * How long to stop auto-retrying an `ineffectiveLatest` target. Long enough to
14
+ * break the per-invocation loop on a busy agent box, short enough that a box
15
+ * whose PATH is later fixed recovers on its own without a manual `hq rescue`.
16
+ */
17
+ const INEFFECTIVE_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
12
18
  function cachePath() {
13
19
  return path.join(os.homedir(), ".hq", "version-check.json");
14
20
  }
@@ -26,7 +32,16 @@ function readCache() {
26
32
  typeof parsed.fetchedAt !== "number") {
27
33
  return null;
28
34
  }
29
- return { latest: parsed.latest, fetchedAt: parsed.fetchedAt };
35
+ return {
36
+ latest: parsed.latest,
37
+ fetchedAt: parsed.fetchedAt,
38
+ ...(typeof parsed.ineffectiveLatest === "string"
39
+ ? { ineffectiveLatest: parsed.ineffectiveLatest }
40
+ : {}),
41
+ ...(typeof parsed.ineffectiveAt === "number"
42
+ ? { ineffectiveAt: parsed.ineffectiveAt }
43
+ : {}),
44
+ };
30
45
  }
31
46
  catch {
32
47
  return null;
@@ -119,8 +134,38 @@ export function staleAgainstCachedLatest(now = Date.now()) {
119
134
  return null;
120
135
  if (!semver.gt(latest, current))
121
136
  return null;
137
+ // Loop guard: a previous self-update installed this exact `latest` but the
138
+ // on-PATH `hq` never converged to it (see markLatestIneffective). Retrying
139
+ // the same target re-installs it and re-execs into the same stale copy on
140
+ // every invocation without ever making progress, so suppress it for a
141
+ // cooldown. A newer `latest` (different target) is never suppressed.
142
+ if (entry.ineffectiveLatest === entry.latest &&
143
+ typeof entry.ineffectiveAt === "number" &&
144
+ now - entry.ineffectiveAt <= INEFFECTIVE_COOLDOWN_MS) {
145
+ return null;
146
+ }
122
147
  return entry.latest;
123
148
  }
149
+ /**
150
+ * Record that installing `version` did not move the on-PATH `hq` forward, so
151
+ * {@link staleAgainstCachedLatest} stops auto-retrying it for a cooldown. Called
152
+ * by the self-updater's convergence check. Best-effort: a write failure only
153
+ * means the loop guard is skipped this once, never a broken CLI.
154
+ */
155
+ export function markLatestIneffective(version, now = Date.now()) {
156
+ const entry = readCache();
157
+ if (!entry)
158
+ return;
159
+ // Only mark the target we actually believe is `latest`; marking anything else
160
+ // would risk suppressing a legitimately newer release.
161
+ if (entry.latest !== version)
162
+ return;
163
+ writeCache({
164
+ ...entry,
165
+ ineffectiveLatest: version,
166
+ ineffectiveAt: now,
167
+ });
168
+ }
124
169
  export async function refreshVersionCache() {
125
170
  if (isOptedOut())
126
171
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.3",
3
+ "version": "5.108.5",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {