@indigoai-us/hq-cloud 6.14.12 → 6.14.14

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.
Files changed (78) hide show
  1. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +6 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +3 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +42 -3
  11. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
  13. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-routes.js +3 -0
  15. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +17 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js +83 -33
  19. package/dist/bin/sync-runner.js.map +1 -1
  20. package/dist/bin/sync-runner.test.js +193 -5
  21. package/dist/bin/sync-runner.test.js.map +1 -1
  22. package/dist/cli/reindex.d.ts.map +1 -1
  23. package/dist/cli/reindex.js +23 -60
  24. package/dist/cli/reindex.js.map +1 -1
  25. package/dist/cli/reindex.test.d.ts +2 -2
  26. package/dist/cli/reindex.test.js +60 -109
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/cli/rescue.reindex.test.js +3 -2
  29. package/dist/cli/rescue.reindex.test.js.map +1 -1
  30. package/dist/cli/share.d.ts.map +1 -1
  31. package/dist/cli/share.js +12 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/share.test.js +14 -0
  34. package/dist/cli/share.test.js.map +1 -1
  35. package/dist/cli/sync.d.ts.map +1 -1
  36. package/dist/cli/sync.js +9 -1
  37. package/dist/cli/sync.js.map +1 -1
  38. package/dist/cli/sync.test.js +8 -0
  39. package/dist/cli/sync.test.js.map +1 -1
  40. package/dist/cognito-auth.d.ts +44 -12
  41. package/dist/cognito-auth.d.ts.map +1 -1
  42. package/dist/cognito-auth.js +440 -76
  43. package/dist/cognito-auth.js.map +1 -1
  44. package/dist/cognito-auth.test.js +174 -0
  45. package/dist/cognito-auth.test.js.map +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/machine-auth.test.js +683 -5
  51. package/dist/machine-auth.test.js.map +1 -1
  52. package/dist/vault-client.d.ts +2 -1
  53. package/dist/vault-client.d.ts.map +1 -1
  54. package/dist/vault-client.js +45 -9
  55. package/dist/vault-client.js.map +1 -1
  56. package/dist/vault-client.test.js +77 -7
  57. package/dist/vault-client.test.js.map +1 -1
  58. package/package.json +2 -1
  59. package/src/bin/sync-runner-company.ts +5 -0
  60. package/src/bin/sync-runner-planning.ts +2 -1
  61. package/src/bin/sync-runner-watch-loop.ts +51 -4
  62. package/src/bin/sync-runner-watch-routes.ts +2 -0
  63. package/src/bin/sync-runner.test.ts +244 -4
  64. package/src/bin/sync-runner.ts +99 -32
  65. package/src/cli/reindex.test.ts +63 -120
  66. package/src/cli/reindex.ts +23 -62
  67. package/src/cli/rescue.reindex.test.ts +3 -2
  68. package/src/cli/share.test.ts +20 -0
  69. package/src/cli/share.ts +7 -1
  70. package/src/cli/sync.test.ts +14 -0
  71. package/src/cli/sync.ts +5 -1
  72. package/src/cognito-auth.test.ts +227 -0
  73. package/src/cognito-auth.ts +533 -71
  74. package/src/index.ts +5 -1
  75. package/src/machine-auth.test.ts +808 -5
  76. package/src/vault-client.test.ts +100 -11
  77. package/src/vault-client.ts +55 -10
  78. package/test/share-sync.integration.test.ts +12 -9
@@ -23,6 +23,7 @@ import {
23
23
  readPinnedPrefixes,
24
24
  SESSIONS_SCOPE_PREFIX,
25
25
  defaultCollectTelemetry,
26
+ AUTH_REQUIRED_PASS_EXIT,
26
27
  } from "./sync-runner.js";
27
28
  import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
28
29
  import type { EntityContext, VaultServiceConfig } from "../types.js";
@@ -33,6 +34,7 @@ import type {
33
34
  WatcherSurface,
34
35
  } from "./sync-runner.js";
35
36
  import { FakeClock } from "../watcher.js";
37
+ import { adaptivePollMs } from "./sync-runner-watch-loop.js";
36
38
  import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
37
39
  import { HQ_CLOUD_VERSION } from "../version.js";
38
40
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
@@ -48,6 +50,7 @@ import type {
48
50
  TelemetryEventsBatch,
49
51
  } from "../vault-client.js";
50
52
  import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
53
+ import { CognitoRefreshError } from "../cognito-auth.js";
51
54
  import type { PushEvent } from "../sync/push-event.js";
52
55
 
53
56
  // ---------------------------------------------------------------------------
@@ -209,6 +212,11 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
209
212
  // deps without each override re-wrapping.
210
213
  return {
211
214
  getAccessToken: vi.fn().mockResolvedValue("test-access-token"),
215
+ clearSession: vi.fn(),
216
+ // Keep tests isolated from the developer/agent token cache. Without this,
217
+ // an agent-shaped real idToken enables the post-sync AGENTS materializer
218
+ // and adds ambient diagnostic events to otherwise-hermetic assertions.
219
+ getIdTokenClaims: vi.fn().mockReturnValue(null),
212
220
  createVaultClient: vi.fn().mockImplementation(() => makeVaultStub()),
213
221
  sync: vi.fn().mockResolvedValue(defaultSyncResult()),
214
222
  reindex: vi.fn(() => ({ status: 0 })),
@@ -288,11 +296,37 @@ describe("auth", () => {
288
296
  expect(code).toBe(0);
289
297
  // auth-error is an error-class event → stderr, not stdout
290
298
  expect(deps.stderr.events()).toEqual([
291
- { type: "auth-error", message: "no cached tokens" },
299
+ {
300
+ type: "auth-error",
301
+ message:
302
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
303
+ },
292
304
  ]);
293
305
  expect(deps.stdout.events()).toEqual([]);
294
306
  });
295
307
 
308
+ it("keeps the watch pass retryable when initial refresh fails transiently", async () => {
309
+ const deps = makeDeps({
310
+ getAccessToken: vi
311
+ .fn()
312
+ .mockRejectedValue(
313
+ new CognitoRefreshError("Cognito refresh request failed: ECONNRESET", false),
314
+ ),
315
+ });
316
+
317
+ const code = await runRunner(["--companies"], deps);
318
+
319
+ expect(code).toBe(TRANSIENT_NETWORK_EXIT);
320
+ expect(deps.stderr.events()).toEqual([
321
+ {
322
+ type: "error",
323
+ path: "(auth)",
324
+ message: "Cognito refresh request failed: ECONNRESET",
325
+ },
326
+ ]);
327
+ expect(deps.clearSession).not.toHaveBeenCalled();
328
+ });
329
+
296
330
  it("emits auth-error on stderr when VaultAuthError thrown during discovery", async () => {
297
331
  const deps = makeDeps({
298
332
  createVaultClient: () => ({
@@ -304,11 +338,38 @@ describe("auth", () => {
304
338
  const code = await runRunner(["--companies"], deps);
305
339
  expect(code).toBe(0);
306
340
  expect(deps.stderr.events()).toEqual([
307
- { type: "auth-error", message: "token expired" },
341
+ {
342
+ type: "auth-error",
343
+ message:
344
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
345
+ },
308
346
  ]);
347
+ expect(deps.clearSession).toHaveBeenCalledTimes(1);
309
348
  expect(deps.stdout.events()).toEqual([]);
310
349
  });
311
350
 
351
+ it("handles a typed 401 while planning the personal target", async () => {
352
+ const deps = makeDeps({
353
+ createVaultClient: () =>
354
+ makeVaultStub({
355
+ listPersons: () =>
356
+ Promise.reject(new VaultAuthError("personal lookup unauthorized")),
357
+ }),
358
+ });
359
+
360
+ const code = await runRunner(["--personal"], deps);
361
+
362
+ expect(code).toBe(0);
363
+ expect(deps.clearSession).toHaveBeenCalledTimes(1);
364
+ expect(deps.stderr.events()).toEqual([
365
+ {
366
+ type: "auth-error",
367
+ message:
368
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
369
+ },
370
+ ]);
371
+ });
372
+
312
373
  it("emits error event on stderr and returns 1 on non-auth discovery failure (after 3 attempts)", async () => {
313
374
  let calls = 0;
314
375
  const deps = makeDeps({
@@ -459,8 +520,13 @@ describe("memberships retry", () => {
459
520
  // Auth failures don't retry — re-vending creds is the caller's job.
460
521
  expect(listCalls).toBe(1);
461
522
  expect(deps.stderr.events()).toEqual([
462
- { type: "auth-error", message: "token expired" },
523
+ {
524
+ type: "auth-error",
525
+ message:
526
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
527
+ },
463
528
  ]);
529
+ expect(deps.clearSession).toHaveBeenCalledTimes(1);
464
530
  });
465
531
 
466
532
  it("single-company mode bypasses listMyMemberships entirely (no retry path triggered)", async () => {
@@ -1512,6 +1578,31 @@ describe("per-company fanout", () => {
1512
1578
  expect(betaComplete?.filesDownloaded).toBe(1);
1513
1579
  });
1514
1580
 
1581
+ it("turns a typed mid-fanout 401 into one terminal auth-error", async () => {
1582
+ const deps = makeDeps({
1583
+ createVaultClient: () =>
1584
+ makeVaultStub({
1585
+ memberships: [{ companyUid: "cmp_auth" }],
1586
+ entityGet: (uid: string) =>
1587
+ Promise.resolve({ uid, slug: "auth-co" } as unknown as EntityInfo),
1588
+ }),
1589
+ sync: vi.fn().mockRejectedValue(new VaultAuthError("expired access token")),
1590
+ });
1591
+
1592
+ const code = await runRunner(["--companies"], deps);
1593
+
1594
+ expect(code).toBe(0);
1595
+ expect(deps.clearSession).toHaveBeenCalledTimes(1);
1596
+ expect(deps.stderr.events()).toEqual([
1597
+ {
1598
+ type: "auth-error",
1599
+ message:
1600
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.",
1601
+ },
1602
+ ]);
1603
+ expect(deps.stdout.events().some((event) => event.type === "all-complete")).toBe(false);
1604
+ });
1605
+
1515
1606
  /**
1516
1607
  * Regression test: AWS SDK v3 raises an opaque `UnknownError` (name and
1517
1608
  * message both literally "UnknownError") when it cannot match a Node-side
@@ -3375,6 +3466,12 @@ describe("routeChangeToTarget", () => {
3375
3466
  expect(routeChangeToTarget("companies")).toBeNull();
3376
3467
  expect(routeChangeToTarget("companies/")).toBeNull();
3377
3468
  });
3469
+
3470
+ it("does not route the company scaffold to cloud sync", () => {
3471
+ expect(
3472
+ routeChangeToTarget("companies/_template/knowledge/README.md"),
3473
+ ).toBeNull();
3474
+ });
3378
3475
  });
3379
3476
 
3380
3477
  describe("buildTargetedPushArgv", () => {
@@ -3481,6 +3578,127 @@ function makeSteppableSleep() {
3481
3578
  };
3482
3579
  }
3483
3580
 
3581
+ // ---------------------------------------------------------------------------
3582
+ // Adaptive remote-poll backoff — whole-box CPU aware (no --poll-remote-ms)
3583
+ // ---------------------------------------------------------------------------
3584
+
3585
+ describe("adaptivePollMs (whole-box CPU backoff)", () => {
3586
+ const FLOOR = 60_000;
3587
+ const CEIL = 600_000;
3588
+
3589
+ it("returns the floor when the box is idle (load 0)", () => {
3590
+ expect(adaptivePollMs(0, 8)).toBe(FLOOR);
3591
+ });
3592
+
3593
+ it("returns the ceiling when load equals the core count (fully saturated)", () => {
3594
+ expect(adaptivePollMs(8, 8)).toBe(CEIL);
3595
+ });
3596
+
3597
+ it("clamps to the ceiling when load exceeds the core count", () => {
3598
+ expect(adaptivePollMs(64, 8)).toBe(CEIL);
3599
+ });
3600
+
3601
+ it("scales linearly between floor and ceiling at half saturation", () => {
3602
+ // load 4 on 8 cores → ratio 0.5 → midpoint of [60s, 600s] = 330s.
3603
+ expect(adaptivePollMs(4, 8)).toBe(330_000);
3604
+ });
3605
+
3606
+ it("treats a negative load sample as idle (defensive)", () => {
3607
+ expect(adaptivePollMs(-1, 8)).toBe(FLOOR);
3608
+ });
3609
+
3610
+ it("guards a zero/absent core count without dividing by zero", () => {
3611
+ // cores coerced to 1; load 1 → fully saturated → ceil.
3612
+ expect(adaptivePollMs(1, 0)).toBe(CEIL);
3613
+ });
3614
+
3615
+ it("honors custom floor/ceil bounds", () => {
3616
+ expect(adaptivePollMs(0, 4, 30_000, 300_000)).toBe(30_000);
3617
+ expect(adaptivePollMs(4, 4, 30_000, 300_000)).toBe(300_000);
3618
+ });
3619
+ });
3620
+
3621
+ describe("runRunnerWithLoop — adaptive poll interval (no --poll-remote-ms)", () => {
3622
+ // Capture the ms handed to sleep on the FIRST poll, then hang so the loop
3623
+ // parks until shutdown (mirrors the never-resolving sleep the other loop
3624
+ // tests use).
3625
+ function makeCapturingSleep() {
3626
+ const calls: number[] = [];
3627
+ return {
3628
+ calls,
3629
+ sleep: (ms: number) => {
3630
+ calls.push(ms);
3631
+ return new Promise<void>(() => {});
3632
+ },
3633
+ };
3634
+ }
3635
+
3636
+ it("sleeps the floor interval when the box is idle", async () => {
3637
+ const { calls, sleep } = makeCapturingSleep();
3638
+ let triggerShutdown = () => {};
3639
+ const loop = runRunnerWithLoop(
3640
+ ["--companies", "--watch", "--hq-root", "/tmp/hq"],
3641
+ {
3642
+ runPass: vi.fn().mockResolvedValue(0),
3643
+ sleep,
3644
+ sampleLoadAvg: () => 0,
3645
+ onShutdownSignal: (handler) => {
3646
+ triggerShutdown = handler;
3647
+ return () => {};
3648
+ },
3649
+ },
3650
+ );
3651
+ await flushLoopMicrotasks();
3652
+ expect(calls[0]).toBe(60_000);
3653
+ triggerShutdown();
3654
+ await loop;
3655
+ });
3656
+
3657
+ it("backs off to the ceiling interval when the box is fully loaded", async () => {
3658
+ const { calls, sleep } = makeCapturingSleep();
3659
+ let triggerShutdown = () => {};
3660
+ const loop = runRunnerWithLoop(
3661
+ ["--companies", "--watch", "--hq-root", "/tmp/hq"],
3662
+ {
3663
+ runPass: vi.fn().mockResolvedValue(0),
3664
+ sleep,
3665
+ sampleLoadAvg: () => 1_000, // far above any core count → clamps to ceil
3666
+ onShutdownSignal: (handler) => {
3667
+ triggerShutdown = handler;
3668
+ return () => {};
3669
+ },
3670
+ },
3671
+ );
3672
+ await flushLoopMicrotasks();
3673
+ expect(calls[0]).toBe(600_000);
3674
+ triggerShutdown();
3675
+ await loop;
3676
+ });
3677
+
3678
+ it("an explicit --poll-remote-ms pins a fixed interval and ignores load", async () => {
3679
+ const { calls, sleep } = makeCapturingSleep();
3680
+ const sampleLoadAvg = vi.fn(() => 1_000);
3681
+ let triggerShutdown = () => {};
3682
+ const loop = runRunnerWithLoop(
3683
+ ["--companies", "--watch", "--poll-remote-ms", "123456", "--hq-root", "/tmp/hq"],
3684
+ {
3685
+ runPass: vi.fn().mockResolvedValue(0),
3686
+ sleep,
3687
+ sampleLoadAvg,
3688
+ onShutdownSignal: (handler) => {
3689
+ triggerShutdown = handler;
3690
+ return () => {};
3691
+ },
3692
+ },
3693
+ );
3694
+ await flushLoopMicrotasks();
3695
+ expect(calls[0]).toBe(123456);
3696
+ expect(sampleLoadAvg).not.toHaveBeenCalled();
3697
+ triggerShutdown();
3698
+ await loop;
3699
+ });
3700
+ });
3701
+
3484
3702
  describe("runRunnerWithLoop — event-push wiring", () => {
3485
3703
  it("starts the watcher alongside the poll loop when --event-push is on", async () => {
3486
3704
  const watcher = makeWatcherStub();
@@ -3810,6 +4028,28 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3810
4028
  expect(watcher.disposed).toBe(true);
3811
4029
  });
3812
4030
 
4031
+ it("an exit-0 auth-required pass stops the unattended loop without reporting a crash", async () => {
4032
+ const watcher = makeWatcherStub();
4033
+ const runPass = vi.fn().mockResolvedValue(AUTH_REQUIRED_PASS_EXIT);
4034
+ const sleep = vi.fn().mockResolvedValue(undefined);
4035
+
4036
+ const code = await runRunnerWithLoop(
4037
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
4038
+ {
4039
+ runPass,
4040
+ sleep,
4041
+ clock: new FakeClock(),
4042
+ createWatcher: () => watcher,
4043
+ onShutdownSignal: () => () => {},
4044
+ },
4045
+ );
4046
+
4047
+ expect(code).toBe(0);
4048
+ expect(runPass).toHaveBeenCalledTimes(1);
4049
+ expect(sleep).not.toHaveBeenCalled();
4050
+ expect(watcher.disposed).toBe(true);
4051
+ });
4052
+
3813
4053
  it("a TRANSIENT_NETWORK_EXIT poll pass does NOT surface as a crash — the loop retries (HQ-SYNC-1W)", async () => {
3814
4054
  // A transient offline blip must keep the watcher alive: the loop logs and
3815
4055
  // polls again instead of returning the code and letting the process exit
@@ -3845,7 +4085,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3845
4085
 
3846
4086
  it("a PARTIAL_SYNC_EXIT poll pass does NOT surface as a crash — the loop retries", async () => {
3847
4087
  // A partial pass (some per-file transfers threw: oversized object on an
3848
- // old runner, presign denial, expired token, corrupt vault key) must keep
4088
+ // old runner, presign denial, corrupt vault key) must keep
3849
4089
  // the watcher alive. Exiting on it got the watcher hot-respawned by the
3850
4090
  // menubar supervisor and Sentry-captured on EVERY failing pass — the
3851
4091
  // July 2026 "watcher exited unexpectedly (code=Some(2))" alert flood. The
@@ -62,7 +62,9 @@ import * as fs from "fs";
62
62
  import { randomUUID } from "node:crypto";
63
63
  import { fileURLToPath } from "url";
64
64
  import {
65
+ CognitoRefreshError,
65
66
  getValidAccessToken,
67
+ invalidateCachedTokensByFingerprint,
66
68
  loadCachedTokens,
67
69
  VaultClient,
68
70
  VaultAuthError,
@@ -532,6 +534,16 @@ export type { PullScope };
532
534
  */
533
535
  const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
534
536
 
537
+ const REAUTH_MESSAGE =
538
+ "Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
539
+
540
+ /**
541
+ * Internal-only result used between a watch pass and its owning loop. The
542
+ * outer process still exits 0 (auth-required is a handled UX state), while
543
+ * the loop stops instead of retrying a known-unusable session forever.
544
+ */
545
+ export const AUTH_REQUIRED_PASS_EXIT = 18;
546
+
535
547
  /**
536
548
  * Call `listMyMemberships()` with up to 3 attempts and a small linear
537
549
  * backoff between them. The single network call that drives every cloud
@@ -589,6 +601,8 @@ export interface RunnerDeps {
589
601
  stderr?: { write: (chunk: string) => boolean | void };
590
602
  /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
591
603
  getAccessToken?: () => Promise<string>;
604
+ /** Invalidate only the rejected token generation. Injectable for tests. */
605
+ clearSession?: (tokenFingerprint?: string) => void;
592
606
  /**
593
607
  * Read the caller's identity claims (sub/email/name) off the cached Cognito
594
608
  * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
@@ -621,6 +635,8 @@ export interface RunnerDeps {
621
635
  ) => Promise<void>;
622
636
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
623
637
  operationLockAlreadyHeld?: boolean;
638
+ /** Internal watch-loop result override; one-shot callers keep exit 0. */
639
+ authRequiredExitCode?: number;
624
640
  /** Share function (push phase). Defaults to `cli/share.share`. */
625
641
  share?: (options: ShareOptions) => Promise<ShareResult>;
626
642
  /**
@@ -1109,14 +1125,27 @@ export async function runRunner(
1109
1125
  const getAccessToken =
1110
1126
  deps.getAccessToken ??
1111
1127
  (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
1128
+ const clearSession =
1129
+ deps.clearSession ?? invalidateCachedTokensByFingerprint;
1112
1130
  try {
1113
1131
  await getAccessToken();
1114
1132
  } catch (err) {
1133
+ if (
1134
+ (err instanceof CognitoRefreshError && !err.requiresReauth) ||
1135
+ isTransientNetworkError(err)
1136
+ ) {
1137
+ emit({
1138
+ type: "error",
1139
+ path: "(auth)",
1140
+ message: err instanceof Error ? err.message : String(err),
1141
+ });
1142
+ return TRANSIENT_NETWORK_EXIT;
1143
+ }
1115
1144
  emit({
1116
1145
  type: "auth-error",
1117
- message: err instanceof Error ? err.message : String(err),
1146
+ message: REAUTH_MESSAGE,
1118
1147
  });
1119
- return 0;
1148
+ return deps.authRequiredExitCode ?? 0;
1120
1149
  }
1121
1150
 
1122
1151
  // ---- vault client -----------------------------------------------------
@@ -1257,11 +1286,12 @@ export async function runRunner(
1257
1286
  memberships = resolution.memberships;
1258
1287
  } catch (err) {
1259
1288
  if (err instanceof VaultAuthError) {
1289
+ clearSession(err.tokenFingerprint);
1260
1290
  emit({
1261
1291
  type: "auth-error",
1262
- message: err.message,
1292
+ message: REAUTH_MESSAGE,
1263
1293
  });
1264
- return 0;
1294
+ return deps.authRequiredExitCode ?? 0;
1265
1295
  }
1266
1296
  // Surface the failure as an error event for observability either way.
1267
1297
  emit({
@@ -1279,15 +1309,31 @@ export async function runRunner(
1279
1309
  return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1280
1310
  }
1281
1311
 
1282
- const targetPlan = await buildFanoutPlan({
1283
- memberships,
1284
- companies: parsed.companies,
1285
- personal: parsed.personal,
1286
- skipPersonal: parsed.skipPersonal,
1287
- client,
1288
- claims,
1289
- resolveSkipPersonal,
1290
- });
1312
+ let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
1313
+ try {
1314
+ targetPlan = await buildFanoutPlan({
1315
+ memberships,
1316
+ companies: parsed.companies,
1317
+ personal: parsed.personal,
1318
+ skipPersonal: parsed.skipPersonal,
1319
+ client,
1320
+ claims,
1321
+ resolveSkipPersonal,
1322
+ });
1323
+ } catch (err) {
1324
+ if (err instanceof VaultAuthError) {
1325
+ clearSession(err.tokenFingerprint);
1326
+ emit({ type: "auth-error", message: REAUTH_MESSAGE });
1327
+ return deps.authRequiredExitCode ?? 0;
1328
+ }
1329
+ emit({
1330
+ type: "error",
1331
+ message: err instanceof Error ? err.message : String(err),
1332
+ path: "(discovery)",
1333
+ });
1334
+ emitRunnerTerminalTelemetry({ status: "failed" });
1335
+ return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1336
+ }
1291
1337
  if (targetPlan.status === "setup-needed") {
1292
1338
  emit({ type: "setup-needed" });
1293
1339
  return 0;
@@ -1306,24 +1352,34 @@ export async function runRunner(
1306
1352
  // ---- fanout -----------------------------------------------------------
1307
1353
  const syncFn = deps.sync ?? defaultSync;
1308
1354
  const shareFn = deps.share ?? defaultShare;
1309
- const fanout = await executeCompanyFanout({
1310
- plan,
1311
- direction: parsed.direction,
1312
- hqRoot: parsed.hqRoot,
1313
- onConflict: parsed.onConflict,
1314
- client,
1315
- vaultConfig,
1316
- ...(uploadAuthor ? { uploadAuthor } : {}),
1317
- ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1318
- syncFn,
1319
- shareFn,
1320
- resolveDeletePolicy,
1321
- emit,
1322
- scopePaths: parsed.scopePaths,
1323
- telemetryClient: client,
1324
- telemetryClaims: claims,
1325
- telemetrySessionId,
1326
- });
1355
+ let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
1356
+ try {
1357
+ fanout = await executeCompanyFanout({
1358
+ plan,
1359
+ direction: parsed.direction,
1360
+ hqRoot: parsed.hqRoot,
1361
+ onConflict: parsed.onConflict,
1362
+ client,
1363
+ vaultConfig,
1364
+ ...(uploadAuthor ? { uploadAuthor } : {}),
1365
+ ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1366
+ syncFn,
1367
+ shareFn,
1368
+ resolveDeletePolicy,
1369
+ emit,
1370
+ scopePaths: parsed.scopePaths,
1371
+ telemetryClient: client,
1372
+ telemetryClaims: claims,
1373
+ telemetrySessionId,
1374
+ });
1375
+ } catch (err) {
1376
+ if (err instanceof VaultAuthError) {
1377
+ clearSession(err.tokenFingerprint);
1378
+ emit({ type: "auth-error", message: REAUTH_MESSAGE });
1379
+ return deps.authRequiredExitCode ?? 0;
1380
+ }
1381
+ throw err;
1382
+ }
1327
1383
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1328
1384
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1329
1385
 
@@ -1685,6 +1741,13 @@ const isDirectInvocation = (() => {
1685
1741
  export interface RunnerLoopDeps {
1686
1742
  /** Sleep `ms` between passes. Default: host setTimeout. */
1687
1743
  sleep?: (ms: number) => Promise<void>;
1744
+ /**
1745
+ * 1-minute whole-box load average source for the adaptive remote-poll
1746
+ * backoff (used only when `--poll-remote-ms` is omitted). Defaults to
1747
+ * `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval
1748
+ * scales with load without depending on the host's real load.
1749
+ */
1750
+ sampleLoadAvg?: () => number;
1688
1751
  /**
1689
1752
  * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
1690
1753
  * (and the event-push wiring) so the poll loop and the watcher-triggered
@@ -1787,7 +1850,11 @@ export async function runRunnerWithLoop(
1787
1850
  const parsed = parseArgs(argv);
1788
1851
  const runtime = {
1789
1852
  runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
1790
- runRunner(passArgv, { operationLockAlreadyHeld: true }),
1853
+ runRunner(passArgv, {
1854
+ operationLockAlreadyHeld: true,
1855
+ authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
1856
+ }),
1857
+ authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
1791
1858
  defaultGetIdTokenClaims,
1792
1859
  defaultGetAccessToken: () =>
1793
1860
  getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),