@indigoai-us/hq-cli 5.345.202 → 5.345.204

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 (64) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/command-catalog.generated.d.ts +24 -0
  3. package/dist/command-catalog.generated.js +26 -0
  4. package/dist/commands/bot.js +7 -2
  5. package/dist/commands/capacity.js +10 -2
  6. package/dist/commands/cloud.d.ts +14 -2
  7. package/dist/commands/cloud.js +25 -16
  8. package/dist/commands/daemon.d.ts +2 -1
  9. package/dist/commands/daemon.js +225 -4
  10. package/dist/commands/integrations-core.d.ts +3 -0
  11. package/dist/commands/integrations-core.js +11 -0
  12. package/dist/commands/integrations-manage.js +2 -2
  13. package/dist/commands/integrations.js +5 -5
  14. package/dist/commands/sync-core.js +4 -1
  15. package/dist/lib/bot/claude-login.d.ts +7 -1
  16. package/dist/lib/bot/claude-login.js +56 -16
  17. package/dist/lib/bot/executive-settings.d.ts +1 -1
  18. package/dist/lib/bot/executive-settings.js +8 -6
  19. package/dist/lib/bot/jobs/meeting-detect.js +2 -2
  20. package/dist/lib/bot/jobs/meeting-rider.js +2 -2
  21. package/dist/lib/bot/run.js +3 -3
  22. package/dist/lib/capacity/codex-availability.d.ts +6 -0
  23. package/dist/lib/capacity/codex-availability.js +33 -0
  24. package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
  25. package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
  26. package/dist/lib/capacity/ledger.d.ts +0 -11
  27. package/dist/lib/capacity/ledger.js +30 -3
  28. package/dist/lib/capacity/route.js +11 -5
  29. package/dist/lib/daemon/catalog.d.ts +3 -0
  30. package/dist/lib/daemon/catalog.js +17 -4
  31. package/dist/lib/daemon/handoff.d.ts +2 -0
  32. package/dist/lib/daemon/handoff.js +9 -2
  33. package/dist/lib/daemon/install.d.ts +99 -0
  34. package/dist/lib/daemon/install.js +316 -7
  35. package/dist/lib/daemon/migrate.d.ts +3 -1
  36. package/dist/lib/daemon/migrate.js +14 -2
  37. package/dist/lib/daemon/plist-refresh.d.ts +34 -0
  38. package/dist/lib/daemon/plist-refresh.js +62 -0
  39. package/dist/lib/daemon/run.js +27 -2
  40. package/dist/lib/daemon/selfcheck.d.ts +71 -0
  41. package/dist/lib/daemon/selfcheck.js +150 -0
  42. package/dist/lib/daemon/supervisor.d.ts +31 -0
  43. package/dist/lib/daemon/supervisor.js +130 -6
  44. package/dist/lib/daemon/unit-holds.js +2 -1
  45. package/dist/lib/lanes/config.js +13 -0
  46. package/dist/lib/lanes/throttle.d.ts +12 -1
  47. package/dist/lib/lanes/throttle.js +27 -8
  48. package/dist/lib/lanes/types.d.ts +5 -0
  49. package/dist/lib/service-manager/launchd.d.ts +25 -0
  50. package/dist/lib/service-manager/launchd.js +51 -3
  51. package/dist/lib/service-manager/migration.d.ts +14 -0
  52. package/dist/lib/service-manager/migration.js +34 -0
  53. package/dist/lib/service-manager/systemd.d.ts +13 -0
  54. package/dist/lib/service-manager/systemd.js +19 -0
  55. package/dist/lib/service-manager/types.d.ts +8 -0
  56. package/dist/lib/signals/steps/extract.d.ts +110 -4
  57. package/dist/lib/signals/steps/extract.js +520 -39
  58. package/dist/lib/sync-core/engine-home.d.ts +2 -1
  59. package/dist/lib/sync-core/engine-home.js +25 -8
  60. package/dist/lib/sync-core/reconcile.d.ts +7 -2
  61. package/dist/lib/sync-core/reconcile.js +32 -9
  62. package/dist/lib/sync-core/release.d.ts +35 -3
  63. package/dist/lib/sync-core/release.js +95 -12
  64. package/package.json +7 -7
@@ -36,6 +36,7 @@ import { createLanesReconcileWatcher } from "./lanes-reconcile-watcher.js";
36
36
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup, } from "./legacy.js";
37
37
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
38
38
  import { readVaultSyncSwitches, unreadableSwitchesWarning, writeVaultSyncSwitches } from "../vault-sync-switches.js";
39
+ import { scheduleDaemonPlistRefresh } from "./plist-refresh.js";
39
40
  import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
40
41
  import { retireLegacyMeshUnits } from "./migrate.js";
41
42
  import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
@@ -81,7 +82,7 @@ export function syncCoreVaultPlans(home, hqRoot, platform) {
81
82
  release: state.release,
82
83
  ...(vault.legacyOffSince ? { legacyOffSince: vault.legacyOffSince } : {}),
83
84
  }, platform);
84
- const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release) : undefined;
85
+ const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release, platform) : undefined;
85
86
  return releaseHold ? { ...plan, blockedReason: releaseHold } : plan;
86
87
  });
87
88
  }
@@ -114,7 +115,7 @@ export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
114
115
  let restorePersonal = false;
115
116
  const restoreCompanies = {};
116
117
  for (const vault of context.syncCoreVaults ?? []) {
117
- if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release))
118
+ if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release, context.platform))
118
119
  continue;
119
120
  if (vault.kind === "personal") {
120
121
  restorePersonal = personalOff;
@@ -658,6 +659,21 @@ export async function runDaemon(opts) {
658
659
  }
659
660
  const hostNote = opts.host ? `, hosted by the ${opts.host} app` : "";
660
661
  log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})${hostNote}`);
662
+ try {
663
+ scheduleDaemonPlistRefresh({
664
+ platform,
665
+ managedByLaunchd: opts.managed === true && opts.host === undefined,
666
+ home,
667
+ daemonRoot: paths.root,
668
+ nodeBinary: opts.nodeBinary,
669
+ hqBinary: opts.hqBinary,
670
+ env,
671
+ log,
672
+ });
673
+ }
674
+ catch (err) {
675
+ log("warn", `could not check the daemon LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
676
+ }
661
677
  // On Linux, services can outlive a daemon (KillMode=process). Adopt the ones
662
678
  // the previous daemon handed over and stop any others it left behind before
663
679
  // starting anything, so a service never runs twice.
@@ -1264,6 +1280,15 @@ export async function runDaemon(opts) {
1264
1280
  read: () => readUnitHolds(paths.holdsPath),
1265
1281
  write: (holds) => writeUnitHolds(paths.holdsPath, holds),
1266
1282
  },
1283
+ pidAlive: (pid) => {
1284
+ try {
1285
+ process.kill(pid, 0);
1286
+ return true;
1287
+ }
1288
+ catch (err) {
1289
+ return err.code === "EPERM";
1290
+ }
1291
+ },
1267
1292
  exitOutputTail: (unitId) => {
1268
1293
  // An adopted unit was started by an earlier daemon; its output start is unknown.
1269
1294
  const offset = unitLogOffsets.get(unitId);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The daemon's sync-engine self-check (`hq daemon service sync-core-selfcheck`).
3
+ *
4
+ * It reads the supervisor's state and holds and finds engine units
5
+ * (`sync-core:<vault>`) that have been held, or failing without a heartbeat,
6
+ * for more than ATTENTION_AFTER_MS. When there are any, it sends one DM to the
7
+ * person signed in on this machine, at most once a day per machine whichever
8
+ * units are affected, naming every affected vault in that one DM. The 16-hour outage of 2026-11-03 was
9
+ * found by hand; this makes the next one reach the owner.
10
+ */
11
+ import type { DaemonState, PersistedHolds } from "./supervisor.js";
12
+ /** An engine unit that has been held or failing this long needs a person. */
13
+ export declare const ATTENTION_AFTER_MS: number;
14
+ /** This machine sends at most one self-check DM per this long. */
15
+ export declare const REPORT_INTERVAL_MS: number;
16
+ export interface EngineUnitProblem {
17
+ unitId: string;
18
+ /** Epoch ms since when the unit has been held or failing. */
19
+ since: number;
20
+ /** One line for the DM. */
21
+ what: string;
22
+ }
23
+ /** Engine units held, or failing without a heartbeat, for more than ATTENTION_AFTER_MS at `now`. */
24
+ export declare function engineUnitsNeedingAttention(state: DaemonState | null, holds: PersistedHolds, now: number,
25
+ /** False for a vault that is skipped on this machine or not on the rollout. */
26
+ wanted?: (vaultUid: string) => boolean): EngineUnitProblem[];
27
+ export interface SelfCheckRecord {
28
+ lastSentAt: number;
29
+ /** Sorted unit ids last reported (for the log; the rate limit ignores them). */
30
+ units: string[];
31
+ }
32
+ export declare function readSelfCheckRecord(file: string): SelfCheckRecord | null;
33
+ export declare function writeSelfCheckRecord(file: string, record: SelfCheckRecord): void;
34
+ /**
35
+ * Send now: something is wrong and this machine has not sent a self-check DM
36
+ * in the last day. Which units are affected does not matter, so units that
37
+ * take turns failing cannot send a DM on every change.
38
+ */
39
+ export declare function shouldReport(problems: EngineUnitProblem[], last: SelfCheckRecord | null, now: number): boolean;
40
+ /** The DM text for the owner. Plain, with the commands that fix each case. */
41
+ export declare function selfCheckMessage(problems: EngineUnitProblem[], machine: string,
42
+ /** A name for a vault (company slug, "personal"), when known. */
43
+ vaultName?: (vaultUid: string) => string | undefined): {
44
+ body: string;
45
+ details: string;
46
+ };
47
+ export interface SelfCheckDeps {
48
+ statePath: string;
49
+ holdsPath: string;
50
+ recordPath: string;
51
+ now: () => number;
52
+ machine: string;
53
+ readState: () => DaemonState | null;
54
+ readHolds: () => PersistedHolds;
55
+ /** False for a vault that is skipped here or not on the rollout. Default: every vault. */
56
+ wanted?: (vaultUid: string) => boolean;
57
+ /** A name for a vault, for the DM. */
58
+ vaultName?: (vaultUid: string) => string | undefined;
59
+ /**
60
+ * Send the DM to the person signed in on this machine; resolves to who it
61
+ * went to, or null when no person is signed in (an agent identity).
62
+ */
63
+ sendToOwner: (message: {
64
+ body: string;
65
+ details: string;
66
+ }) => Promise<string | null>;
67
+ log: (line: string) => void;
68
+ }
69
+ export type SelfCheckOutcome = "healthy" | "reported" | "already-reported" | "no-owner";
70
+ export declare function runSelfCheck(deps: SelfCheckDeps): Promise<SelfCheckOutcome>;
71
+ //# sourceMappingURL=selfcheck.d.ts.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The daemon's sync-engine self-check (`hq daemon service sync-core-selfcheck`).
3
+ *
4
+ * It reads the supervisor's state and holds and finds engine units
5
+ * (`sync-core:<vault>`) that have been held, or failing without a heartbeat,
6
+ * for more than ATTENTION_AFTER_MS. When there are any, it sends one DM to the
7
+ * person signed in on this machine, at most once a day per machine whichever
8
+ * units are affected, naming every affected vault in that one DM. The 16-hour outage of 2026-11-03 was
9
+ * found by hand; this makes the next one reach the owner.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { syncCoreUnitId } from "../sync-core/engine-home.js";
14
+ import { ensurePrivateDir } from "./paths.js";
15
+ /** An engine unit that has been held or failing this long needs a person. */
16
+ export const ATTENTION_AFTER_MS = 30 * 60_000;
17
+ /** This machine sends at most one self-check DM per this long. */
18
+ export const REPORT_INTERVAL_MS = 24 * 60 * 60_000;
19
+ const ENGINE_UNIT_PREFIX = syncCoreUnitId("");
20
+ /** Engine units held, or failing without a heartbeat, for more than ATTENTION_AFTER_MS at `now`. */
21
+ export function engineUnitsNeedingAttention(state, holds, now,
22
+ /** False for a vault that is skipped on this machine or not on the rollout. */
23
+ wanted = () => true) {
24
+ const problems = [];
25
+ const units = state?.units ?? [];
26
+ const seen = new Set();
27
+ for (const unit of units) {
28
+ if (!unit.id.startsWith(ENGINE_UNIT_PREFIX))
29
+ continue;
30
+ if (!wanted(unit.id.slice(ENGINE_UNIT_PREFIX.length)))
31
+ continue;
32
+ seen.add(unit.id);
33
+ const problem = unitProblem(unit, holds[unit.id], state, now);
34
+ if (problem && now - problem.since > ATTENTION_AFTER_MS)
35
+ problems.push(problem);
36
+ }
37
+ // With no daemon state, the persisted holds are all there is to go on. With
38
+ // state, a hold for a unit the daemon does not supervise is left over from a
39
+ // vault that is no longer on the engine: it stops nothing, so it is not reported.
40
+ if (state)
41
+ return problems.sort((a, b) => a.unitId.localeCompare(b.unitId));
42
+ for (const [unitId, hold] of Object.entries(holds)) {
43
+ if (seen.has(unitId) || !unitId.startsWith(ENGINE_UNIT_PREFIX) || hold.retryStopped === true)
44
+ continue;
45
+ if (!wanted(unitId.slice(ENGINE_UNIT_PREFIX.length)))
46
+ continue;
47
+ if (now - hold.since > ATTENTION_AFTER_MS) {
48
+ problems.push({ unitId, since: hold.since, what: `held since ${iso(hold.since)}: ${hold.detail}` });
49
+ }
50
+ }
51
+ return problems.sort((a, b) => a.unitId.localeCompare(b.unitId));
52
+ }
53
+ function unitProblem(unit, hold, state, now) {
54
+ // Stopped on request (`hq daemon stop`, or a stopped recovery probe) is not failing.
55
+ if (unit.hold?.retryStopped === true || hold?.retryStopped === true)
56
+ return null;
57
+ if (unit.status === "held") {
58
+ const since = unit.hold?.since ?? hold?.since ?? now;
59
+ const detail = unit.hold?.detail ?? hold?.detail ?? unit.reason ?? "held";
60
+ return { unitId: unit.id, since, what: `held since ${iso(since)}: ${detail}` };
61
+ }
62
+ // Paused on purpose (sync paused, waiting for the legacy sync to hand over) is not failing.
63
+ if (unit.status !== "backoff" && unit.status !== "exited" && unit.status !== "running")
64
+ return null;
65
+ const last = unit.lastHeartbeatAt ? Date.parse(unit.lastHeartbeatAt) : Number.NaN;
66
+ // No heartbeat yet: count from when the daemon started supervising it.
67
+ const since = Number.isFinite(last) ? last : state.startedAt;
68
+ if (unit.status === "running" && unit.restarts === 0)
69
+ return null;
70
+ if (unit.status === "running" && Number.isFinite(last) && now - last <= ATTENTION_AFTER_MS)
71
+ return null;
72
+ const exit = unit.lastExit ? ` (last exit code ${unit.lastExit.code ?? "none"}${unit.lastExit.signal ? `, signal ${unit.lastExit.signal}` : ""})` : "";
73
+ return {
74
+ unitId: unit.id,
75
+ since,
76
+ what: `${unit.status === "running" ? "restarting" : "failing"} with no heartbeat since ${iso(since)}, restarted ${unit.restarts} times${exit}`,
77
+ };
78
+ }
79
+ function iso(ms) {
80
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
81
+ }
82
+ export function readSelfCheckRecord(file) {
83
+ try {
84
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
85
+ if (typeof parsed.lastSentAt !== "number" || !Array.isArray(parsed.units))
86
+ return null;
87
+ return { lastSentAt: parsed.lastSentAt, units: parsed.units.filter((u) => typeof u === "string") };
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ export function writeSelfCheckRecord(file, record) {
94
+ ensurePrivateDir(path.dirname(file));
95
+ const tmp = `${file}.${process.pid}.tmp`;
96
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
97
+ fs.renameSync(tmp, file);
98
+ }
99
+ /**
100
+ * Send now: something is wrong and this machine has not sent a self-check DM
101
+ * in the last day. Which units are affected does not matter, so units that
102
+ * take turns failing cannot send a DM on every change.
103
+ */
104
+ export function shouldReport(problems, last, now) {
105
+ if (problems.length === 0)
106
+ return false;
107
+ if (!last)
108
+ return true;
109
+ return now - last.lastSentAt >= REPORT_INTERVAL_MS;
110
+ }
111
+ /** The DM text for the owner. Plain, with the commands that fix each case. */
112
+ export function selfCheckMessage(problems, machine,
113
+ /** A name for a vault (company slug, "personal"), when known. */
114
+ vaultName = () => undefined) {
115
+ const named = (p) => {
116
+ const uid = p.unitId.slice(ENGINE_UNIT_PREFIX.length);
117
+ const name = vaultName(uid);
118
+ return name ? `${name} (${uid})` : uid;
119
+ };
120
+ const names = problems.map(named).join(", ");
121
+ const body = `HQ sync on ${machine}: the sync engine for ${names} has not synced for over ${ATTENTION_AFTER_MS / 60_000} minutes. ` +
122
+ "Run `hq daemon status` on that machine.";
123
+ const lines = problems.map((p) => `- ${named(p)}: ${p.what}. Retry with: hq daemon restart ${p.unitId}`);
124
+ lines.push("", "Logs: hq daemon logs <unit>. This check runs in hq daemon and sends at most one DM a day from this machine.");
125
+ return { body, details: lines.join("\n") };
126
+ }
127
+ export async function runSelfCheck(deps) {
128
+ const now = deps.now();
129
+ const problems = engineUnitsNeedingAttention(deps.readState(), deps.readHolds(), now, deps.wanted);
130
+ if (problems.length === 0) {
131
+ deps.log("sync engine self-check: every engine unit is healthy");
132
+ return "healthy";
133
+ }
134
+ for (const p of problems)
135
+ deps.log(`sync engine self-check: ${p.unitId} ${p.what}`);
136
+ const last = readSelfCheckRecord(deps.recordPath);
137
+ if (!shouldReport(problems, last, now)) {
138
+ deps.log(`sync engine self-check: already reported at ${iso(last.lastSentAt)}; next report after ${iso(last.lastSentAt + REPORT_INTERVAL_MS)}`);
139
+ return "already-reported";
140
+ }
141
+ const to = await deps.sendToOwner(selfCheckMessage(problems, deps.machine, deps.vaultName));
142
+ if (to === null) {
143
+ deps.log("sync engine self-check: this machine is signed in as an agent, so there is no owner to DM; nothing was sent");
144
+ return "no-owner";
145
+ }
146
+ writeSelfCheckRecord(deps.recordPath, { lastSentAt: now, units: problems.map((p) => p.unitId) });
147
+ deps.log(`sync engine self-check: sent a DM to ${to}`);
148
+ return "reported";
149
+ }
150
+ //# sourceMappingURL=selfcheck.js.map
@@ -46,6 +46,30 @@ export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
46
46
  export declare const HANDOFF_STAGGER_MS = 15000;
47
47
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
48
48
  export declare const HANDOFF_DRAIN_MAX_MS: number;
49
+ /** hq-syncd's exit code when another engine holds its engine lease or the HQ operation lock. */
50
+ export declare const SYNC_CORE_LOCK_HELD_EXIT = 17;
51
+ /**
52
+ * Heartbeat-timeout restarts of an engine that reported growing progress are
53
+ * not counted toward the hold, up to this many per failure episode. After
54
+ * that they count, so an engine that moves a few bytes each run and then hangs
55
+ * is still held (and reported by the self-check) instead of restarting forever.
56
+ */
57
+ export declare const SYNC_CORE_PROGRESS_EXEMPT_MAX = 6;
58
+ /** How often a unit held for exit 17 checks whether the lock holder is still alive. */
59
+ export declare const LOCK_HOLDER_POLL_MS = 5000;
60
+ /**
61
+ * The pid hq-syncd names when it exits 17: "local sync engine is already
62
+ * running (pid N)" or "HQ operation lock for ... is held by "cmd" (pid N)".
63
+ * Null when the output names no live-looking pid.
64
+ */
65
+ export declare function lockHolderPidFromOutput(output: string | undefined): number | null;
66
+ /**
67
+ * Bytes the engine reports having moved, from a protocol heartbeat that
68
+ * carries `progress` (see the engine contract in the hq-sync-core 0.1.24
69
+ * notes): `{"type":"heartbeat","progress":{"bytesReceived":N,"bytesSent":M}}`,
70
+ * both cumulative over the engine process. Null for a heartbeat without it.
71
+ */
72
+ export declare function heartbeatProgressBytes(event: Record<string, unknown>): number | null;
49
73
  export interface SupervisedChild {
50
74
  pid?: number;
51
75
  stdout?: {
@@ -84,6 +108,11 @@ export interface UnitHold {
84
108
  probing?: boolean;
85
109
  /** Explicit stop of a recovering unit; only an explicit restart clears it. */
86
110
  retryStopped?: boolean;
111
+ /**
112
+ * Exit 17 holds: the pid hq-syncd said holds its lock. The hold is released
113
+ * as soon as that process is gone instead of waiting out the cooldown.
114
+ */
115
+ lockHolderPid?: number;
87
116
  }
88
117
  /** The holds file: unit id to hold. */
89
118
  export type PersistedHolds = Record<string, UnitHold>;
@@ -172,6 +201,8 @@ export interface SupervisorDeps {
172
201
  read(): PersistedHolds;
173
202
  write(holds: PersistedHolds): void;
174
203
  };
204
+ /** Whether a process is alive (signal 0). Enables releasing exit-17 holds when the lock holder exits. */
205
+ pidAlive?: (pid: number) => boolean;
175
206
  }
176
207
  export interface Supervisor {
177
208
  start(): void;
@@ -51,6 +51,47 @@ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
51
51
  export const HANDOFF_STAGGER_MS = 15_000;
52
52
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
53
53
  export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
54
+ /** hq-syncd's exit code when another engine holds its engine lease or the HQ operation lock. */
55
+ export const SYNC_CORE_LOCK_HELD_EXIT = 17;
56
+ /**
57
+ * Heartbeat-timeout restarts of an engine that reported growing progress are
58
+ * not counted toward the hold, up to this many per failure episode. After
59
+ * that they count, so an engine that moves a few bytes each run and then hangs
60
+ * is still held (and reported by the self-check) instead of restarting forever.
61
+ */
62
+ export const SYNC_CORE_PROGRESS_EXEMPT_MAX = 6;
63
+ /** How often a unit held for exit 17 checks whether the lock holder is still alive. */
64
+ export const LOCK_HOLDER_POLL_MS = 5_000;
65
+ /**
66
+ * The pid hq-syncd names when it exits 17: "local sync engine is already
67
+ * running (pid N)" or "HQ operation lock for ... is held by "cmd" (pid N)".
68
+ * Null when the output names no live-looking pid.
69
+ */
70
+ export function lockHolderPidFromOutput(output) {
71
+ if (!output)
72
+ return null;
73
+ const matches = [...output.matchAll(/\(pid (\d+)\)/g)];
74
+ const pid = Number(matches.at(-1)?.[1]);
75
+ return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
76
+ }
77
+ /**
78
+ * Bytes the engine reports having moved, from a protocol heartbeat that
79
+ * carries `progress` (see the engine contract in the hq-sync-core 0.1.24
80
+ * notes): `{"type":"heartbeat","progress":{"bytesReceived":N,"bytesSent":M}}`,
81
+ * both cumulative over the engine process. Null for a heartbeat without it.
82
+ */
83
+ export function heartbeatProgressBytes(event) {
84
+ if (event.type !== "heartbeat")
85
+ return null;
86
+ const progress = event.progress;
87
+ if (!progress || typeof progress !== "object" || Array.isArray(progress))
88
+ return null;
89
+ const { bytesReceived, bytesSent } = progress;
90
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
91
+ if (typeof bytesReceived !== "number" && typeof bytesSent !== "number")
92
+ return null;
93
+ return count(bytesReceived) + count(bytesSent);
94
+ }
54
95
  const PASS_REQUEST_DIR_ENV = "HQ_SYNC_PASS_REQUEST_DIR";
55
96
  function passRequestDirOf(env) {
56
97
  const dir = env?.[PASS_REQUEST_DIR_ENV];
@@ -182,6 +223,9 @@ export function createSupervisor(deps) {
182
223
  if (slot.recoveryHealthyTimer !== undefined)
183
224
  clearT(slot.recoveryHealthyTimer);
184
225
  slot.recoveryHealthyTimer = undefined;
226
+ if (slot.lockHolderTimer !== undefined)
227
+ clearT(slot.lockHolderTimer);
228
+ slot.lockHolderTimer = undefined;
185
229
  };
186
230
  const clearTimer = (slot) => {
187
231
  clearRecoveryWindow(slot);
@@ -363,16 +407,60 @@ export function createSupervisor(deps) {
363
407
  if (deps.holds && recoverableHold(slot.plan, slot.state.hold)) {
364
408
  slot.state.hold.retryAt = now() + EPISODE_HEALTHY_RESET_MS;
365
409
  }
410
+ if (kind === "exhausted" && slot.plan.service === "sync-core"
411
+ && slot.state.lastExit?.code === SYNC_CORE_LOCK_HELD_EXIT && slot.lastLockHolderPid !== undefined) {
412
+ slot.state.hold.lockHolderPid = slot.lastLockHolderPid;
413
+ slot.state.hold.detail += `; the lock is held by pid ${slot.lastLockHolderPid}, released when it exits`;
414
+ }
366
415
  persistedHolds[slot.plan.id] = slot.state.hold;
367
416
  showHeld(slot);
368
417
  deps.log("error", `holding ${slot.plan.id}: ${slot.state.reason}`);
369
418
  writeHolds();
370
419
  armRecoveryProbe(slot);
420
+ armLockHolderWatch(slot);
421
+ };
422
+ /**
423
+ * A unit held because its lock was taken (exit 17) starts again as soon as
424
+ * the process holding the lock is gone, instead of after the cooldown.
425
+ */
426
+ const armLockHolderWatch = (slot) => {
427
+ const hold = slot.state.hold;
428
+ const pid = hold?.lockHolderPid;
429
+ if (!hold || pid === undefined || !deps.pidAlive || hold.retryStopped === true || stopped || slot.child)
430
+ return;
431
+ if (slot.lockHolderTimer !== undefined)
432
+ clearT(slot.lockHolderTimer);
433
+ const check = () => {
434
+ slot.lockHolderTimer = undefined;
435
+ if (stopped || slots.get(slot.plan.id) !== slot || slot.child || slot.state.hold !== hold || hold.retryStopped === true)
436
+ return;
437
+ let alive;
438
+ try {
439
+ alive = deps.pidAlive(pid);
440
+ }
441
+ catch {
442
+ alive = true;
443
+ }
444
+ if (alive) {
445
+ slot.lockHolderTimer = setT(check, LOCK_HOLDER_POLL_MS);
446
+ return;
447
+ }
448
+ deps.log("info", `releasing the hold on ${slot.plan.id}: pid ${pid}, which held its lock, has exited`);
449
+ clearTimer(slot);
450
+ clearHold(slot);
451
+ if (slot.plan.blocked)
452
+ setBlocked(slot);
453
+ else
454
+ startLong(slot);
455
+ emit();
456
+ };
457
+ slot.lockHolderTimer = setT(check, 0);
371
458
  };
372
459
  const clearHold = (slot) => {
373
460
  clearRecoveryWindow(slot);
374
461
  slot.attempts = 0;
375
462
  slot.failures = 0;
463
+ slot.progressExemptRestarts = 0;
376
464
  if (!slot.state.hold && !persistedHolds[slot.plan.id])
377
465
  return;
378
466
  delete slot.state.hold;
@@ -466,10 +554,11 @@ export function createSupervisor(deps) {
466
554
  escalateToKill();
467
555
  signalChild(slot, child, "SIGTERM");
468
556
  };
469
- const scheduleRestart = (slot) => {
557
+ const scheduleRestart = (slot, countFailure = true) => {
470
558
  if (stopped)
471
559
  return;
472
- slot.failures += 1;
560
+ if (countFailure)
561
+ slot.failures += 1;
473
562
  if (slot.failures >= EPISODE_MAX_FAILURES) {
474
563
  holdUnit(slot, "exhausted", "transient", `${slot.failures} failures without staying up for ${EPISODE_HEALTHY_RESET_MS / 60_000} min`);
475
564
  return;
@@ -502,8 +591,20 @@ export function createSupervisor(deps) {
502
591
  slot.heartbeatTimer = undefined;
503
592
  if (stopped || slot.child !== child || slot.expectingExit)
504
593
  return;
505
- deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it`);
506
- terminate(slot, () => scheduleRestart(slot));
594
+ // An engine that reported bytes moving during this run is slow, not
595
+ // hung: restart it, but do not count it toward the exhausted hold.
596
+ // Bounded per episode: past SYNC_CORE_PROGRESS_EXEMPT_MAX these count again.
597
+ const grew = slot.plan.service === "sync-core" && slot.progressedThisRun === true;
598
+ const progressed = grew && (slot.progressExemptRestarts ?? 0) < SYNC_CORE_PROGRESS_EXEMPT_MAX;
599
+ if (progressed)
600
+ slot.progressExemptRestarts = (slot.progressExemptRestarts ?? 0) + 1;
601
+ deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it` +
602
+ (progressed
603
+ ? ` (it reported transfer progress this run, so this does not count toward holding it; ${slot.progressExemptRestarts}/${SYNC_CORE_PROGRESS_EXEMPT_MAX})`
604
+ : grew
605
+ ? ` (it reported progress, but ${SYNC_CORE_PROGRESS_EXEMPT_MAX} such restarts already did not count, so this one does)`
606
+ : ""));
607
+ terminate(slot, () => scheduleRestart(slot, !progressed));
507
608
  emit();
508
609
  }, timeoutMs);
509
610
  };
@@ -543,6 +644,16 @@ export function createSupervisor(deps) {
543
644
  runDeferredCliRestart();
544
645
  }
545
646
  }
647
+ if (slot.plan.service === "sync-core") {
648
+ const bytes = heartbeatProgressBytes(event);
649
+ if (bytes !== null) {
650
+ // The first progress heartbeat of a run is the baseline: counters are
651
+ // cumulative per process, and a handshake alone moves some bytes.
652
+ if (slot.progressBytes !== undefined && bytes > slot.progressBytes)
653
+ slot.progressedThisRun = true;
654
+ slot.progressBytes = Math.max(bytes, slot.progressBytes ?? 0);
655
+ }
656
+ }
546
657
  const at = now();
547
658
  slot.state.lastHeartbeatAt = new Date(at).toISOString();
548
659
  const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
@@ -610,8 +721,10 @@ export function createSupervisor(deps) {
610
721
  // run starts a new failure episode, as an unexpected exit would.
611
722
  if (ranFor >= HEALTHY_RUN_MS)
612
723
  slot.attempts = 0;
613
- if (ranFor >= EPISODE_HEALTHY_RESET_MS)
724
+ if (ranFor >= EPISODE_HEALTHY_RESET_MS) {
614
725
  slot.failures = 0;
726
+ slot.progressExemptRestarts = 0;
727
+ }
615
728
  const then = slot.afterExit;
616
729
  slot.afterExit = undefined;
617
730
  then?.();
@@ -688,8 +801,10 @@ export function createSupervisor(deps) {
688
801
  deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
689
802
  if (ranFor >= HEALTHY_RUN_MS)
690
803
  slot.attempts = 0;
691
- if (ranFor >= EPISODE_HEALTHY_RESET_MS)
804
+ if (ranFor >= EPISODE_HEALTHY_RESET_MS) {
692
805
  slot.failures = 0;
806
+ slot.progressExemptRestarts = 0;
807
+ }
693
808
  const stoppedFromOutside = signal === "SIGTERM" || signal === "SIGINT";
694
809
  if (slot.plan.restart === "on-failure" && (code === 0 || stoppedFromOutside)) {
695
810
  slot.state.status = "exited";
@@ -702,6 +817,12 @@ export function createSupervisor(deps) {
702
817
  const spawnError = slot.lastSpawnError;
703
818
  delete slot.lastSpawnError;
704
819
  const outputTail = spawnError === undefined ? readExitOutputTail(slot) : undefined;
820
+ delete slot.lastLockHolderPid;
821
+ if (slot.plan.service === "sync-core" && code === SYNC_CORE_LOCK_HELD_EXIT) {
822
+ const holder = lockHolderPidFromOutput(outputTail);
823
+ if (holder !== null)
824
+ slot.lastLockHolderPid = holder;
825
+ }
705
826
  if (!holdIfPermanent(slot, { code, signal, spawnError, outputTail }))
706
827
  scheduleRestart(slot);
707
828
  }
@@ -740,6 +861,8 @@ export function createSupervisor(deps) {
740
861
  delete slot.lastProtocolLine;
741
862
  delete slot.lastProtocolStatus;
742
863
  delete slot.syncPassActive;
864
+ delete slot.progressBytes;
865
+ delete slot.progressedThisRun;
743
866
  let child;
744
867
  try {
745
868
  child = deps.spawn(slot.plan);
@@ -923,6 +1046,7 @@ export function createSupervisor(deps) {
923
1046
  if (slot.state.hold) {
924
1047
  showHeld(slot);
925
1048
  armRecoveryProbe(slot);
1049
+ armLockHolderWatch(slot);
926
1050
  return;
927
1051
  }
928
1052
  startLong(slot);
@@ -20,7 +20,8 @@ function isHold(value) {
20
20
  && typeof h.detail === "string"
21
21
  && typeof h.since === "number"
22
22
  && typeof h.failures === "number"
23
- && typeof h.keyHash === "string");
23
+ && typeof h.keyHash === "string"
24
+ && (h.lockHolderPid === undefined || (typeof h.lockHolderPid === "number" && Number.isSafeInteger(h.lockHolderPid) && h.lockHolderPid > 1)));
24
25
  }
25
26
  export function readUnitHolds(file) {
26
27
  let raw;
@@ -172,6 +172,14 @@ function optionalUnitInterval(value, field) {
172
172
  }
173
173
  return value;
174
174
  }
175
+ function optionalPercent(value, field) {
176
+ if (value === undefined)
177
+ return undefined;
178
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
179
+ throw new LanesConfigError("invalid_config", `${field} must be a number in [0, 100]`, { field, value });
180
+ }
181
+ return value;
182
+ }
175
183
  function parseAccount(provider, id, raw, deps) {
176
184
  if (!isPlainObject(raw)) {
177
185
  throw new LanesConfigError("invalid_config", `providers.${provider}.accounts.${id} must be an object`, { provider, account: id });
@@ -407,6 +415,11 @@ function parseThrottle(raw) {
407
415
  }
408
416
  result.min_available_mem_mb = obj.min_available_mem_mb;
409
417
  }
418
+ // darwin: a warn pressure level holds only at or below this free
419
+ // percentage. Unset means DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT (20).
420
+ const warnMinFree = optionalPercent(obj.memory_warn_min_free_percent, "throttle.memory_warn_min_free_percent");
421
+ if (warnMinFree !== undefined)
422
+ result.memory_warn_min_free_percent = warnMinFree;
410
423
  return result;
411
424
  }
412
425
  function parseLiveness(raw) {
@@ -25,6 +25,17 @@ export declare const SYSCTL_BIN = "/usr/sbin/sysctl";
25
25
  * hw.memsize turns the percentage into MiB for min_available_mem_mb.
26
26
  */
27
27
  export declare const MEMORY_PRESSURE_SYSCTLS: readonly ["kern.memorystatus_level", "kern.memorystatus_vm_pressure_level", "hw.memsize"];
28
+ /**
29
+ * Default for throttle.memory_warn_min_free_percent. On darwin a warn pressure
30
+ * level holds admission only while the free percentage is at or below this.
31
+ */
32
+ export declare const DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT = 20;
33
+ /**
34
+ * Whether a darwin memory_pressure level holds admission on its own. Critical
35
+ * always holds. Warn holds only at or below warnMinFreePercent free, or when
36
+ * the free percentage is unknown.
37
+ */
38
+ export declare function pressureLevelHolds(signal: MemorySignal, warnMinFreePercent?: number): boolean;
28
39
  export declare const LANES_CPU_BUSY_OVERRIDE = "HQ_LANES_CPU_BUSY_OVERRIDE";
29
40
  export declare const LANES_CPU_CHECK = "HQ_LANES_CPU_CHECK";
30
41
  export interface CpuTimes {
@@ -129,7 +140,7 @@ export declare function readMemorySignal(deps?: ThrottleDeps): MemorySignal | nu
129
140
  /** Available memory in MiB from readMemorySignal; null skips the memory gate. */
130
141
  export declare function readAvailableMemMb(deps?: ThrottleDeps): number | null;
131
142
  /** Plain explanation of a memory hold: which signal, its value, and the threshold. */
132
- export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number): string;
143
+ export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number, warnMinFreePercent?: number): string;
133
144
  export interface ThrottleState {
134
145
  consecutiveDeferrals: number;
135
146
  lastAdmissionAt: string | null;