@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 +9 -0
- package/dist/commands/core-checkpoint-native.js +30 -10
- package/dist/commands/feedback.js +11 -3
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -1
- package/dist/lib/doctor/checks/sync-health.js +67 -2
- package/dist/lib/doctor/fix/apply.d.ts +40 -4
- package/dist/lib/doctor/fix/apply.js +183 -69
- package/dist/lib/doctor/fix/process-identity.d.ts +13 -0
- package/dist/lib/doctor/fix/process-identity.js +34 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.d.ts +201 -0
- package/dist/lib/doctor/fix/refresh-sync-handoff.js +752 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +1 -1
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +13 -0
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +2 -0
- package/dist/sentry-before-send.js +13 -0
- package/dist/utils/breadcrumb-buffer.js +15 -4
- package/dist/utils/feedback-diagnostics.js +5 -2
- package/dist/utils/feedback-logs.d.ts +7 -0
- package/dist/utils/feedback-logs.js +37 -3
- package/dist/utils/scrub-diagnostics.d.ts +4 -0
- package/dist/utils/scrub-diagnostics.js +95 -0
- package/package.json +7 -7
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 =
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
*
|
|
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
|
|
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
|
|
544
|
-
*
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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:
|
|
596
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|