@indigoai-us/hq-cli 5.345.11 → 5.345.13

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-grant.sh +16 -16
  3. package/assets/scaffold/core/scripts/register-project.sh +5 -5
  4. package/assets/scaffold/core/scripts/repo-run-registry.sh +8 -8
  5. package/assets/scaffold/core/scripts/resume-thread-lock.sh +4 -4
  6. package/assets/scaffold/core/scripts/verify-story-deliverables.sh +1 -1
  7. package/assets/scaffold/core/scripts/worktree-gc.sh +1 -1
  8. package/dist/commands/core-delegate-w1b.js +14 -9
  9. package/dist/commands/core-delegate.d.ts +1 -0
  10. package/dist/commands/core-delegate.js +10 -2
  11. package/dist/commands/core-handoff.js +69 -34
  12. package/dist/commands/core-hq-session-native.js +26 -5
  13. package/dist/commands/core-job-notify-fast.d.ts +2 -0
  14. package/dist/commands/core-job-notify-fast.js +6 -0
  15. package/dist/commands/core-job-probe-fast.d.ts +2 -0
  16. package/dist/commands/core-job-probe-fast.js +11 -0
  17. package/dist/commands/core-job-probe-native.js +1 -1
  18. package/dist/commands/core-job-remediate-fast.d.ts +2 -0
  19. package/dist/commands/core-job-remediate-fast.js +6 -0
  20. package/dist/commands/core-job-run-fast.d.ts +2 -0
  21. package/dist/commands/core-job-run-fast.js +6 -0
  22. package/dist/commands/core-pin-fast.d.ts +2 -0
  23. package/dist/commands/core-pin-fast.js +15 -0
  24. package/dist/commands/core-rebuild-index-fast.d.ts +6 -0
  25. package/dist/commands/core-rebuild-index-fast.js +39 -0
  26. package/dist/commands/core-register-project-native.js +11 -2
  27. package/dist/commands/core-repo-run-registry-native.js +47 -4
  28. package/dist/commands/core-session-journal-native.d.ts +1 -1
  29. package/dist/commands/core-session-journal-native.js +2 -2
  30. package/dist/commands/core-share-suggestion-state-native.js +6 -2
  31. package/dist/commands/core-work-mesh-live-rebind-native.js +5 -2
  32. package/dist/commands/core-worker-lint-fast.d.ts +2 -0
  33. package/dist/commands/core-worker-lint-fast.js +11 -0
  34. package/dist/commands/core.js +9 -6
  35. package/dist/fast-scaffold.js +49 -0
  36. package/dist/lib/core-utils/worktree-resume.d.ts +1 -0
  37. package/dist/lib/core-utils/worktree-resume.js +68 -31
  38. package/dist/lib/daemon/catalog.d.ts +10 -0
  39. package/dist/lib/daemon/catalog.js +8 -0
  40. package/dist/lib/daemon/lanes-reconcile-guard.d.ts +43 -0
  41. package/dist/lib/daemon/lanes-reconcile-guard.js +115 -0
  42. package/dist/lib/daemon/run.js +2 -0
  43. package/dist/lib/daemon/supervisor.d.ts +2 -0
  44. package/dist/lib/daemon/supervisor.js +28 -0
  45. package/dist/lib/lanes/autopilot-registry.d.ts +2 -5
  46. package/dist/lib/lanes/autopilot-registry.js +4 -4
  47. package/dist/lib/lanes/compaction.d.ts +2 -2
  48. package/dist/lib/lanes/compaction.js +221 -54
  49. package/dist/lib/lanes/move-account.js +24 -1
  50. package/dist/lib/lanes/reconcile.d.ts +0 -5
  51. package/dist/lib/lanes/reconcile.js +4 -4
  52. package/dist/lib/lanes/types.d.ts +6 -5
  53. package/dist/utils/run-bundled-script.js +1 -1
  54. package/package.json +1 -1
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { serializeJqJson } from "./jq-json-serializer.js";
8
+ import { shellDisplayPath } from "./shell-display-path.js";
8
9
  export const WORKTREE_GC_ASSET = "core/scripts/worktree-gc.sh";
9
10
  export const CLEAN_HANDOFF_ASSET = "core/scripts/clean-worktree-reconcile-handoff.sh";
10
11
  export const RESUME_LOCK_ASSET = "core/scripts/resume-thread-lock.sh";
@@ -13,6 +14,7 @@ const stdout = (io, value) => (io.stdout ?? ((v) => process.stdout.write(v)))(va
13
14
  const stderr = (io, value) => (io.stderr ?? ((v) => process.stderr.write(v)))(value);
14
15
  const envOf = (io) => io.env ?? process.env;
15
16
  const nowOf = (io) => (io.now ?? (() => new Date()))();
17
+ const randomBytesOf = (io, size) => (io.randomBytes ?? randomBytes)(size);
16
18
  const stripTrailingNewlines = (value) => value.replace(/\n+$/u, "");
17
19
  function git(args, options = {}) {
18
20
  const childEnv = options.env;
@@ -61,6 +63,23 @@ function fileExists(file) {
61
63
  return false;
62
64
  }
63
65
  }
66
+ function sameDirectory(left, right) {
67
+ try {
68
+ const a = fs.statSync(left), b = fs.statSync(right);
69
+ if (a.dev !== 0 && b.dev !== 0 && a.ino !== 0 && b.ino !== 0)
70
+ return a.dev === b.dev && a.ino === b.ino;
71
+ }
72
+ catch { /* compare canonical spellings below */ }
73
+ const canonical = (value) => path.resolve(value).replace(/^\\\\\?\\/u, "").replaceAll("/", "\\").toLowerCase();
74
+ return canonical(left) === canonical(right);
75
+ }
76
+ function shellJsonPath(value) {
77
+ const display = shellDisplayPath(value);
78
+ if (process.platform !== "win32")
79
+ return display;
80
+ const match = /^\/([a-z])\/(.*)$/u.exec(display);
81
+ return match?.[1] && match[2] !== undefined ? `${match[1].toUpperCase()}:/${match[2]}` : display;
82
+ }
64
83
  function statMtime(file) {
65
84
  try {
66
85
  return Math.floor(fs.statSync(file).mtimeMs / 1000);
@@ -119,7 +138,10 @@ function sessionReferences(hqRoot, worktree) {
119
138
  path.join(hqRoot, "workspace/threads/handoff.json"),
120
139
  path.join(hqRoot, "workspace/locks"),
121
140
  ];
122
- const needle = Buffer.from(worktree, "utf8");
141
+ // The shell receives the Git Bash spelling of this path. Session references
142
+ // written by shell hooks use that spelling too, while Node's win32 path is
143
+ // drive-letter based. Compare the representation the shell actually sees.
144
+ const needle = Buffer.from(shellDisplayPath(worktree), "utf8");
123
145
  const visit = (target) => {
124
146
  let info;
125
147
  try {
@@ -187,7 +209,16 @@ function duSizeKb(root) {
187
209
  return;
188
210
  if (info.isFile())
189
211
  seen.add(inode);
190
- bytes += typeof info.blocks === "number" ? info.blocks * 512 : info.size;
212
+ // Windows Git Bash du counts file payloads in 1 KiB units. Node's Windows
213
+ // directory sizes include metadata that du does not count, so omit those
214
+ // directory entries and round file payloads as du -sk does.
215
+ bytes += process.platform === "win32" && info.isDirectory()
216
+ ? 0
217
+ : typeof info.blocks === "number" && !(process.platform === "win32" && info.blocks === 0)
218
+ ? info.blocks * 512
219
+ : process.platform === "win32"
220
+ ? Math.max(1, Math.ceil(info.size / 1024)) * 1024
221
+ : info.size;
191
222
  if (info.isDirectory()) {
192
223
  let children;
193
224
  try {
@@ -338,9 +369,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
338
369
  const skip = (wt, repo, branch, isHqRoot, ageDays, sizeKb, reason) => {
339
370
  skipped++;
340
371
  if (reason === "remove-failed")
341
- record({ path: wt, repo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "skipped", reason });
372
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(repo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "skipped", reason });
342
373
  if (!json)
343
- stdout(io, ` skip [${reason}] ${wt}\n`);
374
+ stdout(io, ` skip [${reason}] ${shellDisplayPath(wt)}\n`);
344
375
  };
345
376
  const runFetch = (repo) => {
346
377
  const timeoutName = executableOnPath("timeout", env) ? "timeout" : executableOnPath("gtimeout", env) ? "gtimeout" : undefined;
@@ -372,11 +403,7 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
372
403
  skip(wt, "", "", false, null, null, "git-error");
373
404
  continue;
374
405
  }
375
- let isHqRoot = false;
376
- try {
377
- isHqRoot = fs.realpathSync(mainRepo) === fs.realpathSync(hqRoot);
378
- }
379
- catch { /* both paths are already resolved by the core root resolver */ }
406
+ const isHqRoot = sameDirectory(mainRepo, hqRoot);
380
407
  const branchResult = gitText(["-C", wt, "symbolic-ref", "--short", "HEAD"]);
381
408
  const branch = branchResult.status === 0 ? commandOutput(branchResult) : "";
382
409
  const headResult = gitText(["-C", wt, "rev-parse", "HEAD"]);
@@ -401,7 +428,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
401
428
  continue;
402
429
  }
403
430
  let createdEpoch = wtMtime;
404
- const stampFile = path.join(metaDir, `${worktreeGcPathKey(wt)}.json`);
431
+ // worktree-gc.sh hashes the path as Bash/Git Bash spells it. On Windows a
432
+ // native path (C:\...) and the shell path (/c/...) have different keys.
433
+ const stampFile = path.join(metaDir, `${worktreeGcPathKey(shellDisplayPath(wt))}.json`);
405
434
  if (fileExists(stampFile)) {
406
435
  try {
407
436
  const stamp = JSON.parse(fs.readFileSync(stampFile, "utf8"));
@@ -465,9 +494,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
465
494
  if (!apply) {
466
495
  would++;
467
496
  reclaimedKb += sizeKb;
468
- record({ path: wt, repo: mainRepo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "would-remove", reason: "safe" });
497
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(mainRepo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "would-remove", reason: "safe" });
469
498
  if (!json)
470
- stdout(io, ` DRY would remove (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${wt}\n`);
499
+ stdout(io, ` DRY would remove (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${shellDisplayPath(wt)}\n`);
471
500
  continue;
472
501
  }
473
502
  if (wtGit(mainRepo, isHqRoot, ["worktree", "remove", wt]).status !== 0) {
@@ -483,9 +512,9 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
483
512
  catch { /* rm -f */ }
484
513
  removed++;
485
514
  reclaimedKb += sizeKb;
486
- record({ path: wt, repo: mainRepo, branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "removed", reason: "safe" });
515
+ record({ path: shellJsonPath(wt), repo: shellJsonPath(mainRepo), branch, hq_root: isHqRoot, age_days: ageDays, size_kb: sizeKb, action: "removed", reason: "safe" });
487
516
  if (!json)
488
- stdout(io, ` GC removed (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${wt}\n`);
517
+ stdout(io, ` GC removed (${Math.floor(sizeKb / 1024)} MB, ${ageDays}d) ${shellDisplayPath(wt)}\n`);
489
518
  }
490
519
  const reclaimedGb = Number((reclaimedKb / 1024 / 1024).toFixed(2));
491
520
  if (json) {
@@ -554,18 +583,18 @@ export function runCleanWorktreeReconcileHandoff(args, io = {}) {
554
583
  }
555
584
  const rawRoot = args[0] ?? "";
556
585
  const branch = args[1] ?? "";
557
- if (!rawRoot.startsWith("/")) {
586
+ if (!rawRoot.startsWith("/") && !(process.platform === "win32" && path.win32.isAbsolute(rawRoot))) {
558
587
  stderr(io, `ERROR: HQ root must be an absolute path\n`);
559
588
  cleanUsage(io);
560
589
  return 64;
561
590
  }
562
591
  if (!fileExists(rawRoot) || !fs.statSync(rawRoot).isDirectory()) {
563
- return cleanGitError(io, `ERROR: HQ root is not a directory: ${rawRoot}`);
592
+ return cleanGitError(io, `ERROR: HQ root is not a directory: ${shellDisplayPath(rawRoot)}`);
564
593
  }
565
594
  const hqRoot = realPathOrResolved(rawRoot);
566
595
  const top = gitText(["-C", hqRoot, "rev-parse", "--show-toplevel"]);
567
596
  if (top.status !== 0)
568
- return cleanGitError(io, `ERROR: HQ root is not a Git worktree: ${hqRoot}`);
597
+ return cleanGitError(io, `ERROR: HQ root is not a Git worktree: ${shellDisplayPath(hqRoot)}`);
569
598
  const topLevel = realPathOrResolved(commandOutput(top));
570
599
  if (topLevel !== hqRoot)
571
600
  return cleanGitError(io, `ERROR: supplied path is not the Git worktree root: ${hqRoot}`);
@@ -640,7 +669,7 @@ export function runCleanWorktreeReconcileHandoff(args, io = {}) {
640
669
  let movedFile = "";
641
670
  try {
642
671
  for (let attempt = 0; attempt < 10; attempt++) {
643
- const candidate = path.join(destinationDir, `.clean-worktree-handoff.${randomBytes(4).toString("hex").slice(0, 6)}`);
672
+ const candidate = path.join(destinationDir, `.clean-worktree-handoff.${randomBytesOf(io, 4).toString("hex").slice(0, 6)}`);
644
673
  try {
645
674
  fs.writeFileSync(candidate, "", { flag: "wx", mode: 0o600 });
646
675
  movedFile = candidate;
@@ -769,12 +798,12 @@ function lockState(threadId, relativeLockPath, lockDir, recordPath, staleSeconds
769
798
  }
770
799
  return locked(record.session_id, record.resumed_at, record.generation);
771
800
  }
772
- function writeLockRecord(lockDir, recordPath, threadId, sessionId, now) {
801
+ function writeLockRecord(lockDir, recordPath, threadId, sessionId, now, io) {
773
802
  const epoch = Math.floor(now.getTime() / 1000);
774
803
  const timestamp = now.toISOString().replace(/\.\d{3}Z$/u, "Z");
775
804
  let temporary = "";
776
805
  for (let attempt = 0; attempt < 10; attempt++) {
777
- const generation = `.current.${randomBytes(8).toString("base64url").slice(0, 6)}`;
806
+ const generation = `.current.${randomBytesOf(io, 8).toString("base64url").slice(0, 6)}`;
778
807
  const candidate = path.join(lockDir, generation);
779
808
  try {
780
809
  fs.writeFileSync(candidate, serializeJqJson({ version: 1, thread_id: threadId, session_id: sessionId, resumed_at: timestamp, resumed_epoch: epoch, generation }, "compact"), { flag: "wx", mode: 0o600 });
@@ -858,7 +887,7 @@ export function runResumeThreadLock(args, hqRoot, io = {}) {
858
887
  fs.mkdirSync(locksDir, { recursive: true });
859
888
  try {
860
889
  fs.mkdirSync(lockDir);
861
- const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now());
890
+ const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now(), io);
862
891
  emit({ status: "acquired", thread_id: threadId, lock_path: relativeLockPath, session_id: sessionId, lock_generation: generation });
863
892
  return 0;
864
893
  }
@@ -915,7 +944,7 @@ export function runResumeThreadLock(args, hqRoot, io = {}) {
915
944
  emit(current);
916
945
  return 4;
917
946
  }
918
- const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now());
947
+ const generation = writeLockRecord(lockDir, recordPath, threadId, sessionId, now(), io);
919
948
  emit({ status: "acquired", thread_id: threadId, lock_path: relativeLockPath, session_id: sessionId, lock_generation: generation });
920
949
  return 0;
921
950
  }
@@ -1008,7 +1037,7 @@ async function drainResponseBody(response) {
1008
1037
  export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1009
1038
  const env = envOf(io);
1010
1039
  let prdPath = "", storyId = "", repo = "";
1011
- let json = false, strict = false, write = false;
1040
+ let json = false, strict = false, write = false, repoExplicit = false;
1012
1041
  let evidenceJson = "[]", commitsJson = "[]";
1013
1042
  let offline = env.HQ_DELIVERABLES_OFFLINE ?? "0";
1014
1043
  for (let index = 0; index < args.length;) {
@@ -1021,8 +1050,10 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1021
1050
  prdPath = value;
1022
1051
  else if (arg === "--story")
1023
1052
  storyId = value;
1024
- else if (arg === "--repo")
1053
+ else if (arg === "--repo") {
1025
1054
  repo = value;
1055
+ repoExplicit = true;
1056
+ }
1026
1057
  else if (arg === "--evidence-json")
1027
1058
  evidenceJson = value;
1028
1059
  else
@@ -1058,9 +1089,12 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1058
1089
  stderr(io, `usage: --prd <prd.json> --story <id> [--repo <path>]\n`);
1059
1090
  return 2;
1060
1091
  }
1092
+ const displayedPrdPath = process.platform === "win32" && path.win32.isAbsolute(prdPath)
1093
+ ? shellDisplayPath(prdPath)
1094
+ : prdPath;
1061
1095
  const prdFilePath = path.resolve(hqRoot, prdPath);
1062
1096
  if (!isReadable(prdFilePath)) {
1063
- stderr(io, `verify-story-deliverables: cannot read ${prdPath}\n`);
1097
+ stderr(io, `verify-story-deliverables: cannot read ${displayedPrdPath}\n`);
1064
1098
  return 2;
1065
1099
  }
1066
1100
  let prd;
@@ -1073,7 +1107,7 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1073
1107
  const userStories = Array.isArray(prd.userStories) ? prd.userStories : [];
1074
1108
  const story = userStories.find((item) => item && item.id === storyId);
1075
1109
  if (!story) {
1076
- stderr(io, `verify-story-deliverables: story ${storyId} not in ${prdPath}\n`);
1110
+ stderr(io, `verify-story-deliverables: story ${storyId} not in ${displayedPrdPath}\n`);
1077
1111
  return 2;
1078
1112
  }
1079
1113
  if (!repo) {
@@ -1081,7 +1115,10 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1081
1115
  ? prd.metadata : {};
1082
1116
  repo = jqText(metadata.repoPath);
1083
1117
  }
1084
- if (repo && !repo.startsWith("/"))
1118
+ // The shell sees Git Bash's /c/... spelling, which remains absolute. Native
1119
+ // receives the corresponding Win32 C:\\... path; preserve that absolute
1120
+ // location instead of prefixing HQ_ROOT and pointing at a nonexistent path.
1121
+ if (repo && !repo.startsWith("/") && !(repoExplicit && path.win32.isAbsolute(repo)))
1085
1122
  repo = `${hqRoot}/${repo}`;
1086
1123
  const evidence = jsonDecode(evidenceJson);
1087
1124
  if (!jqArrayCheck(evidence)) {
@@ -1099,7 +1136,7 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1099
1136
  // deliverables are left to jq's normal processing behavior.
1100
1137
  const refsRaw = declaredRaw;
1101
1138
  if (!Array.isArray(refsRaw)) {
1102
- stderr(io, `verify-story-deliverables: story ${storyId} not in ${prdPath}\n`);
1139
+ stderr(io, `verify-story-deliverables: story ${storyId} not in ${displayedPrdPath}\n`);
1103
1140
  return 2;
1104
1141
  }
1105
1142
  }
@@ -1138,11 +1175,11 @@ export async function runVerifyStoryDeliverables(args, hqRoot, io = {}) {
1138
1175
  else if (kind === "repo-path")
1139
1176
  ok = Boolean(repo) && fileExists(`${repo}/${value}`);
1140
1177
  else if (kind === "branch" && repo && fileExists(repo)) {
1141
- ok = git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/heads/${value}`]).status === 0 ||
1142
- git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${value}`]).status === 0;
1178
+ ok = git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/heads/${value}`], { env }).status === 0 ||
1179
+ git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/remotes/origin/${value}`], { env }).status === 0;
1143
1180
  }
1144
1181
  else if (kind === "commit" && repo && fileExists(repo)) {
1145
- ok = git(["-C", repo, "cat-file", "-e", `${value}^{commit}`]).status === 0;
1182
+ ok = git(["-C", repo, "cat-file", "-e", `${value}^{commit}`], { env }).status === 0;
1146
1183
  }
1147
1184
  else if (kind === "url")
1148
1185
  ok = offline === "1" || await urlIsPresent(value);
@@ -65,6 +65,16 @@ export interface UnitPlan {
65
65
  drainSignal?: "SIGUSR2";
66
66
  /** A new CLI version alone does not restart this unit; it restarts itself when idle. */
67
67
  keepAcrossCliUpdates?: true;
68
+ /**
69
+ * Scheduled units only: before each timed run the supervisor asks
70
+ * `SupervisorDeps.runGuard` whether there is work. `spawn` runs as usual,
71
+ * `idle` runs at most once per `idleIntervalSec`, `skip` does not run. A
72
+ * guard error, or no guard in the supervisor, runs as usual.
73
+ */
74
+ runGuard?: {
75
+ name: "lanes-reconcile";
76
+ idleIntervalSec: number;
77
+ };
68
78
  }
69
79
  export interface PlanContext {
70
80
  machineType: MachineType;
@@ -230,10 +230,18 @@ function planService(service, ctx) {
230
230
  return [
231
231
  base(service, {
232
232
  kind: "scheduled",
233
+ // Polls every 15 s so keep-alive resumes are admitted on time (#1396).
234
+ // Each pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS), so
235
+ // desktops first check the lane store in-process
236
+ // (lanes-reconcile-guard.ts) and fall back to the pre-#1396 60 s
237
+ // cadence, or no run, when no lane can need the 15 s poll.
233
238
  intervalSec: 15,
234
239
  initialDelaySec: 30,
235
240
  command: { type: "hq", args: ["lanes", "reconcile"] },
236
241
  env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
242
+ ...(ctx.machineType === "desktop"
243
+ ? { runGuard: { name: "lanes-reconcile", idleIntervalSec: 60 } }
244
+ : {}),
237
245
  blocked: firstBlock([[!ctx.hqRoot, NO_HQ_ROOT]]),
238
246
  }),
239
247
  ];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pre-spawn check for the desktop `lanes-reconcile` unit.
3
+ *
4
+ * Every reconcile pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS),
5
+ * so a 15-second poll costs about 12% of a core even when the lane store holds
6
+ * nothing to do. This check reads the lane store in-process (readdir + small
7
+ * JSON reads, no child process) and tells the supervisor how urgent the next
8
+ * pass is:
9
+ *
10
+ * - `spawn`: something the 15-second cadence exists for may be due — lane
11
+ * admission, a running lane's heartbeat, or a keep-alive resume (#1396).
12
+ * - `idle`: only work that ran on the pre-#1396 60-second cadence remains
13
+ * (terminal lanes, worker-profile resumes, question aging, Codex rollout
14
+ * retention, lead-autopilot registry). The supervisor runs it at most once
15
+ * per `idleIntervalSec`.
16
+ * - `skip`: the store holds no lane or question records, no lead registry and
17
+ * no lanes config, so a reconcile pass has no input to act on.
18
+ *
19
+ * Any error reading the store returns `spawn` (fail open), as does any record
20
+ * this check does not understand.
21
+ *
22
+ * The paths below mirror `LANES_STORE_REL`, `LANE_RECORDS_DIR`,
23
+ * `QUESTION_RECORDS_DIR` (lanes/store.ts), `LANES_CONFIG_REL` (lanes/config.ts)
24
+ * and `LEAD_REGISTRY_REL` (lanes/autopilot-registry.ts). They are copied, not
25
+ * imported, so the daemon does not load the lanes modules; a test pins them to
26
+ * the originals.
27
+ */
28
+ import type { UnitPlan } from "./catalog.js";
29
+ export type LanesReconcileVerdict = "spawn" | "idle" | "skip";
30
+ export declare const LANES_STORE_DIR: string;
31
+ export declare const LANE_RECORDS_SUBDIR = "lanes";
32
+ export declare const QUESTION_RECORDS_SUBDIR = "questions";
33
+ export declare const LANES_CONFIG_FILE: string;
34
+ export declare const LEAD_REGISTRY_FILE: string;
35
+ export interface LanesReconcileGuardFs {
36
+ readdirSync: (dir: string) => string[];
37
+ readFileSync: (file: string, encoding: "utf8") => string;
38
+ existsSync: (file: string) => boolean;
39
+ }
40
+ export declare function lanesReconcileVerdict(hqRoot: string | undefined, fsDeps?: LanesReconcileGuardFs): LanesReconcileVerdict;
41
+ /** The supervisor's `runGuard`: dispatches on the unit's declared guard. */
42
+ export declare function daemonRunGuard(unit: Pick<UnitPlan, "runGuard" | "env">): LanesReconcileVerdict;
43
+ //# sourceMappingURL=lanes-reconcile-guard.d.ts.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pre-spawn check for the desktop `lanes-reconcile` unit.
3
+ *
4
+ * Every reconcile pass is a fresh `hq` node process (~1.8 s CPU, ~200 MB RSS),
5
+ * so a 15-second poll costs about 12% of a core even when the lane store holds
6
+ * nothing to do. This check reads the lane store in-process (readdir + small
7
+ * JSON reads, no child process) and tells the supervisor how urgent the next
8
+ * pass is:
9
+ *
10
+ * - `spawn`: something the 15-second cadence exists for may be due — lane
11
+ * admission, a running lane's heartbeat, or a keep-alive resume (#1396).
12
+ * - `idle`: only work that ran on the pre-#1396 60-second cadence remains
13
+ * (terminal lanes, worker-profile resumes, question aging, Codex rollout
14
+ * retention, lead-autopilot registry). The supervisor runs it at most once
15
+ * per `idleIntervalSec`.
16
+ * - `skip`: the store holds no lane or question records, no lead registry and
17
+ * no lanes config, so a reconcile pass has no input to act on.
18
+ *
19
+ * Any error reading the store returns `spawn` (fail open), as does any record
20
+ * this check does not understand.
21
+ *
22
+ * The paths below mirror `LANES_STORE_REL`, `LANE_RECORDS_DIR`,
23
+ * `QUESTION_RECORDS_DIR` (lanes/store.ts), `LANES_CONFIG_REL` (lanes/config.ts)
24
+ * and `LEAD_REGISTRY_REL` (lanes/autopilot-registry.ts). They are copied, not
25
+ * imported, so the daemon does not load the lanes modules; a test pins them to
26
+ * the originals.
27
+ */
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ export const LANES_STORE_DIR = path.join("workspace", "lanes");
31
+ export const LANE_RECORDS_SUBDIR = "lanes";
32
+ export const QUESTION_RECORDS_SUBDIR = "questions";
33
+ export const LANES_CONFIG_FILE = path.join("personal", "lanes", "config.json");
34
+ export const LEAD_REGISTRY_FILE = path.join("workspace", "lane-briefs", "juniors.json");
35
+ /**
36
+ * Lane states that reconcile admission, heartbeats and liveness never act on.
37
+ * `blocked` is listed because reconcile only resumes a blocked lane when it is
38
+ * keep-alive, which is checked separately.
39
+ */
40
+ const QUIET_LANE_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed", "blocked"]);
41
+ const realFs = {
42
+ readdirSync: (dir) => fs.readdirSync(dir),
43
+ readFileSync: (file, encoding) => fs.readFileSync(file, encoding),
44
+ existsSync: (file) => fs.existsSync(file),
45
+ };
46
+ /** Same filter reconcile's `readDirJsonIds` applies. */
47
+ function recordNames(fsDeps, dir) {
48
+ let names;
49
+ try {
50
+ names = fsDeps.readdirSync(dir);
51
+ }
52
+ catch (err) {
53
+ if (err.code === "ENOENT")
54
+ return [];
55
+ throw err;
56
+ }
57
+ return names.filter((name) => name.endsWith(".json") && !name.startsWith(".") && !name.includes(".conflict-"));
58
+ }
59
+ function readRecord(fsDeps, file) {
60
+ const parsed = JSON.parse(fsDeps.readFileSync(file, "utf8"));
61
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
62
+ throw new Error(`${file} is not a JSON object`);
63
+ }
64
+ return parsed;
65
+ }
66
+ /** A lane that may need the 15-second cadence. Unknown shapes count as urgent. */
67
+ function laneIsUrgent(lane) {
68
+ if (typeof lane.state !== "string" || !QUIET_LANE_STATES.has(lane.state))
69
+ return true;
70
+ if (lane.keep_alive === true)
71
+ return true;
72
+ if (lane.pending_resume !== undefined)
73
+ return true;
74
+ return false;
75
+ }
76
+ export function lanesReconcileVerdict(hqRoot, fsDeps = realFs) {
77
+ try {
78
+ if (!hqRoot)
79
+ return "spawn";
80
+ const store = path.join(hqRoot, LANES_STORE_DIR);
81
+ const laneDir = path.join(store, LANE_RECORDS_SUBDIR);
82
+ const questionDir = path.join(store, QUESTION_RECORDS_SUBDIR);
83
+ const laneNames = recordNames(fsDeps, laneDir);
84
+ const questionNames = recordNames(fsDeps, questionDir);
85
+ for (const name of laneNames) {
86
+ if (laneIsUrgent(readRecord(fsDeps, path.join(laneDir, name))))
87
+ return "spawn";
88
+ }
89
+ for (const name of questionNames) {
90
+ const question = readRecord(fsDeps, path.join(questionDir, name));
91
+ if (typeof question.status !== "string" || question.status === "pending")
92
+ return "spawn";
93
+ }
94
+ if (laneNames.length === 0 &&
95
+ questionNames.length === 0 &&
96
+ !fsDeps.existsSync(path.join(hqRoot, LEAD_REGISTRY_FILE)) &&
97
+ !fsDeps.existsSync(path.join(hqRoot, LANES_CONFIG_FILE))) {
98
+ return "skip";
99
+ }
100
+ return "idle";
101
+ }
102
+ catch {
103
+ return "spawn";
104
+ }
105
+ }
106
+ /** The supervisor's `runGuard`: dispatches on the unit's declared guard. */
107
+ export function daemonRunGuard(unit) {
108
+ switch (unit.runGuard?.name) {
109
+ case "lanes-reconcile":
110
+ return lanesReconcileVerdict(unit.env.HQ_ROOT);
111
+ default:
112
+ return "spawn";
113
+ }
114
+ }
115
+ //# sourceMappingURL=lanes-reconcile-guard.js.map
@@ -45,6 +45,7 @@ import { adoptChild, createChildTracker, finishSuccession, handoffEligible, read
45
45
  import { listSyncRunnerProcesses, readWatchRunnerOwnerStatus, syncHeartbeatTimeoutMs, syncRunnerNodeOptions, watchRunnerOwnerStatusPath, } from "./sync-runner.js";
46
46
  import { STOP_ESCALATION_MS, createSupervisor, } from "./supervisor.js";
47
47
  import { userToolDirs } from "./tools.js";
48
+ import { daemonRunGuard } from "./lanes-reconcile-guard.js";
48
49
  export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
49
50
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
50
51
  export const RESTART_EXIT_CODE = 75;
@@ -788,6 +789,7 @@ export async function runDaemon(opts) {
788
789
  return takeControlRequests(paths.requestsDir);
789
790
  },
790
791
  restartDaemon: () => restartDaemon(),
792
+ runGuard: daemonRunGuard,
791
793
  writeState: (state) => writeJsonAtomic(paths.statePath, state),
792
794
  log,
793
795
  syncOwnerBlockReason: () => {
@@ -111,6 +111,8 @@ export interface SupervisorDeps {
111
111
  cliVersion?: string;
112
112
  /** Services to take over from the previous daemon instead of starting them. */
113
113
  adopt?: readonly AdoptedUnit[];
114
+ /** Pre-run check for scheduled units that declare `runGuard`; a throw counts as `spawn`. */
115
+ runGuard?: (unit: UnitPlan) => "spawn" | "idle" | "skip";
114
116
  }
115
117
  export interface Supervisor {
116
118
  start(): void;
@@ -56,6 +56,9 @@ export function planKey(plan, cliVersion) {
56
56
  plan.command,
57
57
  plan.env,
58
58
  ];
59
+ // Only units that declare a guard carry it, so existing keys stay unchanged.
60
+ if (plan.runGuard !== undefined)
61
+ fields.push({ runGuard: plan.runGuard });
59
62
  if (cliVersion !== undefined && plan.command?.type === "hq" && !plan.keepAcrossCliUpdates)
60
63
  fields.push(cliVersion);
61
64
  return JSON.stringify(fields);
@@ -554,6 +557,27 @@ export function createSupervisor(deps) {
554
557
  runScheduled(slot);
555
558
  }, delayMs);
556
559
  };
560
+ /** True when the unit's run guard says this timed run has nothing to do. Fails open. */
561
+ const guardSaysWait = (slot) => {
562
+ const guard = slot.plan.runGuard;
563
+ if (!guard || !deps.runGuard)
564
+ return false;
565
+ let verdict;
566
+ try {
567
+ verdict = deps.runGuard(slot.plan);
568
+ }
569
+ catch (err) {
570
+ deps.log("warn", `${slot.plan.id} run check failed, running anyway: ${errorMessage(err)}`);
571
+ return false;
572
+ }
573
+ if (verdict === "skip")
574
+ return true;
575
+ if (verdict === "idle") {
576
+ const last = slot.state.lastRunAt;
577
+ return last !== undefined && now() - last < guard.idleIntervalSec * 1000;
578
+ }
579
+ return false;
580
+ };
557
581
  const runScheduled = (slot, manual = false) => {
558
582
  if (stopped)
559
583
  return;
@@ -564,6 +588,10 @@ export function createSupervisor(deps) {
564
588
  emit();
565
589
  return;
566
590
  }
591
+ if (!manual && guardSaysWait(slot)) {
592
+ emit();
593
+ return;
594
+ }
567
595
  slot.busy = true;
568
596
  slot.state.lastRunAt = now();
569
597
  const command = slot.plan.command;
@@ -1,7 +1,5 @@
1
1
  import type { LaneCompanyRef, LaneHost } from "./types.js";
2
- export declare const OUTPOST_LEAD_REGISTRY_SCHEMA = "hq-lanes-outpost-lead-registry.v1";
3
- export declare const OUTPOST_LEAD_REGISTRY_MAX_AGE_MS: number;
4
- export declare const OUTPOST_LEAD_REGISTRY_REFRESH_MS: number;
2
+ declare const OUTPOST_LEAD_REGISTRY_SCHEMA = "hq-lanes-outpost-lead-registry.v1";
5
3
  export declare const LEAD_REGISTRY_REL: string;
6
4
  export declare const OUTPOST_LEAD_REGISTRY_STATE_REL: string;
7
5
  export declare const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
@@ -23,8 +21,6 @@ export declare function buildOutpostLeadRegistry(input: {
23
21
  outpostId: string;
24
22
  generatedAt: string;
25
23
  }): OutpostLeadRegistry;
26
- /** Stage a host-filtered registry on the Outpost using an atomic local rename. */
27
- export declare function buildOutpostLeadRegistryInstallCommand(registry: OutpostLeadRegistry): string;
28
24
  export declare function syncOutpostLeadRegistries(input: {
29
25
  hqRoot: string;
30
26
  outpostIds: readonly string[];
@@ -59,4 +55,5 @@ export declare function validateOutpostLeadRegistry(input: {
59
55
  outpostId: string;
60
56
  now: Date;
61
57
  }): OutpostLeadRegistry;
58
+ export {};
62
59
  //# sourceMappingURL=autopilot-registry.d.ts.map
@@ -4,9 +4,9 @@ import * as path from "node:path";
4
4
  import { joinCommandParts, withRemoteHqDir } from "@indigoai-us/hq-cloud/outposts";
5
5
  import { isControllerAliasOutpostId } from "./admission-host.js";
6
6
  import { LanesError } from "./errors.js";
7
- export const OUTPOST_LEAD_REGISTRY_SCHEMA = "hq-lanes-outpost-lead-registry.v1";
8
- export const OUTPOST_LEAD_REGISTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
9
- export const OUTPOST_LEAD_REGISTRY_REFRESH_MS = 6 * 60 * 60 * 1000;
7
+ const OUTPOST_LEAD_REGISTRY_SCHEMA = "hq-lanes-outpost-lead-registry.v1";
8
+ const OUTPOST_LEAD_REGISTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
9
+ const OUTPOST_LEAD_REGISTRY_REFRESH_MS = 6 * 60 * 60 * 1000;
10
10
  export const LEAD_REGISTRY_REL = path.join("workspace", "lane-briefs", "juniors.json");
11
11
  export const OUTPOST_LEAD_REGISTRY_STATE_REL = path.join("workspace", "lane-briefs", "autopilot-outpost-registry-state.json");
12
12
  export const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
@@ -50,7 +50,7 @@ export function buildOutpostLeadRegistry(input) {
50
50
  };
51
51
  }
52
52
  /** Stage a host-filtered registry on the Outpost using an atomic local rename. */
53
- export function buildOutpostLeadRegistryInstallCommand(registry) {
53
+ function buildOutpostLeadRegistryInstallCommand(registry) {
54
54
  const encoded = Buffer.from(`${JSON.stringify(registry)}\n`, "utf8").toString("base64");
55
55
  const script = [
56
56
  'const fs=require("node:fs"),path=require("node:path");',
@@ -53,7 +53,7 @@ export interface ProviderCompactionDelta {
53
53
  replaced: boolean;
54
54
  }
55
55
  /** Read newly appended, newline-complete marker records after a saved byte offset. */
56
- export declare function probeProviderCompactionsSince(transcriptPath: string, provider: LaneProvider, startOffset: number): ProviderCompactionDelta;
56
+ export declare function probeProviderCompactionsSince(transcriptPath: string, provider: LaneProvider, startOffset: number, maxLineBytes?: number): ProviderCompactionDelta;
57
57
  export declare function providerTranscriptSize(transcriptPath: string): number;
58
58
  /**
59
59
  * Read only provider compaction markers from a lane transcript. Claude writes
@@ -64,7 +64,7 @@ export declare function probeProviderCompactions(transcriptPath: string | null,
64
64
  /** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
65
65
  export declare function findSessionTranscript(home: string, sessionId: string): string | null;
66
66
  /** Count compact boundaries in a transcript. Other lines are never parsed. */
67
- export declare function probeCompactBoundaries(transcriptPath: string | null): CompactBoundaryProbe;
67
+ export declare function probeCompactBoundaries(transcriptPath: string | null, maxLineBytes?: number): CompactBoundaryProbe;
68
68
  interface CompactionCommandResult {
69
69
  status: number | null;
70
70
  error?: string;