@indigoai-us/hq-cli 5.223.0 → 5.225.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.
@@ -15,6 +15,7 @@ import { Option } from "commander";
15
15
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
16
16
  import { registerLaneChecksCommand } from "./lanes-checks.js";
17
17
  import { registerLaneReportCommands } from "./lanes-report.js";
18
+ import { registerLaneTrackerImportCommand } from "./lanes-import.js";
18
19
  import { ensureCognitoToken } from "../utils/cognito-session.js";
19
20
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
20
21
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
@@ -8611,6 +8612,13 @@ export function registerLanesCommand(parent, deps = {}) {
8611
8612
  now: ctx.now,
8612
8613
  actor: ctx.trackerActor,
8613
8614
  }, wrap);
8615
+ registerLaneTrackerImportCommand(lanes, {
8616
+ hqRoot: ctx.hqRoot,
8617
+ resolveCompany: ctx.resolveCompany,
8618
+ trackerEnabled: ctx.laneTrackerEnabled,
8619
+ trackerMeshEnabled: ctx.laneTrackerMeshEnabled,
8620
+ now: ctx.now,
8621
+ }, wrap);
8614
8622
  registerLaneChecksCommand(lanes, {
8615
8623
  hqRoot: ctx.hqRoot,
8616
8624
  resolveCompany: ctx.resolveCompany,
@@ -0,0 +1,24 @@
1
+ import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ import { type BotConfig } from "./config.js";
3
+ export declare const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
4
+ export declare const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
5
+ export declare const INBOX_POLL_BACKOFF_FLAG_TIMEOUT_MS = 150;
6
+ export declare const INBOX_POLL_BACKOFF_FLAG_CACHE_MS: number;
7
+ type InboxPollBackoffFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
8
+ export interface InboxPollBackoffFlagReaderDependencies {
9
+ env?: NodeJS.ProcessEnv;
10
+ createClient?: (options: FlagClientOptions) => InboxPollBackoffFlagClient;
11
+ getToken?: FlagClientOptions["getToken"];
12
+ fetch?: typeof fetch;
13
+ now?: () => number;
14
+ reportFailure?: (error: unknown) => void;
15
+ }
16
+ export declare function safeErrorClass(error: unknown): string;
17
+ /**
18
+ * Create a per-bot reader. The flag client is opened at most once per three
19
+ * minutes; a missing identity, failed request, or malformed snapshot keeps
20
+ * idle polling at its existing two-second interval.
21
+ */
22
+ export declare function createInboxPollBackoffFlagReader(config: Pick<BotConfig, "kind" | "workerId" | "companies" | "companySlug" | "hqRoot">, dependencies?: InboxPollBackoffFlagReaderDependencies): () => Promise<boolean>;
23
+ export {};
24
+ //# sourceMappingURL=inbox-poll-backoff-flag.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
2
+ import { createFlagClient, } from "@indigoai-us/hq-flags-client";
3
+ import { resolveFlagGate, resolveProcessFlagCompanyContext } from "../flag-registry.js";
4
+ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
5
+ import { effectiveBotCompanies, effectiveBotKind } from "./config.js";
6
+ export const INBOX_POLL_BACKOFF_FLAG = "agents.inbox-poll-backoff-local-cli";
7
+ export const INBOX_POLL_BACKOFF_FLAG_FALLBACK = false;
8
+ export const INBOX_POLL_BACKOFF_FLAG_TIMEOUT_MS = 150;
9
+ export const INBOX_POLL_BACKOFF_FLAG_CACHE_MS = 3 * 60_000;
10
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
11
+ export function safeErrorClass(error) {
12
+ const name = error instanceof Error ? error.name : "UnknownError";
13
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
14
+ }
15
+ function reportFlagFailure(error) {
16
+ process.stderr.write(`HQ local bot inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default off.\n`);
17
+ }
18
+ function companyContexts(config, env) {
19
+ if (effectiveBotKind(config) !== "company")
20
+ return [{}];
21
+ const slugs = effectiveBotCompanies(config);
22
+ const fallbackSlug = config.companySlug?.trim();
23
+ if (slugs.length === 0 && fallbackSlug)
24
+ slugs.push(fallbackSlug);
25
+ if (slugs.length === 0)
26
+ return null;
27
+ const contexts = [];
28
+ for (const slug of slugs) {
29
+ const company = resolveProcessFlagCompanyContext({
30
+ ...env,
31
+ HQ_COMPANY_UID: "",
32
+ HQ_COMPANY_SLUG: slug,
33
+ HQ_ROOT: config.hqRoot,
34
+ });
35
+ if (!company.companyUid || !COMPANY_UID_RE.test(company.companyUid))
36
+ return null;
37
+ contexts.push({ companyUid: company.companyUid, companyIdentifiers: [slug] });
38
+ }
39
+ return contexts;
40
+ }
41
+ /**
42
+ * Create a per-bot reader. The flag client is opened at most once per three
43
+ * minutes; a missing identity, failed request, or malformed snapshot keeps
44
+ * idle polling at its existing two-second interval.
45
+ */
46
+ export function createInboxPollBackoffFlagReader(config, dependencies = {}) {
47
+ const env = dependencies.env ?? process.env;
48
+ const contexts = companyContexts(config, env);
49
+ const endpoint = resolveFlagRegistryEndpoint(undefined, env);
50
+ const now = dependencies.now ?? Date.now;
51
+ const createClient = dependencies.createClient ?? createFlagClient;
52
+ const getToken = dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? "");
53
+ const doFetch = dependencies.fetch ?? globalThis.fetch;
54
+ const report = dependencies.reportFailure ?? reportFlagFailure;
55
+ let cachedValue = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
56
+ let lastReadAt = Number.NEGATIVE_INFINITY;
57
+ let pending = null;
58
+ const readContext = async (context) => {
59
+ let client;
60
+ let failed = false;
61
+ let failureReported = false;
62
+ let enabled = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
63
+ const reportFailureOnce = (error) => {
64
+ failed = true;
65
+ if (failureReported)
66
+ return;
67
+ failureReported = true;
68
+ report(error);
69
+ };
70
+ try {
71
+ client = createClient({
72
+ endpoint,
73
+ ...(context.companyUid ? { companyUid: context.companyUid } : {}),
74
+ ...(context.companyIdentifiers ? { companyIdentifiers: context.companyIdentifiers } : {}),
75
+ env: {},
76
+ getToken,
77
+ ...(doFetch ? { fetch: doFetch } : {}),
78
+ refreshIntervalMs: 0,
79
+ requestTimeoutMs: INBOX_POLL_BACKOFF_FLAG_TIMEOUT_MS,
80
+ onError: reportFailureOnce,
81
+ });
82
+ await client.ready();
83
+ if (!failed) {
84
+ enabled = resolveFlagGate(client, INBOX_POLL_BACKOFF_FLAG, { fallback: INBOX_POLL_BACKOFF_FLAG_FALLBACK }, () => INBOX_POLL_BACKOFF_FLAG_FALLBACK);
85
+ }
86
+ }
87
+ catch (error) {
88
+ reportFailureOnce(error);
89
+ }
90
+ finally {
91
+ try {
92
+ client?.close();
93
+ }
94
+ catch (error) {
95
+ reportFailureOnce(error);
96
+ }
97
+ }
98
+ return failed ? INBOX_POLL_BACKOFF_FLAG_FALLBACK : enabled;
99
+ };
100
+ const read = async () => {
101
+ if (!contexts || contexts.length === 0) {
102
+ report(new Error("local bot inbox poll backoff flag has no resolvable company identity"));
103
+ return INBOX_POLL_BACKOFF_FLAG_FALLBACK;
104
+ }
105
+ const values = await Promise.all(contexts.map(readContext));
106
+ return values.every(Boolean);
107
+ };
108
+ return () => {
109
+ const currentTime = now();
110
+ if (pending)
111
+ return pending;
112
+ if (currentTime - lastReadAt < INBOX_POLL_BACKOFF_FLAG_CACHE_MS) {
113
+ return Promise.resolve(cachedValue);
114
+ }
115
+ lastReadAt = currentTime;
116
+ pending = read()
117
+ .then((value) => {
118
+ cachedValue = value;
119
+ return value;
120
+ })
121
+ .catch((error) => {
122
+ report(error);
123
+ cachedValue = INBOX_POLL_BACKOFF_FLAG_FALLBACK;
124
+ return cachedValue;
125
+ })
126
+ .finally(() => {
127
+ pending = null;
128
+ });
129
+ return pending;
130
+ };
131
+ }
132
+ //# sourceMappingURL=inbox-poll-backoff-flag.js.map
@@ -104,7 +104,7 @@ export declare const UNDELIVERABLE_JOB_REPORT_TEXT = "This scheduled run finishe
104
104
  * instead of waiting, with no explanation, behind two long jobs.
105
105
  */
106
106
  export declare const MAX_PARALLEL_JOB_TURNS = 1;
107
- export type SleepFn = (ms: number) => Promise<void>;
107
+ export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
108
108
  export interface BotRunDeps {
109
109
  dir: string;
110
110
  config: BotConfig;
@@ -118,6 +118,8 @@ export interface BotRunDeps {
118
118
  pid?: number;
119
119
  isProcessAlive?: (pid: number) => boolean;
120
120
  inboxIntervalMs?: number;
121
+ /** Cached, default-off reader for the local inbox polling gate. */
122
+ inboxPollBackoffEnabled?: () => Promise<boolean>;
121
123
  heartbeatIntervalMs?: number;
122
124
  /** Override the rendered system prompt (tests). */
123
125
  systemPromptOverride?: string;
@@ -76,6 +76,7 @@ import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
76
76
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
77
77
  import { CLI_VERSION } from "../../cli-version.js";
78
78
  import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
79
+ import { createInboxPollBackoffFlagReader, safeErrorClass } from "./inbox-poll-backoff-flag.js";
79
80
  export const INBOX_POLL_INTERVAL_MS = 2_000;
80
81
  export const HEARTBEAT_INTERVAL_MS = 30_000;
81
82
  /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
@@ -224,8 +225,20 @@ class FailureWindow {
224
225
  return this.count(nowMs) >= this.limit;
225
226
  }
226
227
  }
227
- function defaultSleep(ms) {
228
- return new Promise((resolve) => setTimeout(resolve, ms));
228
+ function defaultSleep(ms, signal) {
229
+ return new Promise((resolve) => {
230
+ if (signal?.aborted) {
231
+ resolve();
232
+ return;
233
+ }
234
+ const timer = setTimeout(done, ms);
235
+ function done() {
236
+ clearTimeout(timer);
237
+ signal?.removeEventListener("abort", done);
238
+ resolve();
239
+ }
240
+ signal?.addEventListener("abort", done, { once: true });
241
+ });
229
242
  }
230
243
  /**
231
244
  * A body posted as several parts where an early part got through and a later
@@ -262,6 +275,7 @@ export async function runBot(deps) {
262
275
  const { dir, config, api, runtime } = deps;
263
276
  const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
264
277
  const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
278
+ const inboxPollBackoffEnabled = deps.inboxPollBackoffEnabled ?? createInboxPollBackoffFlagReader(config);
265
279
  const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
266
280
  const exit = deps.exit ?? ((code) => process.exit(code));
267
281
  const fallback = () => defaultBotStatus(pid, config.runtime, now);
@@ -312,6 +326,16 @@ export async function runBot(deps) {
312
326
  const cliVersion = deps.cliUpdate?.runningVersion ?? CLI_VERSION;
313
327
  /** Set once an update restart is due: no new turns, then exit for the supervisor. */
314
328
  let cliUpdatePending = null;
329
+ const pollForCliUpdate = () => {
330
+ const update = deps.cliUpdate?.poll(log) ?? null;
331
+ if (!update)
332
+ return false;
333
+ cliUpdatePending = update;
334
+ stopping = true;
335
+ const busy = running.size;
336
+ log("info", `hq-cli updated ${update.from} -> ${update.to}; restarting to load it${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
337
+ return true;
338
+ };
315
339
  // ── Preflight: creds, pid lock, owner verification ─────────────────────────
316
340
  const credsProblem = preflightCreds(dir, config);
317
341
  if (credsProblem) {
@@ -506,6 +530,45 @@ export async function runBot(deps) {
506
530
  })();
507
531
  // ── Inbox loop ─────────────────────────────────────────────────────────────
508
532
  const attempts = new Map();
533
+ const inboxBackoffSteps = [inboxIntervalMs, 10_000, 20_000, 30_000];
534
+ let inboxBackoffIndex = 0;
535
+ let inboxWaitGeneration = 0;
536
+ let activeInboxWait = null;
537
+ const resetInboxBackoff = () => {
538
+ inboxBackoffIndex = 0;
539
+ inboxWaitGeneration += 1;
540
+ activeInboxWait?.abort();
541
+ };
542
+ const waitForInbox = async (delayMs, adaptive) => {
543
+ if (!adaptive) {
544
+ await sleep(delayMs);
545
+ return;
546
+ }
547
+ let remainingMs = delayMs;
548
+ let observedGeneration = inboxWaitGeneration;
549
+ while (remainingMs > 0 && !stopping && state === "running") {
550
+ const controller = new AbortController();
551
+ activeInboxWait = controller;
552
+ const chunkMs = Math.min(inboxIntervalMs, remainingMs);
553
+ await sleep(chunkMs, controller.signal);
554
+ if (activeInboxWait === controller)
555
+ activeInboxWait = null;
556
+ if (stopping || state !== "running")
557
+ break;
558
+ if (hasPromotionHold(dir))
559
+ break;
560
+ if (observedGeneration !== inboxWaitGeneration || controller.signal.aborted) {
561
+ observedGeneration = inboxWaitGeneration;
562
+ remainingMs = inboxIntervalMs;
563
+ continue;
564
+ }
565
+ remainingMs -= chunkMs;
566
+ // Keep the install watcher at its original two-second cadence even when
567
+ // the inbox itself is waiting longer between empty polls.
568
+ if (remainingMs > 0 && pollForCliUpdate())
569
+ break;
570
+ }
571
+ };
509
572
  /** Build the user turn for a room trigger: header + recent transcript + the message. */
510
573
  const buildRoomTurn = async (item, target, text) => {
511
574
  const name = (item.channelName ?? "").trim() || target.channelId;
@@ -1551,6 +1614,7 @@ export async function runBot(deps) {
1551
1614
  log("warn", `inbox item ${item.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
1552
1615
  })
1553
1616
  .finally(() => {
1617
+ resetInboxBackoff();
1554
1618
  busyLanes.delete(lane);
1555
1619
  pending.delete(item.messageId);
1556
1620
  running.delete(task);
@@ -1588,14 +1652,8 @@ export async function runBot(deps) {
1588
1652
  // The install under this process was updated: take nothing new, let the
1589
1653
  // turns already running finish, then exit so the supervisor loads it.
1590
1654
  // Queued items were never started or acked; the next process gets them.
1591
- const update = deps.cliUpdate?.poll(log) ?? null;
1592
- if (update) {
1593
- cliUpdatePending = update;
1594
- stopping = true;
1595
- const busy = running.size;
1596
- log("info", `hq-cli updated ${update.from} -> ${update.to}; restarting to load it${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
1655
+ if (pollForCliUpdate())
1597
1656
  break;
1598
- }
1599
1657
  // A kickoff held for a sign-in retries on the same timer as messages.
1600
1658
  if (busyLanes.size === 0 &&
1601
1659
  (!signInIssue || now().getTime() >= nextSignInRetryAt) &&
@@ -1609,17 +1667,26 @@ export async function runBot(deps) {
1609
1667
  // Completions the cloud has not accepted yet: re-send the report (never
1610
1668
  // the work), alongside the poll rather than in front of it.
1611
1669
  kickCompletionRetries();
1670
+ const useInboxBackoff = await inboxPollBackoffEnabled().catch((error) => {
1671
+ log("warn", `inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default-off`);
1672
+ return false;
1673
+ });
1674
+ let items;
1612
1675
  try {
1613
- const items = await api.pullInbox(config.agentUid);
1676
+ items = await api.pullInbox(config.agentUid);
1614
1677
  inboxFails = 0;
1615
1678
  setStatus({ lastInboxPollAt: now().toISOString(), lastInboxPollOk: true, lastInboxError: undefined });
1616
1679
  // One catch-up per job per wake: when several runs of the same job are
1617
1680
  // pending, the newest runs and the older ones are reported skipped.
1618
1681
  for (const id of planJobRuns(items, handledJobFires).superseded)
1619
1682
  supersededJobRuns.add(id);
1683
+ if (items.length > 0)
1684
+ resetInboxBackoff();
1620
1685
  for (const item of items)
1621
1686
  enqueue(item);
1622
1687
  pump();
1688
+ if (stopping || state !== "running")
1689
+ break;
1623
1690
  }
1624
1691
  catch (err) {
1625
1692
  inboxFails += 1;
@@ -1633,7 +1700,17 @@ export async function runBot(deps) {
1633
1700
  }
1634
1701
  if (stopping || state !== "running")
1635
1702
  break;
1636
- await sleep(inboxIntervalMs);
1703
+ if (!useInboxBackoff) {
1704
+ inboxBackoffIndex = 0;
1705
+ await waitForInbox(inboxIntervalMs, false);
1706
+ continue;
1707
+ }
1708
+ const delayMs = items.length > 0
1709
+ ? inboxIntervalMs
1710
+ : inboxBackoffSteps[Math.min(inboxBackoffIndex, inboxBackoffSteps.length - 1)];
1711
+ if (items.length === 0)
1712
+ inboxBackoffIndex = Math.min(inboxBackoffIndex + 1, inboxBackoffSteps.length - 1);
1713
+ await waitForInbox(delayMs, true);
1637
1714
  }
1638
1715
  // Stop, drain and promotion wait for the turns already running. Queued
1639
1716
  // messages were never started or acked, so the inbox delivers them again.
@@ -1655,6 +1732,7 @@ export async function runBot(deps) {
1655
1732
  return;
1656
1733
  }
1657
1734
  stopping = true;
1735
+ activeInboxWait?.abort();
1658
1736
  // End the turn in progress; its message gets "did not finish", not silence.
1659
1737
  // Promotion instead drains that turn, including its reply and ACK, before
1660
1738
  // the coordinator snapshots memory and starts the cloud consumer.
@@ -14,6 +14,8 @@ export interface HqCloudCopy {
14
14
  }
15
15
  export interface PickedHqCloud extends HqCloudCopy {
16
16
  source: "bundled" | "managed";
17
+ /** Set when a version is pinned but that version is not the installed managed copy. */
18
+ pinnedVersionMissing?: string;
17
19
  }
18
20
  export declare function managedHqCloudDir(daemonRoot: string): string;
19
21
  /** The package folder inside the updater's install prefix. */
@@ -44,8 +44,14 @@ export function pickHqCloud(bundled, managed, usable = () => true, pinnedVersion
44
44
  if (managed && managed.version === pinnedVersion && b && m && m[0] === b[0] && usable(managed)) {
45
45
  return { ...managed, source: "managed" };
46
46
  }
47
- return { ...bundled, source: "bundled" };
47
+ // The pinned version is not installed yet (the updater installs it). Keep
48
+ // running the newest usable copy meanwhile instead of dropping to an older
49
+ // bundled one, and report the missing pin.
50
+ return { ...pickNewest(bundled, managed, b, m, usable), pinnedVersionMissing: pinnedVersion };
48
51
  }
52
+ return pickNewest(bundled, managed, b, m, usable);
53
+ }
54
+ function pickNewest(bundled, managed, b, m, usable) {
49
55
  if (managed && b && m && m[0] === b[0] && newer(m, b) && usable(managed))
50
56
  return { ...managed, source: "managed" };
51
57
  return { ...bundled, source: "bundled" };
@@ -243,6 +243,7 @@ export async function runDaemon(opts) {
243
243
  }
244
244
  return legacyCache.units;
245
245
  };
246
+ let warnedMissingPin = null;
246
247
  const plan = () => {
247
248
  const config = readDaemonConfig(paths.configPath);
248
249
  const legacy = legacyUnits();
@@ -250,6 +251,13 @@ export async function runDaemon(opts) {
250
251
  const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
251
252
  // Re-picked every reconcile, so a newer hq-cloud from the updater is used as soon as it lands.
252
253
  const hqCloud = currentHqCloud(paths.root);
254
+ const missingPin = hqCloud?.pinnedVersionMissing ?? null;
255
+ if (missingPin !== warnedMissingPin) {
256
+ warnedMissingPin = missingPin;
257
+ if (missingPin && hqCloud) {
258
+ log("warn", `hq-cloud is pinned to ${missingPin}, which is not installed; running ${hqCloud.version} (${hqCloud.source}) until the updater installs it`);
259
+ }
260
+ }
253
261
  const runners = hqCloud ? hqCloudRunners(hqCloud.root) : { syncRunner: null, heartbeat: null, sessionHost: null };
254
262
  return planUnits(enabledServices(machineType, config), {
255
263
  machineType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.223.0",
3
+ "version": "5.225.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {