@indigoai-us/hq-cli 5.345.168 → 5.345.170

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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/command-catalog.generated.d.ts +49 -4
  3. package/dist/command-catalog.generated.js +60 -4
  4. package/dist/commands/access.js +7 -2
  5. package/dist/commands/bot.js +4 -0
  6. package/dist/commands/cloud.d.ts +20 -0
  7. package/dist/commands/cloud.js +55 -4
  8. package/dist/commands/core-checkpoint-native.js +30 -10
  9. package/dist/commands/feedback-triage.d.ts +43 -1
  10. package/dist/commands/feedback-triage.js +123 -23
  11. package/dist/commands/feedback.d.ts +40 -1
  12. package/dist/commands/feedback.js +195 -7
  13. package/dist/commands/files.d.ts +42 -0
  14. package/dist/commands/files.js +223 -30
  15. package/dist/commands/mesh.js +6 -0
  16. package/dist/commands/sync-manifest.d.ts +16 -0
  17. package/dist/commands/sync-manifest.js +17 -1
  18. package/dist/lib/bot/decisions.d.ts +62 -0
  19. package/dist/lib/bot/decisions.js +228 -0
  20. package/dist/lib/bot/index.d.ts +1 -0
  21. package/dist/lib/bot/index.js +1 -0
  22. package/dist/lib/bot/run.d.ts +7 -0
  23. package/dist/lib/bot/run.js +104 -1
  24. package/dist/lib/daemon/catalog.d.ts +4 -0
  25. package/dist/lib/daemon/catalog.js +60 -29
  26. package/dist/lib/daemon/config.d.ts +7 -0
  27. package/dist/lib/daemon/config.js +32 -0
  28. package/dist/lib/daemon/run.d.ts +3 -2
  29. package/dist/lib/daemon/run.js +23 -3
  30. package/dist/lib/daemon/supervisor.d.ts +1 -0
  31. package/dist/lib/daemon/supervisor.js +4 -0
  32. package/dist/lib/daemon/sync-core-canary.d.ts +9 -0
  33. package/dist/lib/daemon/sync-core-canary.js +56 -0
  34. package/dist/lib/doctor/checks/sync-health.d.ts +39 -1
  35. package/dist/lib/doctor/checks/sync-health.js +121 -3
  36. package/dist/lib/doctor/fix/apply.d.ts +66 -8
  37. package/dist/lib/doctor/fix/apply.js +278 -80
  38. package/dist/lib/doctor/fix/process-identity.d.ts +13 -0
  39. package/dist/lib/doctor/fix/process-identity.js +34 -0
  40. package/dist/lib/doctor/fix/refresh-sync-handoff.d.ts +201 -0
  41. package/dist/lib/doctor/fix/refresh-sync-handoff.js +752 -0
  42. package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +1 -1
  43. package/dist/lib/doctor/fix/refresh-sync-outcome.js +13 -0
  44. package/dist/lib/doctor/types.d.ts +1 -1
  45. package/dist/lib/doctor/types.js +3 -0
  46. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  47. package/dist/lib/work-context/organize.d.ts +7 -0
  48. package/dist/lib/work-context/organize.js +21 -1
  49. package/dist/sentry-before-send.js +14 -0
  50. package/dist/utils/breadcrumb-buffer.js +15 -4
  51. package/dist/utils/feedback-diagnostics.js +5 -2
  52. package/dist/utils/feedback-logs.d.ts +7 -0
  53. package/dist/utils/feedback-logs.js +37 -3
  54. package/dist/utils/scrub-diagnostics.d.ts +4 -0
  55. package/dist/utils/scrub-diagnostics.js +95 -0
  56. package/package.json +7 -7
@@ -22,6 +22,7 @@ import { readBotConfig } from "../bot/config.js";
22
22
  import { botsRoot } from "../bot/paths.js";
23
23
  import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
24
24
  import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
25
+ import { inspectSyncCoreCanary } from "./sync-core-canary.js";
25
26
  import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
26
27
  import { loadLanesConfig } from "../lanes/config.js";
27
28
  import { daemonLanesReconcileHandler } from "../lanes/daemon-reconcile.js";
@@ -54,7 +55,7 @@ import { stageHqChildBootstrap } from "./hq-child-bootstrap-stage.js";
54
55
  import { runnerSessionName } from "./remote-control.js";
55
56
  import { adoptChild, createChildTracker, finishSuccession, handoffEligible, readProcStart, stopOrphans, successionPaths, systemdKillMode, takeSuccession, writeHandoff, } from "./succession.js";
56
57
  import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
57
- import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, } from "./supervisor.js";
58
+ import { STOP_ESCALATION_MS, SYNC_RUNNER_PERSIST_GRACE_MS, createSupervisor, planKey, } from "./supervisor.js";
58
59
  import { userToolDirs } from "./tools.js";
59
60
  import { logOffset, readLogSince, readUnitHolds, writeUnitHolds } from "./unit-holds.js";
60
61
  import { daemonRunGuard } from "./lanes-reconcile-guard.js";
@@ -63,6 +64,19 @@ export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
63
64
  export const RESTART_EXIT_CODE = 75;
64
65
  const NON_SYNC_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + 5_000;
65
66
  const SYNC_RUNNER_SHUTDOWN_CAP_MS = STOP_ESCALATION_MS + SYNC_RUNNER_PERSIST_GRACE_MS + 5_000;
67
+ export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
68
+ const syncCoreCanaryRunning = supervisor?.runningPlanKey("sync-core-canary") !== undefined;
69
+ const transitionContext = {
70
+ ...context,
71
+ syncCoreCanaryRunning,
72
+ legacySyncReadyForCore: false,
73
+ };
74
+ const waiting = planUnits(services, transitionContext);
75
+ const legacy = waiting.find((unit) => unit.id === "sync");
76
+ const legacySyncReadyForCore = legacy !== undefined
77
+ && supervisor?.runningPlanKey("sync") === planKey(legacy, CLI_VERSION);
78
+ return planUnits(services, { ...transitionContext, legacySyncReadyForCore });
79
+ }
66
80
  /** A bounded shutdown deadline that can account for sync push persistence. */
67
81
  export function createShutdownCap() {
68
82
  const startedAt = Date.now();
@@ -939,7 +953,7 @@ export async function runDaemon(opts) {
939
953
  legacy,
940
954
  appRunning: () => desktopAppRunning(platform),
941
955
  });
942
- const plans = planUnits(services, {
956
+ const context = {
943
957
  machineType,
944
958
  platform,
945
959
  home,
@@ -964,6 +978,11 @@ export async function runDaemon(opts) {
964
978
  return switches;
965
979
  })(),
966
980
  syncOwner,
981
+ syncCoreCanary: config.syncCoreCanaryError
982
+ ? { blockedReason: config.syncCoreCanaryError }
983
+ : config.syncCoreCanary
984
+ ? inspectSyncCoreCanary(home, hqRoot, config.syncCoreCanary, platform)
985
+ : undefined,
967
986
  desktopAppManagesSync: appManagesSync,
968
987
  syncRunnerProcesses,
969
988
  syncRunnerOwnerPid,
@@ -991,7 +1010,8 @@ export async function runDaemon(opts) {
991
1010
  invalidPoolServiceSessions,
992
1011
  availablePoolFrontends,
993
1012
  unverifiablePoolFrontends,
994
- });
1013
+ };
1014
+ const plans = planUnitsWithSyncCoreInterlock(services, context, supervisor);
995
1015
  if (startupPlanOpen) {
996
1016
  const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
997
1017
  if (hqCloud?.source === "bundled")
@@ -179,6 +179,7 @@ export interface Supervisor {
179
179
  stop(): Promise<void>;
180
180
  reconcile(): void;
181
181
  state(): DaemonState;
182
+ runningPlanKey(unitId: string): string | undefined;
182
183
  /** True while a live sync runner has reported that it is persisting pushes. */
183
184
  hasStoppingSyncRunner(): boolean;
184
185
  /** Stop supervising without signalling any child; returns the long-running units left running. */
@@ -1399,6 +1399,10 @@ export function createSupervisor(deps) {
1399
1399
  },
1400
1400
  reconcile,
1401
1401
  state: snapshot,
1402
+ runningPlanKey(unitId) {
1403
+ const slot = slots.get(unitId);
1404
+ return slot?.child ? slot.childKey : undefined;
1405
+ },
1402
1406
  hasStoppingSyncRunner: () => [...liveOwners.values()].some(syncRunnerIsStopping),
1403
1407
  release() {
1404
1408
  const released = [];
@@ -0,0 +1,9 @@
1
+ import type { SyncCoreCanaryConfig } from "./config.js";
2
+ export interface SyncCoreCanaryPlan {
3
+ binaryPath?: string;
4
+ vaultUid?: string;
5
+ canaryPath?: string;
6
+ blockedReason?: string;
7
+ }
8
+ export declare function inspectSyncCoreCanary(home: string, hqRoot: string | undefined, config: SyncCoreCanaryConfig, platform: NodeJS.Platform): SyncCoreCanaryPlan;
9
+ //# sourceMappingURL=sync-core-canary.d.ts.map
@@ -0,0 +1,56 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parse as parseToml } from "smol-toml";
4
+ function blocked(config, blockedReason) {
5
+ return { ...config, blockedReason };
6
+ }
7
+ function record(value) {
8
+ return value && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : undefined;
11
+ }
12
+ export function inspectSyncCoreCanary(home, hqRoot, config, platform) {
13
+ if (platform !== "darwin" && platform !== "linux") {
14
+ return blocked(config, "the sync-core canary requires macOS or Linux");
15
+ }
16
+ try {
17
+ if (!fs.statSync(config.binaryPath).isFile())
18
+ return blocked(config, "the sync-core binary is missing");
19
+ fs.accessSync(config.binaryPath, fs.constants.X_OK);
20
+ }
21
+ catch {
22
+ return blocked(config, "the sync-core binary is missing or not executable");
23
+ }
24
+ const configPath = path.join(home, ".hq", "sync-core", "config.toml");
25
+ let sync;
26
+ try {
27
+ sync = record(record(parseToml(fs.readFileSync(configPath, "utf8")))?.sync);
28
+ }
29
+ catch {
30
+ return blocked(config, `the sync-core config at ${configPath} is missing or invalid`);
31
+ }
32
+ if (sync?.engine !== "rust-test" || sync.mode !== "full") {
33
+ return blocked(config, "the sync-core config must select engine rust-test in full mode");
34
+ }
35
+ const vaults = sync.vaults;
36
+ if (!Array.isArray(vaults) || vaults.length !== 1 || vaults[0] !== config.vaultUid) {
37
+ return blocked(config, `the sync-core config must name exactly ${config.vaultUid}`);
38
+ }
39
+ if (!hqRoot || typeof sync.hq_root !== "string" || path.resolve(sync.hq_root) !== path.resolve(hqRoot)) {
40
+ return blocked(config, "the sync-core config must use the daemon's HQ folder");
41
+ }
42
+ const canaryPath = typeof sync.canary_path === "string" ? sync.canary_path.trim() : "";
43
+ if (!canaryPath)
44
+ return blocked(config, "the sync-core config must name one canary path");
45
+ const ownedPath = path.join(home, ".hq", "sync-core", "owned", `${config.vaultUid}.json`);
46
+ try {
47
+ const owned = record(JSON.parse(fs.readFileSync(ownedPath, "utf8")));
48
+ if (owned?.vault !== config.vaultUid)
49
+ throw new Error("mismatch");
50
+ }
51
+ catch {
52
+ return blocked(config, `the sync-core ownership record for ${config.vaultUid} is missing or invalid`);
53
+ }
54
+ return { ...config, canaryPath };
55
+ }
56
+ //# sourceMappingURL=sync-core-canary.js.map
@@ -61,7 +61,39 @@ export interface SyncManifestUploadStatus {
61
61
  baseUsable: boolean;
62
62
  /** Local company slug for company-scoped remediation; absent for personal. */
63
63
  companySlug?: string;
64
+ /**
65
+ * Last failed pass, from hq-cloud >= 6.18.102 (`readManifestUploadStatus`,
66
+ * hq-cloud #1050). Fixed tokens only. Absent on older hq-cloud and after a
67
+ * later pass succeeded.
68
+ */
69
+ lastAttemptAt?: string;
70
+ consecutiveFailures?: number;
71
+ lastFailureReason?: string;
72
+ lastFailureKind?: string;
73
+ lastFailureStatus?: number;
64
74
  }
75
+ /**
76
+ * The last failed pass for a stale scope, when hq-cloud recorded one after the
77
+ * last successful upload. Undefined means "no evidence a pass ran", which is
78
+ * the only case where "the daily audit pass is not running" is accurate.
79
+ */
80
+ export declare function manifestLastFailure(entry: SyncManifestUploadStatus): {
81
+ reason: string;
82
+ status?: number;
83
+ attempts: number;
84
+ at: string;
85
+ } | undefined;
86
+ /**
87
+ * Failures a fresh `hq sync manifest` from this machine cannot clear: the
88
+ * upload route is not deployed (404 soft skip), the server rejected this
89
+ * identity, or it refused access (403). A 401 is not listed: it can be an
90
+ * expired token that a fresh run refreshes. Offering `--fix` for these only makes the
91
+ * health hook report the same row as "repair did not clear" every day.
92
+ */
93
+ export declare function manifestFailureNotFixable(failure: {
94
+ reason: string;
95
+ status?: number;
96
+ }): boolean;
65
97
  /** Injectable dependencies — defaults are the real collectors. */
66
98
  export interface SyncHealthDeps {
67
99
  /** Runner log and conflict signals; omitted in unit tests of other checks. */
@@ -116,6 +148,13 @@ export interface SyncHealthDeps {
116
148
  * since a re-upload would exit 0 without uploading.
117
149
  */
118
150
  manifestUploadsDisabled?: () => boolean;
151
+ /**
152
+ * Start time (epoch ms) of a live background `hq doctor --fix` pull for
153
+ * this refresh target (a company slug, or `personal`), or null. A journal
154
+ * whose refresh is still running is reported "refresh in progress" rather
155
+ * than stale (feedback cd56a501).
156
+ */
157
+ refreshInProgress?: (fixTarget: string) => number | null;
119
158
  }
120
159
  /**
121
160
  * True when `slug` is a filesystem-safe company folder name. Rejects empty
@@ -148,7 +187,6 @@ export declare function isPersonalJournalSlug(slug: string): boolean;
148
187
  export declare function journalSlugFromCheckId(checkId: string): string | null;
149
188
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
150
189
  export declare const syncHealthFamily: CheckFamily;
151
- /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
152
190
  export declare function checkSyncHealth(context: CheckContext, rawDeps?: SyncHealthDeps): CheckResult[];
153
191
  /** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
154
192
  export declare function semverGt(a: string, b: string): boolean;
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { defaultSyncRuntimeResults } from "./sync-runtime.js";
27
27
  import * as fs from "node:fs";
28
+ import { DEFAULT_OWNER_IDENTITY } from "../fix/process-identity.js";
29
+ import { REFRESH_SPAWN_LOCK_WARN_MS, inspectRefreshSpawnLock, refreshHandoffStartedAt, } from "../fix/refresh-sync-handoff.js";
28
30
  import * as path from "node:path";
29
31
  // Namespace import, deliberately: hq-cloud is an ESM package, so a static
30
32
  // NAMED import of an export the installed version lacks is a link-time
@@ -50,6 +52,45 @@ const SYNC_FAMILY_TITLE = "Versions & sync";
50
52
  export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
51
53
  /** The offline update cache written by the check-hq-update SessionStart hook. */
52
54
  export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
55
+ /** Failure tokens hq-cloud stores, restricted the same way hq-cloud reads them. */
56
+ const MANIFEST_FAILURE_TOKEN = /^[a-z][a-z0-9_]{0,39}$/;
57
+ /**
58
+ * The last failed pass for a stale scope, when hq-cloud recorded one after the
59
+ * last successful upload. Undefined means "no evidence a pass ran", which is
60
+ * the only case where "the daily audit pass is not running" is accurate.
61
+ */
62
+ export function manifestLastFailure(entry) {
63
+ const reason = entry.lastFailureReason;
64
+ const at = entry.lastAttemptAt;
65
+ if (!reason || !MANIFEST_FAILURE_TOKEN.test(reason) || !at)
66
+ return undefined;
67
+ const attempts = entry.consecutiveFailures ?? 0;
68
+ if (!Number.isFinite(attempts) || attempts < 1)
69
+ return undefined;
70
+ const atMs = Date.parse(at);
71
+ if (!Number.isFinite(atMs))
72
+ return undefined;
73
+ const uploadedMs = entry.lastUploadAt ? Date.parse(entry.lastUploadAt) : Number.NaN;
74
+ if (Number.isFinite(uploadedMs) && atMs <= uploadedMs)
75
+ return undefined;
76
+ const status = typeof entry.lastFailureStatus === "number" && Number.isInteger(entry.lastFailureStatus)
77
+ ? entry.lastFailureStatus
78
+ : undefined;
79
+ return { reason, attempts: Math.floor(attempts), at, ...(status !== undefined ? { status } : {}) };
80
+ }
81
+ /**
82
+ * Failures a fresh `hq sync manifest` from this machine cannot clear: the
83
+ * upload route is not deployed (404 soft skip), the server rejected this
84
+ * identity, or it refused access (403). A 401 is not listed: it can be an
85
+ * expired token that a fresh run refreshes. Offering `--fix` for these only makes the
86
+ * health hook report the same row as "repair did not clear" every day.
87
+ */
88
+ export function manifestFailureNotFixable(failure) {
89
+ if (failure.reason === "soft_skip" || failure.reason === "rejected_identity")
90
+ return true;
91
+ // 401 is left fixable: an expired token is refreshed by the next run.
92
+ return failure.status === 403 || failure.status === 404;
93
+ }
53
94
  /** Fill in the optional dependencies so call sites never branch on undefined. */
54
95
  function withDefaults(deps, hqRoot) {
55
96
  return {
@@ -63,6 +104,8 @@ function withDefaults(deps, hqRoot) {
63
104
  companySyncResolvable: deps.companySyncResolvable ??
64
105
  ((slug, lastSync) => !companyRecordedNotResolved(hqRoot, slug, deps.now().getTime(), lastSync)),
65
106
  manifestUploadsDisabled: deps.manifestUploadsDisabled ?? (() => manifestUploadsDisabled()),
107
+ refreshInProgress: deps.refreshInProgress ??
108
+ ((fixTarget) => refreshHandoffStartedAt(hqRoot, fixTarget, deps.now().getTime())),
66
109
  };
67
110
  }
68
111
  /**
@@ -294,6 +337,36 @@ export const syncHealthFamily = {
294
337
  run: (context) => Promise.resolve(checkSyncHealth(context)),
295
338
  };
296
339
  /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
340
+ /**
341
+ * WARN when the `hq doctor --fix` spawn lock has been held for over
342
+ * {@link REFRESH_SPAWN_LOCK_WARN_MS}, naming the holder pid, so a stalled or
343
+ * suspended holder is visible. Report only: the lock is never removed here.
344
+ */
345
+ function refreshSpawnLockResult(hqRoot, nowMs) {
346
+ let lock;
347
+ try {
348
+ lock = inspectRefreshSpawnLock(hqRoot, nowMs, DEFAULT_OWNER_IDENTITY);
349
+ }
350
+ catch {
351
+ return [];
352
+ }
353
+ if (!lock || lock.heldMs === null || lock.heldMs <= REFRESH_SPAWN_LOCK_WARN_MS)
354
+ return [];
355
+ // Left by a claimer that has exited: nothing to stop, and the next
356
+ // `hq doctor --fix` releases it.
357
+ if (lock.ownerGone)
358
+ return [];
359
+ const minutes = Math.floor(lock.heldMs / 60_000);
360
+ return [
361
+ {
362
+ status: "WARN",
363
+ checkId: "sync.refresh.spawn-lock",
364
+ reasonCode: "refresh-lock-held",
365
+ message: `The hq doctor --fix refresh lock has been held for ${minutes} minutes by process ${lock.pid}. No new journal refresh can start until it is released.`,
366
+ remediation: `If process ${lock.pid} is stuck or suspended, resume or stop it; the lock is released when it exits.`,
367
+ },
368
+ ];
369
+ }
297
370
  export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
298
371
  const deps = withDefaults(rawDeps, context.hqRoot);
299
372
  try {
@@ -301,6 +374,7 @@ export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
301
374
  ...versionResults(context, deps),
302
375
  ...updateAvailabilityResult(context, deps),
303
376
  ...journalAndManifestResults(deps),
377
+ ...refreshSpawnLockResult(context.hqRoot, deps.now().getTime()),
304
378
  ...deps.runtime(deps.now()),
305
379
  ];
306
380
  }
@@ -528,7 +602,7 @@ function journalResults(deps, journals) {
528
602
  if (entry.slug === PERSONAL_SCOPE_SLUG) {
529
603
  results.push(leftoverPersonalJournalResult(entry));
530
604
  if (!hasPersonalVaultJournal) {
531
- results.push(journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs));
605
+ results.push(withRefreshInProgress(deps, journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs), "personal"));
532
606
  }
533
607
  continue;
534
608
  }
@@ -570,10 +644,40 @@ function journalResults(deps, journals) {
570
644
  results.push(notOnThisMachineResult(entry, checkId));
571
645
  continue;
572
646
  }
573
- results.push(freshness);
647
+ results.push(withRefreshInProgress(deps, freshness, isPersonalJournalSlug(entry.slug) ? "personal" : entry.slug));
574
648
  }
575
649
  return results;
576
650
  }
651
+ /**
652
+ * A stale or never-synced journal whose `hq doctor --fix` pull is still
653
+ * running in the background is UNTESTED "refresh in progress", not WARN.
654
+ * The pull records lastSync only when it finishes, so until then the journal
655
+ * looks stale even though the repair is under way. Once the process is gone
656
+ * the record no longer counts and the WARN comes back, so a pull that died is
657
+ * retried by the next `--fix`.
658
+ */
659
+ function withRefreshInProgress(deps, result, fixTarget) {
660
+ if (result.status !== "WARN")
661
+ return result;
662
+ if (result.reasonCode !== "stale-threshold" && result.reasonCode !== "never-synced")
663
+ return result;
664
+ let startedAtMs;
665
+ try {
666
+ startedAtMs = deps.refreshInProgress(fixTarget);
667
+ }
668
+ catch {
669
+ return result;
670
+ }
671
+ if (startedAtMs === null)
672
+ return result;
673
+ return {
674
+ status: "UNTESTED",
675
+ checkId: result.checkId,
676
+ reasonCode: "refresh-in-progress",
677
+ ...(result.target ? { target: result.target } : {}),
678
+ message: `${result.message} hq doctor --fix is refreshing it in the background (started ${new Date(startedAtMs).toISOString()}); it passes when that pull finishes.`,
679
+ };
680
+ }
577
681
  function companySyncResolvable(deps, slug, lastSync) {
578
682
  try {
579
683
  return deps.companySyncResolvable(slug, lastSync);
@@ -717,7 +821,21 @@ function manifestResults(deps, journals) {
717
821
  }
718
822
  }
719
823
  const command = `hq sync manifest${scope ? ` --scope ${scope}` : ""}`;
720
- const message = `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`;
824
+ const failure = manifestLastFailure(entry);
825
+ const age = `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt})`;
826
+ // A pass that ran and failed is not "not running": name the stored reason.
827
+ const message = failure
828
+ ? `${age}. The last pass (${failure.at}) failed with ${failure.reason}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ""}, ${failure.attempts} failed pass${failure.attempts === 1 ? "" : "es"} in a row.`
829
+ : `${age} — the daily audit pass is not running.`;
830
+ if (failure && manifestFailureNotFixable(failure)) {
831
+ return {
832
+ status: "WARN",
833
+ checkId,
834
+ reasonCode: "manifest-upload-rejected",
835
+ message,
836
+ remediation: `--fix cannot clear this from this machine. Check you are signed in to the right HQ account (/hq-whoami), then run \`${command}\` and read its error.`,
837
+ };
838
+ }
721
839
  // With the kill switch set the upload exits 0 and sends nothing, so the
722
840
  // row carries no `--fix` target and names the switch instead.
723
841
  if (deps.manifestUploadsDisabled()) {
@@ -42,6 +42,8 @@ import type { CheckResult, DoctorStatus } from "../types.js";
42
42
  import { type GateProfile } from "../hook-gate-profiles.js";
43
43
  import { type FixClass } from "./remediation.js";
44
44
  import { type RemovePs1ShimsResult } from "../../windows/ps1-shims.js";
45
+ import { processStartTime } from "./process-identity.js";
46
+ import { type HarvestedRefreshRun, type OrphanPull, type RefreshOwnerIdentity } from "./refresh-sync-handoff.js";
45
47
  import { type RefreshSyncOutcome } from "./refresh-sync-outcome.js";
46
48
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
47
49
  export declare const HOOK_CONFIG_DIRS: readonly [".claude", ".codex", ".grok"];
@@ -155,8 +157,13 @@ export interface RefreshManifestTarget {
155
157
  }
156
158
  /** Outcome of one {@link ApplyFixesOptions.refreshManifest} call. */
157
159
  export interface RefreshManifestResult {
158
- /** Whether the upload command exited 0. The re-check is the real verdict. */
160
+ /**
161
+ * Whether the upload command exited 0 and, when it printed a `--json`
162
+ * outcome, that outcome was `uploaded`. The re-check is the real verdict.
163
+ */
159
164
  ok: boolean;
165
+ /** The `hq sync manifest --json` status token, when the pass did not upload. */
166
+ passStatus?: string;
160
167
  /** One-line summary. */
161
168
  message: string;
162
169
  /**
@@ -290,17 +297,69 @@ export declare function unresolvedSyncChecks(findings: readonly CheckResult[], a
290
297
  /**
291
298
  * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
292
299
  * `hq sync pull --company <slug>` (or `--personal`) against the tree
293
- * `--fix` is repairing. Never a push, never interactive. A timeout or
294
- * non-zero exit is reported rather than thrown so later plans still run.
300
+ * `--fix` is repairing. Never a push, never interactive. A non-zero exit is
301
+ * reported rather than thrown so later plans still run.
302
+ *
303
+ * A pull still running after {@link REFRESH_SYNC_TIMEOUT_MS} is NOT killed
304
+ * (feedback cd56a501). A pull writes `lastSync` only when its pass finishes,
305
+ * so killing it at the deadline left a large vault, or a pull queued behind
306
+ * the sync runner's lock, stale on every run. It is left running detached and
307
+ * recorded in refresh-sync-handoff.ts; the journal check reports it as
308
+ * "refresh in progress" until it finishes.
309
+ */
310
+ export declare function defaultRefreshSync(target: RefreshSyncTarget, deps?: RefreshSyncSpawnDeps): Promise<RefreshSyncResult>;
311
+ /** Map a finished supervised pull to a {@link RefreshSyncResult}. */
312
+ export declare function harvestedRefreshSyncResult(harvested: HarvestedRefreshRun): RefreshSyncResult;
313
+ export { processStartTime };
314
+ /**
315
+ * Kill an orphaned --fix pull past its cap, but only when the pid still names
316
+ * that pull: its process start time must match the one recorded when it
317
+ * started. A reused pid has a different start time and is left alone. On
318
+ * Windows no start time is recorded, so nothing is killed there.
319
+ */
320
+ export declare function killOrphanPullIfSame(orphan: OrphanPull, _nowMs: number, probe?: (pid: number) => string | null): void;
321
+ /** Test seams for {@link defaultRefreshSync}. */
322
+ export interface RefreshSyncSpawnDeps {
323
+ command?: string;
324
+ args?: string[];
325
+ timeoutMs?: number;
326
+ maxBackgroundMs?: number;
327
+ nowMs?: () => number;
328
+ /** Directory holding hq-cloud's operation-lock files. */
329
+ locksDir?: string;
330
+ /** Process start-time probe for the spawn lock. */
331
+ identity?: RefreshOwnerIdentity;
332
+ }
333
+ /**
334
+ * How long a `--fix` pull waits for the operation lock. Unchanged from before
335
+ * the hand-off: a background pull must never queue on the lock longer than a
336
+ * foreground one did.
295
337
  */
296
- export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
338
+ export declare const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
297
339
  /**
298
340
  * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
299
- * against the tree `--fix` is repairing. Exit 0 also covers throttled,
300
- * disabled, and skipped passes, so the post-fix re-check decides whether the
301
- * row cleared. A timeout or non-zero exit is reported, not thrown.
341
+ * against the tree `--fix` is repairing, with `--json` so a pass that exits 0
342
+ * without uploading (soft_skipped, throttled, disabled, locked,
343
+ * resend_full_scheduled) is reported as not uploaded. A `resend_full` answer
344
+ * is retried once with `--full`. The post-fix re-check still decides whether
345
+ * the row cleared. A timeout or non-zero exit is reported, not thrown.
302
346
  */
303
347
  export declare function defaultRefreshManifest(target: RefreshManifestTarget): Promise<RefreshManifestResult>;
348
+ type ManifestSpawnResult = Pick<SpawnSyncReturns<string>, "error" | "status" | "signal"> & Partial<Pick<SpawnSyncReturns<string>, "stdout" | "stderr">>;
349
+ /**
350
+ * Run one upload and, when the server answered `resend_full` (it dropped or
351
+ * refused the delta base), run it once more with `--full`. Without the retry
352
+ * the scope stays stale until the next day's --fix run, and the health hook
353
+ * reports it as unrepaired in between (feedback_84d7a862).
354
+ */
355
+ export declare function refreshManifestWithRetry(scope: string, run: (full: boolean, json: boolean) => ManifestSpawnResult): RefreshManifestResult;
356
+ /** Commander's rejection of `--json` by a binary that predates it. */
357
+ export declare function manifestJsonUnsupported(result: ManifestSpawnResult): boolean;
358
+ /**
359
+ * The status token from the `hq sync manifest --json` line, or undefined when
360
+ * the output has none (an older binary, or a pass that printed only a summary).
361
+ */
362
+ export declare function manifestPassStatus(stdout: string | undefined): string | undefined;
304
363
  /**
305
364
  * Map one `hq sync manifest` spawn result to a {@link RefreshManifestResult}.
306
365
  * A spawn killed by the timeout has a null status, so it is reported as a
@@ -349,5 +408,4 @@ export declare function addHookIdToGateProfiles(source: string, hookId: string,
349
408
  * non-object `hooks` value — it is replaced rather than trusted.
350
409
  */
351
410
  export declare function registerHookInSettings(settings: unknown, event: string, command: string): Record<string, unknown>;
352
- export {};
353
411
  //# sourceMappingURL=apply.d.ts.map