@indigoai-us/hq-cli 5.345.168 → 5.345.169

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.169] — 2026-10-24
6
+ - Sentry error reports now redact CloudFront signed-URL params (`Policy`, `Key-Pair-Id`) and the `AWSAccessKeyId` and `GoogleAccessId` ids when they appear as structured keys in event data, for example a parsed query under `extra`. The feedback diagnostics scrubber already redacted them (#2177); the Sentry scrubber caught them only inside strings. The shared structured-key cases now run against the Sentry scrubber too.
7
+ - A debounced automatic checkpoint is now queued for the next checkpoint sibling. Before this, `hq core checkpoint` wrote the `-auto-` thread when the per-session debounce skipped the sibling, but it never queued the payload, so no sibling ever filled the thread in. The payload is now added to the pending queue once, and the next sibling run picks it up. The debounce still limits how many siblings start. When the pending queue goes over its cap of 50, a message on stderr now gives the number of oldest payloads dropped. Feedback a2a5c2c3-5ea0-4e1f-8155-1f39ce6ac750.
8
+
9
+ - `hq feedback` now redacts presigned URL signatures, session tokens, bearer tokens and other credentials from the diagnostics it attaches. Before this fix, recent Sentry breadcrumbs were copied into diagnostics without scrubbing, so an S3 presigned URL's X-Amz-Security-Token could reach the stored feedback row. Diagnostics passed in by callers are scrubbed too, and the inline log tails and the S3 log bundle now also catch bare, URL-encoded, CloudFront, GCS and Azure signed URL parameters.
10
+ - `hq doctor --fix` no longer starts a second background sync pull while a pull it stopped at its time limit is still shutting down. It takes the pull-start lock from another process only when that process has exited or its process id now belongs to a different process.
11
+ - `hq doctor --fix` can now refresh sync journals on drives without hard links (exFAT, FAT, some network drives). `hq doctor` warns, naming the process, when a journal refresh lock has been held for more than 10 minutes.
12
+ - `hq doctor --fix` no longer kills a slow sync journal pull after 90 seconds. A pull that is still running keeps going in the background for up to 10 minutes, one at a time, and `hq doctor` shows that journal as "refresh in progress" until it finishes. It never blocks your own `hq sync`, and it steps aside at once for `hq rescue` and other repairs, keeping what it already downloaded. A large company is no longer reported as stale every day.
13
+
5
14
  ## [5.345.168] — 2026-10-24
6
15
  - `hq dm` now picks the default audience from the recipient when no audience flag is passed. A 1:1 DM to a person (email, `prs_` uid, or a bare name that resolves to a person) sends `audience: "human"`. A 1:1 DM to an agent (`agt_` uid), a group DM and a channel post send `audience: "agent"`, so script and lane messages stay out of people's conversations. Scheduled sends (`--at`, `--in`) follow the same rule. Pass `--for-human` (alias for `--audience human`) to tag any message for people; an explicit `--audience human|agent|both` wins over `--for-human`, with a warning on stderr, and is validated on every path. `hq core delegate send` tags the pickup DM `audience: "both"`, `hq dm-bind post` keeps the channel default (`audience: "agent"`) for its automated status posts, and the agent MCP `hq_dm_send` tool, `hq agents message` and the `hq access` grant request apply the recipient rule. Reading commands (inbox, thread, channel) are unchanged, and `hq dm forward` does not set an audience.
7
16
  - `hq scan secrets` no longer exits 2 when a repo's `.gitleaks.toml` allowlist is rejected. In staged and file scans it now prints a fixed warning that quotes no part of the file and scans with no repo allowlist, so a real key is still reported (exit 1) and a commit that replaces a bad allowlist is not blocked. An unknown `--allowlist-rev` still exits 2.
@@ -1206,8 +1206,30 @@ function appendPending(pendingPath, payload) {
1206
1206
  // runs once the queue is already over cap, where newest-wins is the point.
1207
1207
  const kept = lines.slice(-MAX_PENDING_PAYLOADS);
1208
1208
  fs.writeFileSync(pendingPath, `${kept.join("\n")}\n`);
1209
+ // Say so instead of dropping silently: a wedged sibling shows up here.
1210
+ const dropped = lines.length - kept.length;
1211
+ printError(`checkpoint: pending queue over cap (${MAX_PENDING_PAYLOADS}); dropped ${dropped} oldest payload(s)`);
1209
1212
  return kept.length;
1210
1213
  }
1214
+ /**
1215
+ * Queue a payload for the next sibling run and record its ontology touches.
1216
+ * Shared by the busy-sibling path and the debounced automatic path, so both
1217
+ * hand their thread to the next run instead of leaving it unfilled.
1218
+ */
1219
+ async function queuePayload(liveRoot, payload) {
1220
+ const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
1221
+ fs.mkdirSync(siblingRoot, { recursive: true });
1222
+ const depth = appendPending(path.join(siblingRoot, "pending.jsonl"), payload);
1223
+ try {
1224
+ const { recordQueuedTouches } = await import("../lib/checkpoint-ontology/queued-touches.js");
1225
+ recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1226
+ }
1227
+ catch {
1228
+ // Queued touches only help a later ontology plan; they must never fail
1229
+ // the checkpoint itself.
1230
+ }
1231
+ return depth;
1232
+ }
1211
1233
  /**
1212
1234
  * Take ownership of the queued payloads by renaming the queue aside.
1213
1235
  *
@@ -1369,15 +1391,7 @@ async function startSibling(liveRoot, input, threadPath, backend) {
1369
1391
  const activePid = existingSiblingPid(lockPath, Date.now());
1370
1392
  fs.mkdirSync(siblingRoot, { recursive: true });
1371
1393
  if (activePid !== null) {
1372
- const depth = appendPending(pendingPath, payload);
1373
- try {
1374
- const { recordQueuedTouches } = await import("../lib/checkpoint-ontology/queued-touches.js");
1375
- recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1376
- }
1377
- catch {
1378
- // Queued touches only help a later ontology plan; they must never fail
1379
- // the checkpoint itself.
1380
- }
1394
+ const depth = await queuePayload(liveRoot, payload);
1381
1395
  printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1382
1396
  return;
1383
1397
  }
@@ -1579,7 +1593,13 @@ async function runCheckpoint(options, command, group, preResolvedHqRoot) {
1579
1593
  printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
1580
1594
  }
1581
1595
  else if (autoDebounced) {
1582
- printResult("checkpoint: sibling skipped (automatic checkpoint; one ran for this session recently)");
1596
+ // feedback_a2a5c2c3: the debounce limits how many siblings start, but
1597
+ // the thread still needs filling. Queue it once; the next sibling run
1598
+ // claims it. The debounce stamp is not refreshed, so the window still
1599
+ // closes on schedule and the next automatic checkpoint starts a run.
1600
+ const depth = await queuePayload(liveRoot, siblingPayload(input, threadPath, "none"));
1601
+ printResult("checkpoint: sibling skipped (automatic checkpoint; one ran for this session recently)" +
1602
+ ` — payload queued (${depth} pending)`);
1583
1603
  }
1584
1604
  else if (backend === "none") {
1585
1605
  printResult("checkpoint: sibling disabled (backend none)");
@@ -3,6 +3,7 @@ import chalk from "chalk";
3
3
  import { ensureCognitoToken, loadCachedTokens } from "../utils/cognito-session.js";
4
4
  import { assertVaultApiRequestSupported, vaultApiFetch, } from "../utils/vault-api.js";
5
5
  import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
6
+ import { scrubDiagnosticsString, scrubDiagnosticsValue } from "../utils/scrub-diagnostics.js";
6
7
  import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
7
8
  import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectFeedbackLogs, feedbackLogsEnabled, } from "../utils/feedback-logs.js";
8
9
  import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
@@ -79,8 +80,10 @@ export function resolveFeedbackReporter(env = process.env, isTTY = process.stdin
79
80
  function buildFeedbackRequestBody(opts) {
80
81
  const requestBody = {
81
82
  type: opts.type,
82
- title: opts.title,
83
- body: opts.body,
83
+ // Title and body are free text, and /hq-bug puts tool output in the body,
84
+ // so they get the same credential scrub as diagnostics.
85
+ title: scrubDiagnosticsString(opts.title),
86
+ body: scrubDiagnosticsString(opts.body),
84
87
  diagnostics: opts.diagnostics,
85
88
  };
86
89
  if (opts.reporterMark) {
@@ -157,7 +160,12 @@ export async function submitFeedback(opts) {
157
160
  // Keep submitFeedback safe for direct callers as well as the command path,
158
161
  // which invokes the same validator before uploading any attachments.
159
162
  validateFeedbackContent(opts.title, opts.body);
160
- const diagnostics = opts.diagnostics ?? collectDiagnostics();
163
+ // Scrub here, at the one point every submission passes through. Callers may
164
+ // hand in their own diagnostics (the command path collects them before
165
+ // uploads, and adds latestVersion afterwards), and a caller-supplied blob
166
+ // previously skipped redaction entirely. The scrubbed copy is the object
167
+ // attachDebugLogs later mutates, so inline logs still land in the request.
168
+ const diagnostics = scrubDiagnosticsValue(opts.diagnostics ?? collectDiagnostics());
161
169
  const reporterMark = opts.type === "bug" && resolveProcessFlagGate(FEEDBACK_REPORTER_KIND_FLAG, { fallback: FEEDBACK_REPORTER_KIND_FLAG_FALLBACK }, () => FEEDBACK_REPORTER_KIND_FLAG_FALLBACK)
162
170
  ? resolveFeedbackReporter(process.env, process.stdin.isTTY === true, opts.reporterOverride)
163
171
  : undefined;
@@ -116,6 +116,13 @@ export interface SyncHealthDeps {
116
116
  * since a re-upload would exit 0 without uploading.
117
117
  */
118
118
  manifestUploadsDisabled?: () => boolean;
119
+ /**
120
+ * Start time (epoch ms) of a live background `hq doctor --fix` pull for
121
+ * this refresh target (a company slug, or `personal`), or null. A journal
122
+ * whose refresh is still running is reported "refresh in progress" rather
123
+ * than stale (feedback cd56a501).
124
+ */
125
+ refreshInProgress?: (fixTarget: string) => number | null;
119
126
  }
120
127
  /**
121
128
  * True when `slug` is a filesystem-safe company folder name. Rejects empty
@@ -148,7 +155,6 @@ export declare function isPersonalJournalSlug(slug: string): boolean;
148
155
  export declare function journalSlugFromCheckId(checkId: string): string | null;
149
156
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
150
157
  export declare const syncHealthFamily: CheckFamily;
151
- /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
152
158
  export declare function checkSyncHealth(context: CheckContext, rawDeps?: SyncHealthDeps): CheckResult[];
153
159
  /** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
154
160
  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
@@ -63,6 +65,8 @@ function withDefaults(deps, hqRoot) {
63
65
  companySyncResolvable: deps.companySyncResolvable ??
64
66
  ((slug, lastSync) => !companyRecordedNotResolved(hqRoot, slug, deps.now().getTime(), lastSync)),
65
67
  manifestUploadsDisabled: deps.manifestUploadsDisabled ?? (() => manifestUploadsDisabled()),
68
+ refreshInProgress: deps.refreshInProgress ??
69
+ ((fixTarget) => refreshHandoffStartedAt(hqRoot, fixTarget, deps.now().getTime())),
66
70
  };
67
71
  }
68
72
  /**
@@ -294,6 +298,36 @@ export const syncHealthFamily = {
294
298
  run: (context) => Promise.resolve(checkSyncHealth(context)),
295
299
  };
296
300
  /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
301
+ /**
302
+ * WARN when the `hq doctor --fix` spawn lock has been held for over
303
+ * {@link REFRESH_SPAWN_LOCK_WARN_MS}, naming the holder pid, so a stalled or
304
+ * suspended holder is visible. Report only: the lock is never removed here.
305
+ */
306
+ function refreshSpawnLockResult(hqRoot, nowMs) {
307
+ let lock;
308
+ try {
309
+ lock = inspectRefreshSpawnLock(hqRoot, nowMs, DEFAULT_OWNER_IDENTITY);
310
+ }
311
+ catch {
312
+ return [];
313
+ }
314
+ if (!lock || lock.heldMs === null || lock.heldMs <= REFRESH_SPAWN_LOCK_WARN_MS)
315
+ return [];
316
+ // Left by a claimer that has exited: nothing to stop, and the next
317
+ // `hq doctor --fix` releases it.
318
+ if (lock.ownerGone)
319
+ return [];
320
+ const minutes = Math.floor(lock.heldMs / 60_000);
321
+ return [
322
+ {
323
+ status: "WARN",
324
+ checkId: "sync.refresh.spawn-lock",
325
+ reasonCode: "refresh-lock-held",
326
+ 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.`,
327
+ remediation: `If process ${lock.pid} is stuck or suspended, resume or stop it; the lock is released when it exits.`,
328
+ },
329
+ ];
330
+ }
297
331
  export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
298
332
  const deps = withDefaults(rawDeps, context.hqRoot);
299
333
  try {
@@ -301,6 +335,7 @@ export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
301
335
  ...versionResults(context, deps),
302
336
  ...updateAvailabilityResult(context, deps),
303
337
  ...journalAndManifestResults(deps),
338
+ ...refreshSpawnLockResult(context.hqRoot, deps.now().getTime()),
304
339
  ...deps.runtime(deps.now()),
305
340
  ];
306
341
  }
@@ -528,7 +563,7 @@ function journalResults(deps, journals) {
528
563
  if (entry.slug === PERSONAL_SCOPE_SLUG) {
529
564
  results.push(leftoverPersonalJournalResult(entry));
530
565
  if (!hasPersonalVaultJournal) {
531
- results.push(journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs));
566
+ results.push(withRefreshInProgress(deps, journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs), "personal"));
532
567
  }
533
568
  continue;
534
569
  }
@@ -570,10 +605,40 @@ function journalResults(deps, journals) {
570
605
  results.push(notOnThisMachineResult(entry, checkId));
571
606
  continue;
572
607
  }
573
- results.push(freshness);
608
+ results.push(withRefreshInProgress(deps, freshness, isPersonalJournalSlug(entry.slug) ? "personal" : entry.slug));
574
609
  }
575
610
  return results;
576
611
  }
612
+ /**
613
+ * A stale or never-synced journal whose `hq doctor --fix` pull is still
614
+ * running in the background is UNTESTED "refresh in progress", not WARN.
615
+ * The pull records lastSync only when it finishes, so until then the journal
616
+ * looks stale even though the repair is under way. Once the process is gone
617
+ * the record no longer counts and the WARN comes back, so a pull that died is
618
+ * retried by the next `--fix`.
619
+ */
620
+ function withRefreshInProgress(deps, result, fixTarget) {
621
+ if (result.status !== "WARN")
622
+ return result;
623
+ if (result.reasonCode !== "stale-threshold" && result.reasonCode !== "never-synced")
624
+ return result;
625
+ let startedAtMs;
626
+ try {
627
+ startedAtMs = deps.refreshInProgress(fixTarget);
628
+ }
629
+ catch {
630
+ return result;
631
+ }
632
+ if (startedAtMs === null)
633
+ return result;
634
+ return {
635
+ status: "UNTESTED",
636
+ checkId: result.checkId,
637
+ reasonCode: "refresh-in-progress",
638
+ ...(result.target ? { target: result.target } : {}),
639
+ message: `${result.message} hq doctor --fix is refreshing it in the background (started ${new Date(startedAtMs).toISOString()}); it passes when that pull finishes.`,
640
+ };
641
+ }
577
642
  function companySyncResolvable(deps, slug, lastSync) {
578
643
  try {
579
644
  return deps.companySyncResolvable(slug, lastSync);
@@ -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"];
@@ -290,10 +292,45 @@ export declare function unresolvedSyncChecks(findings: readonly CheckResult[], a
290
292
  /**
291
293
  * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
292
294
  * `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.
295
+ * `--fix` is repairing. Never a push, never interactive. A non-zero exit is
296
+ * reported rather than thrown so later plans still run.
297
+ *
298
+ * A pull still running after {@link REFRESH_SYNC_TIMEOUT_MS} is NOT killed
299
+ * (feedback cd56a501). A pull writes `lastSync` only when its pass finishes,
300
+ * so killing it at the deadline left a large vault, or a pull queued behind
301
+ * the sync runner's lock, stale on every run. It is left running detached and
302
+ * recorded in refresh-sync-handoff.ts; the journal check reports it as
303
+ * "refresh in progress" until it finishes.
304
+ */
305
+ export declare function defaultRefreshSync(target: RefreshSyncTarget, deps?: RefreshSyncSpawnDeps): Promise<RefreshSyncResult>;
306
+ /** Map a finished supervised pull to a {@link RefreshSyncResult}. */
307
+ export declare function harvestedRefreshSyncResult(harvested: HarvestedRefreshRun): RefreshSyncResult;
308
+ export { processStartTime };
309
+ /**
310
+ * Kill an orphaned --fix pull past its cap, but only when the pid still names
311
+ * that pull: its process start time must match the one recorded when it
312
+ * started. A reused pid has a different start time and is left alone. On
313
+ * Windows no start time is recorded, so nothing is killed there.
314
+ */
315
+ export declare function killOrphanPullIfSame(orphan: OrphanPull, _nowMs: number, probe?: (pid: number) => string | null): void;
316
+ /** Test seams for {@link defaultRefreshSync}. */
317
+ export interface RefreshSyncSpawnDeps {
318
+ command?: string;
319
+ args?: string[];
320
+ timeoutMs?: number;
321
+ maxBackgroundMs?: number;
322
+ nowMs?: () => number;
323
+ /** Directory holding hq-cloud's operation-lock files. */
324
+ locksDir?: string;
325
+ /** Process start-time probe for the spawn lock. */
326
+ identity?: RefreshOwnerIdentity;
327
+ }
328
+ /**
329
+ * How long a `--fix` pull waits for the operation lock. Unchanged from before
330
+ * the hand-off: a background pull must never queue on the lock longer than a
331
+ * foreground one did.
295
332
  */
296
- export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
333
+ export declare const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
297
334
  /**
298
335
  * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
299
336
  * against the tree `--fix` is repairing. Exit 0 also covers throttled,
@@ -349,5 +386,4 @@ export declare function addHookIdToGateProfiles(source: string, hookId: string,
349
386
  * non-object `hooks` value — it is replaced rather than trusted.
350
387
  */
351
388
  export declare function registerHookInSettings(settings: unknown, event: string, command: string): Record<string, unknown>;
352
- export {};
353
389
  //# sourceMappingURL=apply.d.ts.map
@@ -37,9 +37,10 @@
37
37
  * (the command resolves it once), keeping this module decoupled from the CLI and
38
38
  * trivially testable against a fake tree.
39
39
  */
40
- import { spawnSync } from "node:child_process";
40
+ import { spawn, spawnSync } from "node:child_process";
41
41
  import * as fs from "node:fs";
42
42
  import * as path from "node:path";
43
+ import { getStateDir } from "@indigoai-us/hq-cloud";
43
44
  import { isPersonalJournalSlug } from "../checks/sync-health.js";
44
45
  import { createDefaultRegistry } from "../registry.js";
45
46
  import { flattenFamilies } from "../report.js";
@@ -51,6 +52,8 @@ import { HQ_CLI_BINS, removeHqCliPs1Shims } from "../../windows/ps1-shims.js";
51
52
  import { botDir } from "../../bot/paths.js";
52
53
  import { readBotConfig } from "../../bot/config.js";
53
54
  import { buildBotDaemonPaths, installedDaemonHqBinary, repairBotDaemonIfStale, rewriteBotDaemonNodePath, } from "../../bot/daemon.js";
55
+ import { DEFAULT_OWNER_IDENTITY, processStartTime } from "./process-identity.js";
56
+ import { REFRESH_BACKGROUND_MAX_MS, REFRESH_SUPERVISOR_SOURCE, anyRefreshHandoffAlive, harvestRefreshHandoffs, releaseDeadRefreshSpawnLock, newRefreshRunPaths, readRefreshRunExit, readRefreshRunLog, claimRefreshRun, clearRefreshRecord, removeRefreshRun, } from "./refresh-sync-handoff.js";
54
57
  import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
55
58
  import { pullOutputShowsCompanyNotResolved, recordCompanyResolution, } from "../company-resolution-state.js";
56
59
  import { readRefreshManifestCursor, readRefreshSyncProgress, writeRefreshManifestCursor, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
@@ -184,6 +187,10 @@ export async function applyFixes(options) {
184
187
  };
185
188
  const refreshSync = async (target) => {
186
189
  const result = await performRefreshSync(target);
190
+ // Still running in the background: no evidence about the company yet.
191
+ // The next --fix harvests its real exit before it plans anything.
192
+ if (result.outcome === "pull-handed-off")
193
+ return result;
187
194
  if (target.personal)
188
195
  return result;
189
196
  noteCompanyOutcome(target.slug, result.ok, result.companyNotResolved === true);
@@ -205,6 +212,17 @@ export async function applyFixes(options) {
205
212
  // 2. Collect findings, then plan each newly unlocked allowlisted repair.
206
213
  // Identity is deliberately the check id + target pair: an unsuccessful
207
214
  // repair must not be applied repeatedly in one invocation.
215
+ // Background pulls from an earlier --fix: record how each really ended
216
+ // before planning, then drop the finished records (feedback cd56a501).
217
+ // A spawn lock left by a crashed claimer is released here when its owner is
218
+ // proven gone, even if no journal is stale this run.
219
+ releaseDeadRefreshSpawnLock(hqRoot, Date.now(), DEFAULT_OWNER_IDENTITY);
220
+ for (const harvested of harvestRefreshHandoffs(hqRoot, Date.now(), killOrphanPullIfSame)) {
221
+ const outcome = harvestedRefreshSyncResult(harvested);
222
+ if (harvested.slug === "personal" || isPersonalJournalSlug(harvested.slug))
223
+ continue;
224
+ noteCompanyOutcome(harvested.slug, outcome.ok, outcome.companyNotResolved === true);
225
+ }
208
226
  let findings = await collect(hqRoot);
209
227
  let lastAttemptedRefreshSyncSlug = readRefreshSyncProgress(hqRoot)?.lastAttemptedSlug ?? null;
210
228
  let refreshSyncAppliedThisRun = 0;
@@ -333,6 +351,11 @@ export async function applyFixes(options) {
333
351
  // left over and reported "not attempted" rather than as a failed repair.
334
352
  if (network && !networkBudgetLeft())
335
353
  continue;
354
+ // One background pull per HQ root. While it runs it holds the operation
355
+ // lock, so another pull would only wait on it; leave the rest for the
356
+ // next run.
357
+ if (plan.fixClass === "refresh-sync" && anyRefreshHandoffAlive(hqRoot, Date.now()))
358
+ continue;
336
359
  if (plan.fixClass === "refresh-sync") {
337
360
  // Persist before starting the bounded pull. If this process is killed,
338
361
  // the next invocation resumes after this company instead of starving
@@ -540,85 +563,176 @@ async function defaultRunChecks(hqRoot) {
540
563
  /**
541
564
  * Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
542
565
  * `hq sync pull --company <slug>` (or `--personal`) against the tree
543
- * `--fix` is repairing. Never a push, never interactive. A timeout or
544
- * non-zero exit is reported rather than thrown so later plans still run.
566
+ * `--fix` is repairing. Never a push, never interactive. A non-zero exit is
567
+ * reported rather than thrown so later plans still run.
568
+ *
569
+ * A pull still running after {@link REFRESH_SYNC_TIMEOUT_MS} is NOT killed
570
+ * (feedback cd56a501). A pull writes `lastSync` only when its pass finishes,
571
+ * so killing it at the deadline left a large vault, or a pull queued behind
572
+ * the sync runner's lock, stale on every run. It is left running detached and
573
+ * recorded in refresh-sync-handoff.ts; the journal check reports it as
574
+ * "refresh in progress" until it finishes.
545
575
  */
546
- export function defaultRefreshSync(target) {
576
+ export async function defaultRefreshSync(target, deps = {}) {
547
577
  const hqBin = process.argv[1];
548
578
  const selector = target.personal || isPersonalJournalSlug(target.slug)
549
579
  ? ["--personal"]
550
580
  : ["--company", target.slug];
551
- const args = hqBin
552
- ? [
553
- hqBin,
554
- "sync",
555
- "pull",
556
- ...selector,
557
- "--hq-root",
558
- target.hqRoot,
559
- "--on-conflict",
560
- "keep",
561
- "--lock-timeout",
562
- "30",
563
- ]
564
- : [
565
- "sync",
566
- "pull",
567
- ...selector,
568
- "--hq-root",
569
- target.hqRoot,
570
- "--on-conflict",
571
- "keep",
572
- "--lock-timeout",
573
- "30",
574
- ];
575
- const result = spawnSync(hqBin ? process.execPath : "hq", args, {
576
- encoding: "utf8",
577
- timeout: REFRESH_SYNC_TIMEOUT_MS,
578
- cwd: target.hqRoot,
579
- });
580
- if (result.error) {
581
- const spawnError = result.error;
582
- const outcome = classifyRefreshSyncOutcome({
583
- errorCode: spawnError.code,
584
- ...(result.error.message.includes("ETIMEDOUT")
585
- ? { errorCode: "ETIMEDOUT" }
586
- : {}),
587
- signal: result.signal,
588
- status: result.status,
589
- stdout: result.stdout,
590
- stderr: result.stderr,
591
- });
592
- return Promise.resolve({
581
+ const pullArgs = [
582
+ "sync",
583
+ "pull",
584
+ ...selector,
585
+ "--hq-root",
586
+ target.hqRoot,
587
+ "--on-conflict",
588
+ "keep",
589
+ "--lock-timeout",
590
+ String(REFRESH_SYNC_LOCK_TIMEOUT_SEC),
591
+ ];
592
+ // Run the same CLI script under this node. Without a script path there is no
593
+ // safe way to start `hq` on Windows (it is a .cmd shim that needs a shell),
594
+ // so only POSIX falls back to `hq` on PATH.
595
+ if (!deps.command && !hqBin && process.platform === "win32") {
596
+ return {
593
597
  ok: false,
594
- outcome,
595
- message: outcome === "pull-timeout"
596
- ? `timed out pulling '${target.slug}'`
597
- : `could not start pull for '${target.slug}': ${result.error.message}`,
598
- });
598
+ outcome: "pull-failed:other",
599
+ message: `could not start pull for '${target.slug}': the hq CLI script path is unknown`,
600
+ };
599
601
  }
600
- if (result.status !== 0) {
601
- const outcome = classifyRefreshSyncOutcome({
602
- status: result.status,
603
- stdout: result.stdout,
604
- stderr: result.stderr,
605
- });
606
- const detail = (result.stderr || result.stdout || "").trim().split("\n").at(-1) ?? "";
607
- return Promise.resolve({
602
+ const command = deps.command ?? (hqBin ? process.execPath : "hq");
603
+ const args = deps.args ?? (hqBin ? [hqBin, ...pullArgs] : pullArgs);
604
+ const timeoutMs = deps.timeoutMs ?? REFRESH_SYNC_TIMEOUT_MS;
605
+ const maxBackgroundMs = deps.maxBackgroundMs ?? REFRESH_BACKGROUND_MAX_MS;
606
+ const nowMs = deps.nowMs ?? (() => Date.now());
607
+ // The pull runs under a supervisor that records its real exit status, so a
608
+ // pull left running past the deadline is still classified by the next run.
609
+ const run = newRefreshRunPaths(target.hqRoot, target.slug);
610
+ // One pull per HQ root: claim it (record written under the spawn lock)
611
+ // before spawning, so a concurrent --fix sees this pull and backs off.
612
+ if (!claimRefreshRun(target.hqRoot, target.slug, run, nowMs(), deps.identity ?? DEFAULT_OWNER_IDENTITY)) {
613
+ return {
608
614
  ok: false,
609
- outcome,
610
- message: `pull of '${target.slug}' exited ${result.status}${detail ? `: ${detail}` : ""}`,
611
- companyNotResolved: !target.personal &&
612
- !isPersonalJournalSlug(target.slug) &&
613
- pullOutputShowsCompanyNotResolved(`${result.stdout ?? ""}\n${result.stderr ?? ""}`),
614
- });
615
+ outcome: "pull-failed:lock-busy",
616
+ message: `another hq doctor --fix pull is already running; '${target.slug}' is left for the next run`,
617
+ };
618
+ }
619
+ let supervisor;
620
+ try {
621
+ fs.mkdirSync(path.dirname(run.log), { recursive: true });
622
+ supervisor = spawn(process.execPath, [
623
+ "-e",
624
+ REFRESH_SUPERVISOR_SOURCE,
625
+ run.log,
626
+ run.exit,
627
+ run.heartbeat,
628
+ run.pid,
629
+ run.cancel,
630
+ deps.locksDir ?? defaultLocksDir(),
631
+ String(maxBackgroundMs),
632
+ command,
633
+ ...args,
634
+ ], { cwd: target.hqRoot, detached: true, stdio: "ignore", windowsHide: true });
635
+ }
636
+ catch (error) {
637
+ clearRefreshRecord(target.hqRoot, target.slug, run);
638
+ removeRefreshRun(run);
639
+ return {
640
+ ok: false,
641
+ outcome: classifyRefreshSyncOutcome({ errorCode: error.code }),
642
+ message: `could not start pull for '${target.slug}': ${error.message}`,
643
+ };
615
644
  }
616
- return Promise.resolve({
617
- ok: true,
618
- outcome: "pull-succeeded",
619
- message: `pulled '${target.slug}'`,
645
+ const finished = await new Promise((resolve) => {
646
+ let settled = false;
647
+ const settle = (value) => {
648
+ if (settled)
649
+ return;
650
+ settled = true;
651
+ clearTimeout(timer);
652
+ resolve(value);
653
+ };
654
+ supervisor.once("error", () => settle(true));
655
+ supervisor.once("exit", () => settle(true));
656
+ const timer = setTimeout(() => settle(false), timeoutMs);
620
657
  });
658
+ if (!finished) {
659
+ // Leave the pull running on its own. It records lastSync when it
660
+ // finishes; until then the journal check reports "refresh in progress".
661
+ supervisor.removeAllListeners();
662
+ supervisor.unref();
663
+ return {
664
+ ok: false,
665
+ outcome: "pull-handed-off",
666
+ message: `still pulling '${target.slug}' after ${timeoutMs / 1000}s; it keeps running in the background`,
667
+ };
668
+ }
669
+ const result = harvestedRefreshSyncResult({
670
+ slug: target.slug,
671
+ exit: readRefreshRunExit(run),
672
+ output: readRefreshRunLog(run),
673
+ });
674
+ clearRefreshRecord(target.hqRoot, target.slug, run);
675
+ removeRefreshRun(run);
676
+ return result;
677
+ }
678
+ /** Map a finished supervised pull to a {@link RefreshSyncResult}. */
679
+ export function harvestedRefreshSyncResult(harvested) {
680
+ const { slug, exit, output } = harvested;
681
+ if (!exit) {
682
+ return { ok: false, outcome: "pull-failed:other", message: `pull of '${slug}' ended without a result` };
683
+ }
684
+ if (exit.capped) {
685
+ // Stopped at the background time cap. Completed downloads are kept.
686
+ return { ok: false, outcome: "pull-timeout-capped", message: `stopped pulling '${slug}' at the background time limit` };
687
+ }
688
+ if (exit.preempted) {
689
+ // Stopped so a sync the user or the runner started could take the lock.
690
+ // Completed downloads are kept; the next --fix resumes the journal.
691
+ return { ok: false, outcome: "pull-preempted", message: `paused pulling '${slug}' for another sync` };
692
+ }
693
+ if (exit.status === 0 && !exit.signal) {
694
+ return { ok: true, outcome: "pull-succeeded", message: `pulled '${slug}'` };
695
+ }
696
+ const outcome = classifyRefreshSyncOutcome({ status: exit.status, signal: exit.signal, stdout: output });
697
+ const detail = output.trim().split("\n").at(-1) ?? "";
698
+ const personal = slug === "personal" || isPersonalJournalSlug(slug);
699
+ return {
700
+ ok: false,
701
+ outcome,
702
+ message: `pull of '${slug}' exited ${exit.status ?? exit.signal}${detail ? `: ${detail}` : ""}`,
703
+ companyNotResolved: !personal && pullOutputShowsCompanyNotResolved(output),
704
+ };
621
705
  }
706
+ export { processStartTime };
707
+ /**
708
+ * Kill an orphaned --fix pull past its cap, but only when the pid still names
709
+ * that pull: its process start time must match the one recorded when it
710
+ * started. A reused pid has a different start time and is left alone. On
711
+ * Windows no start time is recorded, so nothing is killed there.
712
+ */
713
+ export function killOrphanPullIfSame(orphan, _nowMs, probe = processStartTime) {
714
+ if (process.platform === "win32" || !orphan.recordedStart)
715
+ return;
716
+ const current = probe(orphan.pid);
717
+ if (!current || current !== orphan.recordedStart)
718
+ return;
719
+ try {
720
+ process.kill(-orphan.pid, "SIGKILL");
721
+ }
722
+ catch {
723
+ // gone
724
+ }
725
+ }
726
+ /** hq-cloud keeps operation locks in `<stateDir>/locks`. */
727
+ function defaultLocksDir() {
728
+ return path.join(getStateDir(), "locks");
729
+ }
730
+ /**
731
+ * How long a `--fix` pull waits for the operation lock. Unchanged from before
732
+ * the hand-off: a background pull must never queue on the lock longer than a
733
+ * foreground one did.
734
+ */
735
+ export const REFRESH_SYNC_LOCK_TIMEOUT_SEC = 30;
622
736
  /**
623
737
  * Default `refresh-manifest` repair: a bounded `hq sync manifest --scope <s>`
624
738
  * against the tree `--fix` is repairing. Exit 0 also covers throttled,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Process start times, used to tell the owner of a pid from a later process
3
+ * that reused it (feedback cd56a501).
4
+ */
5
+ import type { RefreshOwnerIdentity } from "./refresh-sync-handoff.js";
6
+ /**
7
+ * A process's start time, or null if it cannot be read: `ps -o lstart=` on
8
+ * POSIX, the process StartTime from PowerShell on Windows.
9
+ */
10
+ export declare function processStartTime(pid: number): string | null;
11
+ /** Real process-table identity for the refresh spawn lock. */
12
+ export declare const DEFAULT_OWNER_IDENTITY: RefreshOwnerIdentity;
13
+ //# sourceMappingURL=process-identity.d.ts.map