@indigoai-us/hq-cli 5.345.202 → 5.345.203

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 (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/command-catalog.generated.d.ts +24 -0
  3. package/dist/command-catalog.generated.js +26 -0
  4. package/dist/commands/capacity.js +10 -2
  5. package/dist/commands/daemon.d.ts +2 -1
  6. package/dist/commands/daemon.js +225 -4
  7. package/dist/commands/integrations-core.d.ts +3 -0
  8. package/dist/commands/integrations-core.js +11 -0
  9. package/dist/commands/integrations-manage.js +2 -2
  10. package/dist/commands/integrations.js +5 -5
  11. package/dist/commands/sync-core.js +2 -1
  12. package/dist/lib/bot/executive-settings.d.ts +1 -1
  13. package/dist/lib/bot/executive-settings.js +8 -6
  14. package/dist/lib/bot/jobs/meeting-detect.js +2 -2
  15. package/dist/lib/bot/jobs/meeting-rider.js +2 -2
  16. package/dist/lib/bot/run.js +3 -3
  17. package/dist/lib/capacity/codex-availability.d.ts +6 -0
  18. package/dist/lib/capacity/codex-availability.js +33 -0
  19. package/dist/lib/capacity/ledger.d.ts +0 -11
  20. package/dist/lib/capacity/ledger.js +30 -3
  21. package/dist/lib/capacity/route.js +11 -5
  22. package/dist/lib/daemon/catalog.d.ts +3 -0
  23. package/dist/lib/daemon/catalog.js +15 -2
  24. package/dist/lib/daemon/handoff.d.ts +2 -0
  25. package/dist/lib/daemon/handoff.js +9 -2
  26. package/dist/lib/daemon/install.d.ts +99 -0
  27. package/dist/lib/daemon/install.js +316 -7
  28. package/dist/lib/daemon/migrate.d.ts +3 -1
  29. package/dist/lib/daemon/migrate.js +14 -2
  30. package/dist/lib/daemon/plist-refresh.d.ts +34 -0
  31. package/dist/lib/daemon/plist-refresh.js +62 -0
  32. package/dist/lib/daemon/run.js +25 -0
  33. package/dist/lib/daemon/selfcheck.d.ts +71 -0
  34. package/dist/lib/daemon/selfcheck.js +150 -0
  35. package/dist/lib/daemon/supervisor.d.ts +31 -0
  36. package/dist/lib/daemon/supervisor.js +130 -6
  37. package/dist/lib/daemon/unit-holds.js +2 -1
  38. package/dist/lib/service-manager/launchd.d.ts +25 -0
  39. package/dist/lib/service-manager/launchd.js +51 -3
  40. package/dist/lib/service-manager/migration.d.ts +14 -0
  41. package/dist/lib/service-manager/migration.js +34 -0
  42. package/dist/lib/service-manager/systemd.d.ts +13 -0
  43. package/dist/lib/service-manager/systemd.js +19 -0
  44. package/dist/lib/service-manager/types.d.ts +8 -0
  45. package/dist/lib/sync-core/reconcile.d.ts +4 -1
  46. package/dist/lib/sync-core/reconcile.js +12 -2
  47. package/dist/lib/sync-core/release.d.ts +23 -0
  48. package/dist/lib/sync-core/release.js +39 -0
  49. package/package.json +7 -7
@@ -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;
@@ -114,6 +114,31 @@ export declare function activateLaunchd(plistPath: string, run: RunCommand, uid?
114
114
  sleep?: SleepFn;
115
115
  unloadDeadlineMs?: number;
116
116
  }): void;
117
+ export interface LaunchdBootstrapResult {
118
+ ok: boolean;
119
+ attempts: number;
120
+ error?: string;
121
+ }
122
+ /**
123
+ * Bootstrap a plist that is not loaded, retrying launchd's transient EIO with
124
+ * backoff. A label that is already loaded counts as success. Unlike
125
+ * reloadLaunchd this never boots anything out, so it is safe to call on a
126
+ * running job.
127
+ */
128
+ export declare function bootstrapLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
129
+ sleep?: SleepFn;
130
+ }): LaunchdBootstrapResult;
131
+ export interface LaunchdJobInfo {
132
+ loaded: boolean;
133
+ /** launchd's `state = ...` line (running, not running, spawn scheduled, ...). */
134
+ state?: string;
135
+ pid?: number;
136
+ /** The loaded job's program arguments (`arguments = { ... }`), when printed. */
137
+ arguments?: string[];
138
+ }
139
+ /** What `launchctl print gui/<uid>/<label>` says about one job. */
140
+ export declare function launchdJobInfo(label: string, run: RunCommand, uid?: number): LaunchdJobInfo;
141
+ export declare function parseLaunchctlPrintJob(stdout: string): LaunchdJobInfo;
117
142
  export declare function deactivateLaunchd(plistPath: string, run: RunCommand, uid?: number): void;
118
143
  export declare function launchdIsLoaded(label: string, run: RunCommand, uid?: number): boolean;
119
144
  //# sourceMappingURL=launchd.d.ts.map
@@ -58,9 +58,11 @@ export function launchdPlistPath(home, spec) {
58
58
  return path.join(launchAgentsDir(home), `${spec.label}.plist`);
59
59
  }
60
60
  export function renderLaunchdPlist(spec, host) {
61
- const discovered = discoverLaunchdIdentity(host.home);
62
- const associatedBundleIdentifier = host.associatedBundleIdentifier ?? discovered.associatedBundleIdentifier;
63
- const launcherBinary = host.launchdLauncherBinary ?? discovered.launcherBinary;
61
+ const discovered = spec.independentOfApp ? {} : discoverLaunchdIdentity(host.home);
62
+ const associatedBundleIdentifier = spec.independentOfApp
63
+ ? undefined
64
+ : host.associatedBundleIdentifier ?? discovered.associatedBundleIdentifier;
65
+ const launcherBinary = spec.independentOfApp ? undefined : host.launchdLauncherBinary ?? discovered.launcherBinary;
64
66
  const args = launchdProgramArguments({ ...host, launchdLauncherBinary: launcherBinary }, spec.args)
65
67
  .map((a) => ` <string>${escapeXml(a)}</string>`)
66
68
  .join("\n");
@@ -303,6 +305,52 @@ export function activateLaunchd(plistPath, run, uid, opts) {
303
305
  }
304
306
  throw new Error(`${detail}. ${target} is NOT loaded and will not run until it is. Load it with: launchctl bootstrap ${domain} ${plistPath}`);
305
307
  }
308
+ /**
309
+ * Bootstrap a plist that is not loaded, retrying launchd's transient EIO with
310
+ * backoff. A label that is already loaded counts as success. Unlike
311
+ * reloadLaunchd this never boots anything out, so it is safe to call on a
312
+ * running job.
313
+ */
314
+ export function bootstrapLaunchd(plistPath, run, uid, opts) {
315
+ const domain = guiDomain(uid);
316
+ const target = launchdServiceTarget(plistPath, uid);
317
+ const sleep = opts?.sleep ?? blockingSleep;
318
+ if (run("launchctl", ["print", target]).status === 0)
319
+ return { ok: true, attempts: 0 };
320
+ let last = run("launchctl", ["bootstrap", domain, plistPath]);
321
+ let attempts = 1;
322
+ for (const backoff of LAUNCHD_BOOTSTRAP_BACKOFF_MS) {
323
+ if (last.status === 0 || !isRetryableLaunchdBootstrap(last))
324
+ break;
325
+ sleep(backoff);
326
+ last = run("launchctl", ["bootstrap", domain, plistPath]);
327
+ attempts++;
328
+ }
329
+ // "already bootstrapped" races (another caller loaded it) are success.
330
+ if (last.status === 0 || run("launchctl", ["print", target]).status === 0)
331
+ return { ok: true, attempts };
332
+ return { ok: false, attempts, error: last.stderr.trim() || last.stdout.trim() || `exit ${last.status}` };
333
+ }
334
+ /** What `launchctl print gui/<uid>/<label>` says about one job. */
335
+ export function launchdJobInfo(label, run, uid) {
336
+ const r = run("launchctl", ["print", `${guiDomain(uid)}/${label}`]);
337
+ if (r.status !== 0)
338
+ return { loaded: false };
339
+ return parseLaunchctlPrintJob(r.stdout);
340
+ }
341
+ export function parseLaunchctlPrintJob(stdout) {
342
+ const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(stdout)?.[1];
343
+ const pidText = /^\s*pid\s*=\s*(\d+)\s*$/m.exec(stdout)?.[1];
344
+ const pid = pidText ? Number(pidText) : undefined;
345
+ const argsBlock = /^\s*arguments\s*=\s*\{\s*\n([\s\S]*?)\n\s*\}/m.exec(stdout)?.[1];
346
+ const args = argsBlock?.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
347
+ return {
348
+ loaded: true,
349
+ ...(state ? { state } : {}),
350
+ ...(pid && pid > 0 ? { pid } : {}),
351
+ ...(args && args.length > 0 ? { arguments: args } : {}),
352
+ };
353
+ }
306
354
  export function deactivateLaunchd(plistPath, run, uid) {
307
355
  run("launchctl", ["bootout", guiDomain(uid), plistPath]);
308
356
  }
@@ -1,5 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import { type RunCommand, type SleepFn } from "./launchd.js";
3
+ /**
4
+ * Agents that must load at login without the app, so they are never wrapped
5
+ * in the HQ.app launcher or attributed to its bundle. Kept in step with
6
+ * DAEMON_LAUNCHD_LABEL in daemon/install.ts.
7
+ */
8
+ export declare const APP_INDEPENDENT_LAUNCHD_LABELS: ReadonlySet<string>;
3
9
  export interface LaunchdMigrationOptions {
4
10
  home: string;
5
11
  run: RunCommand;
@@ -19,4 +25,12 @@ export interface LaunchdMigrationResult {
19
25
  }
20
26
  /** First-CLI-run cleanup for HQ's old Node-first LaunchAgents and backup plists. */
21
27
  export declare function migrateLegacyNodeLaunchAgents(options: LaunchdMigrationOptions): LaunchdMigrationResult;
28
+ /**
29
+ * The app-independent form of an HQ LaunchAgent plist written by an older
30
+ * install: the HQ.app launcher (`hq-sync-menubar --hq-launchd-task`) is
31
+ * dropped from ProgramArguments and the AssociatedBundleIdentifiers entry is
32
+ * removed. Everything else (environment, logs, KeepAlive) is kept as written.
33
+ * Returns null when the plist is already app-independent.
34
+ */
35
+ export declare function appIndependentLaunchdPlist(source: string): string | null;
22
36
  //# sourceMappingURL=migration.d.ts.map
@@ -5,6 +5,12 @@ import { isVersionedNodePath, preferStableNodeBinary } from "../node-binary.js";
5
5
  import { preferStableHqBinary } from "../mesh/live/daemon/install.js";
6
6
  import { launchdMigrationMarkerPath, shouldRunLaunchdMigration, writeLaunchdMigrationMarker } from "./migration-state.js";
7
7
  const HQ_LABEL_PREFIXES = ["ai.indigo.hq-", "ai.getindigo.hq-"];
8
+ /**
9
+ * Agents that must load at login without the app, so they are never wrapped
10
+ * in the HQ.app launcher or attributed to its bundle. Kept in step with
11
+ * DAEMON_LAUNCHD_LABEL in daemon/install.ts.
12
+ */
13
+ export const APP_INDEPENDENT_LAUNCHD_LABELS = new Set(["ai.getindigo.hq-daemon"]);
8
14
  function xml(value) {
9
15
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10
16
  }
@@ -36,6 +42,8 @@ export function migrateLegacyNodeLaunchAgents(options) {
36
42
  }
37
43
  if (!name.endsWith(".plist") || !HQ_LABEL_PREFIXES.some((prefix) => name.startsWith(prefix)))
38
44
  continue;
45
+ if (APP_INDEPENDENT_LAUNCHD_LABELS.has(name.slice(0, -".plist".length)))
46
+ continue;
39
47
  const source = files.readFileSync(fullPath, "utf8");
40
48
  const args = launchdPlistProgramArguments(source);
41
49
  if (args.length < 2 || !isNodeProgram(args[0]))
@@ -82,4 +90,30 @@ export function migrateLegacyNodeLaunchAgents(options) {
82
90
  writeLaunchdMigrationMarker(options.home, deferred);
83
91
  return { migrated, backupsRemoved, deferred, alreadyDone: false };
84
92
  }
93
+ /**
94
+ * The app-independent form of an HQ LaunchAgent plist written by an older
95
+ * install: the HQ.app launcher (`hq-sync-menubar --hq-launchd-task`) is
96
+ * dropped from ProgramArguments and the AssociatedBundleIdentifiers entry is
97
+ * removed. Everything else (environment, logs, KeepAlive) is kept as written.
98
+ * Returns null when the plist is already app-independent.
99
+ */
100
+ export function appIndependentLaunchdPlist(source) {
101
+ const args = launchdPlistProgramArguments(source);
102
+ const launcherIndex = args.indexOf("--hq-launchd-task");
103
+ const wrapped = launcherIndex === 1 && args.length > 3;
104
+ const associated = /<key>AssociatedBundleIdentifiers<\/key>/.test(source);
105
+ if (!wrapped && !associated)
106
+ return null;
107
+ let updated = source;
108
+ if (wrapped) {
109
+ updated = updated.replace(/<key>ProgramArguments<\/key>\s*<array>[\s\S]*?<\/array>/, plistArgs(args.slice(2)).trimStart());
110
+ }
111
+ if (associated) {
112
+ updated = updated.replace(/[ \t]*<key>AssociatedBundleIdentifiers<\/key>\s*(?:<array>[\s\S]*?<\/array>|<string>[^<]*<\/string>)[ \t]*\n?/, "");
113
+ }
114
+ if (updated === source || /<key>AssociatedBundleIdentifiers<\/key>/.test(updated) || launchdPlistProgramArguments(updated).includes("--hq-launchd-task")) {
115
+ throw new Error("Could not safely rewrite the LaunchAgent to load without HQ.app");
116
+ }
117
+ return updated;
118
+ }
85
119
  //# sourceMappingURL=migration.js.map
@@ -16,4 +16,17 @@ export declare function renderSystemdUserUnit(spec: ServiceSpec, host: ServiceHo
16
16
  export declare function activateSystemd(unitName: string, run: RunCommand): void;
17
17
  export declare function deactivateSystemd(unitName: string, run: RunCommand): void;
18
18
  export declare function systemdIsActive(unitName: string, run: RunCommand): boolean;
19
+ export interface SystemdUnitInfo {
20
+ /** `systemctl is-enabled` printed enabled (or enabled-runtime / linked / alias). */
21
+ enabled: boolean;
22
+ /** `systemctl is-active` printed active (or activating / reloading). */
23
+ active: boolean;
24
+ /** The raw `is-enabled` / `is-active` answers, for status output. */
25
+ enabledState: string;
26
+ activeState: string;
27
+ /** MainPID when systemd reports one. */
28
+ pid?: number;
29
+ }
30
+ /** Ask systemd about one unit; `scope: "user"` adds `--user`. */
31
+ export declare function systemdUnitInfo(unitName: string, run: RunCommand, scope?: "user" | "system"): SystemdUnitInfo;
19
32
  //# sourceMappingURL=systemd.d.ts.map
@@ -69,4 +69,23 @@ export function systemdIsActive(unitName, run) {
69
69
  const r = run("systemctl", ["--user", "is-active", "--quiet", unitName]);
70
70
  return r.status === 0;
71
71
  }
72
+ /** Ask systemd about one unit; `scope: "user"` adds `--user`. */
73
+ export function systemdUnitInfo(unitName, run, scope = "user") {
74
+ const prefix = scope === "user" ? ["--user"] : [];
75
+ const word = (args) => {
76
+ const r = run("systemctl", [...prefix, ...args, unitName]);
77
+ return (r.stdout.trim().split("\n")[0] ?? "").trim() || "unknown";
78
+ };
79
+ const enabledState = word(["is-enabled"]);
80
+ const activeState = word(["is-active"]);
81
+ const show = run("systemctl", [...prefix, "show", "-p", "MainPID", "--value", unitName]);
82
+ const pid = Number(show.stdout.trim());
83
+ return {
84
+ enabled: /^(enabled|enabled-runtime|linked|linked-runtime|alias|static|indirect|generated)$/.test(enabledState),
85
+ active: /^(active|activating|reloading)$/.test(activeState),
86
+ enabledState,
87
+ activeState,
88
+ ...(show.status === 0 && Number.isInteger(pid) && pid > 0 ? { pid } : {}),
89
+ };
90
+ }
72
91
  //# sourceMappingURL=systemd.js.map