@indigoai-us/hq-cli 5.345.10 → 5.345.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/assets/scaffold/core/scripts/hq-delegate-grant.sh +11 -4
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +11 -4
- package/assets/scaffold/core/scripts/hq-job-probe.sh +11 -4
- package/assets/scaffold/core/scripts/jobs-validate.sh +11 -4
- package/assets/scaffold/core/scripts/register-project.sh +5 -5
- package/assets/scaffold/core/scripts/repo-run-registry.sh +8 -8
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +4 -4
- package/assets/scaffold/core/scripts/session-journal.sh +3 -3
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +1 -1
- package/assets/scaffold/core/scripts/worktree-gc.sh +1 -1
- package/dist/commands/core-delegate-w1b.js +4 -1
- package/dist/commands/core-handoff.js +69 -34
- package/dist/commands/core-hq-session-native.js +31 -6
- package/dist/commands/core-job-notify-fast.d.ts +2 -0
- package/dist/commands/core-job-notify-fast.js +6 -0
- package/dist/commands/core-job-probe-fast.d.ts +2 -0
- package/dist/commands/core-job-probe-fast.js +11 -0
- package/dist/commands/core-job-probe-native.js +1 -1
- package/dist/commands/core-job-remediate-fast.d.ts +2 -0
- package/dist/commands/core-job-remediate-fast.js +6 -0
- package/dist/commands/core-job-run-fast.d.ts +2 -0
- package/dist/commands/core-job-run-fast.js +6 -0
- package/dist/commands/core-pin-fast.d.ts +2 -0
- package/dist/commands/core-pin-fast.js +15 -0
- package/dist/commands/core-rebuild-index-fast.d.ts +6 -0
- package/dist/commands/core-rebuild-index-fast.js +39 -0
- package/dist/commands/core-register-project-native.js +11 -2
- package/dist/commands/core-repo-run-registry-native.js +47 -4
- package/dist/commands/core-session-journal-native.d.ts +1 -1
- package/dist/commands/core-session-journal-native.js +4 -4
- package/dist/commands/core-session-project-native.js +2 -1
- package/dist/commands/core-share-suggestion-state-native.js +6 -2
- package/dist/commands/core-work-mesh-live-rebind-native.js +5 -2
- package/dist/commands/core-worker-lint-fast.d.ts +2 -0
- package/dist/commands/core-worker-lint-fast.js +11 -0
- package/dist/commands/core.js +9 -6
- package/dist/fast-scaffold.js +49 -0
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +4 -3
- package/dist/lib/core-utils/worktree-resume.d.ts +1 -0
- package/dist/lib/core-utils/worktree-resume.js +68 -31
- package/dist/lib/daemon/catalog.d.ts +10 -0
- package/dist/lib/daemon/catalog.js +8 -0
- package/dist/lib/daemon/lanes-reconcile-guard.d.ts +43 -0
- package/dist/lib/daemon/lanes-reconcile-guard.js +115 -0
- package/dist/lib/daemon/ops/journal.d.ts +0 -1
- package/dist/lib/daemon/ops/journal.js +1 -1
- package/dist/lib/daemon/ops/mcp.d.ts +0 -1
- package/dist/lib/daemon/ops/mcp.js +1 -1
- package/dist/lib/daemon/ops/policy.d.ts +0 -7
- package/dist/lib/daemon/ops/policy.js +1 -1
- package/dist/lib/daemon/run.js +2 -0
- package/dist/lib/daemon/supervisor.d.ts +2 -0
- package/dist/lib/daemon/supervisor.js +28 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +0 -2
- package/dist/lib/doctor/checks/sync-health.js +1 -1
- package/dist/lib/lanes/admission.d.ts +0 -2
- package/dist/lib/lanes/admission.js +2 -2
- package/dist/lib/lanes/autopilot-metrics.d.ts +5 -4
- package/dist/lib/lanes/autopilot-registry.d.ts +2 -5
- package/dist/lib/lanes/autopilot-registry.js +4 -4
- package/dist/lib/lanes/compaction.d.ts +2 -1
- package/dist/lib/lanes/delivery.d.ts +0 -2
- package/dist/lib/lanes/delivery.js +1 -1
- package/dist/lib/lanes/hooks-install.d.ts +0 -1
- package/dist/lib/lanes/hooks-install.js +1 -1
- package/dist/lib/lanes/merge-command.d.ts +2 -1
- package/dist/lib/lanes/orphans.d.ts +0 -1
- package/dist/lib/lanes/orphans.js +1 -1
- package/dist/lib/lanes/outpost-worktree-pruner.d.ts +3 -2
- package/dist/lib/lanes/provider-rejection.d.ts +0 -1
- package/dist/lib/lanes/provider-rejection.js +1 -1
- package/dist/lib/lanes/reconcile.d.ts +0 -5
- package/dist/lib/lanes/reconcile.js +4 -4
- package/dist/lib/lanes/runaway-tool-guard.d.ts +1 -1
- package/dist/lib/lanes/spawn.d.ts +1 -7
- package/dist/lib/lanes/spawn.js +4 -4
- package/dist/lib/lanes/throttle.d.ts +0 -1
- package/dist/lib/lanes/throttle.js +1 -1
- package/dist/lib/lanes/types.d.ts +6 -5
- package/dist/lib/lanes/watcher.d.ts +0 -9
- package/dist/lib/lanes/watcher.js +2 -2
- package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +0 -3
- package/dist/lib/mesh/live/daemon/legacy-listen.js +3 -3
- package/dist/lib/mesh/live/paths.d.ts +0 -1
- package/dist/lib/mesh/live/paths.js +1 -1
- package/dist/lib/mesh/live/spool.d.ts +0 -8
- package/dist/lib/mesh/live/spool.js +3 -3
- package/dist/lib/mesh/story-status.d.ts +2 -7
- package/dist/lib/mesh/story-status.js +2 -2
- package/dist/lib/sync-invalid-entity-warning.d.ts +0 -1
- package/dist/lib/sync-invalid-entity-warning.js +1 -1
- package/dist/lib/work-context/atomic.d.ts +0 -4
- package/dist/lib/work-context/atomic.js +1 -1
- package/dist/lib/work-context/contract.d.ts +2 -3
- package/dist/lib/work-context/contract.js +1 -1
- package/dist/lib/work-context/preflight-contract.d.ts +2 -7
- package/dist/lib/work-context/preflight-contract.js +1 -1
- package/dist/lib/work-context/repo-remote.d.ts +2 -1
- package/dist/utils/manifest.d.ts +0 -2
- package/dist/utils/manifest.js +2 -2
- package/dist/utils/resident-package-preload.d.ts +0 -5
- package/dist/utils/resident-package-preload.js +1 -1
- package/dist/utils/run-bundled-script.js +1 -1
- package/dist/utils/version-gate.d.ts +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.${
|
|
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.${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* <project>/journal/ is used, and one is opened (same template as
|
|
10
10
|
* `journal.sh open`) when none exists.
|
|
11
11
|
*/
|
|
12
|
-
export declare const JOURNAL_SECTIONS: Record<string, string>;
|
|
13
12
|
export declare class JournalOpError extends Error {
|
|
14
13
|
readonly code: string;
|
|
15
14
|
constructor(code: string, message: string);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
const SLUG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
15
|
-
|
|
15
|
+
const JOURNAL_SECTIONS = {
|
|
16
16
|
decisions: "## Decisions",
|
|
17
17
|
open: "## Open threads",
|
|
18
18
|
findings: "## Findings",
|
|
@@ -12,7 +12,6 @@ export interface McpToolResult {
|
|
|
12
12
|
isError?: boolean;
|
|
13
13
|
}
|
|
14
14
|
export declare function jsonResult(value: unknown): McpToolResult;
|
|
15
|
-
export declare const HQ_MCP_HTTP_TIMEOUT_MS = 30000;
|
|
16
15
|
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
17
16
|
export declare function defaultMcpToken(): Promise<string>;
|
|
18
17
|
export interface HqMcpBridgeOptions {
|
|
@@ -16,7 +16,7 @@ export function jsonResult(value) {
|
|
|
16
16
|
const structured = value && typeof value === "object" && !Array.isArray(value) ? value : { value };
|
|
17
17
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: structured };
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
const HQ_MCP_HTTP_TIMEOUT_MS = 30_000;
|
|
20
20
|
export function resolveHqMcpEndpoint(env = process.env) {
|
|
21
21
|
try {
|
|
22
22
|
return resolveEndpoint(env);
|
|
@@ -61,13 +61,6 @@ export interface PolicyCheckResult {
|
|
|
61
61
|
version: number;
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
/**
|
|
65
|
-
* The scope authorizer reads the session's company from
|
|
66
|
-
* workspace/sessions/<sid>/meta.yaml (or scope-capability.json). hqd binds a
|
|
67
|
-
* session id of its own per company + caller, so a check never borrows the
|
|
68
|
-
* binding of an unrelated session.
|
|
69
|
-
*/
|
|
70
|
-
export declare function bindCheckSession(hqRoot: string, company: string | undefined, callerSession: string | undefined, now: Date): string;
|
|
71
64
|
export interface PolicyCheckDeps {
|
|
72
65
|
hqRoot: string;
|
|
73
66
|
index: PolicyIndex;
|
|
@@ -160,7 +160,7 @@ function runScript(script, payload, env, cwd, timeoutMs) {
|
|
|
160
160
|
* session id of its own per company + caller, so a check never borrows the
|
|
161
161
|
* binding of an unrelated session.
|
|
162
162
|
*/
|
|
163
|
-
|
|
163
|
+
function bindCheckSession(hqRoot, company, callerSession, now) {
|
|
164
164
|
const caller = (callerSession ?? "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "default";
|
|
165
165
|
const scope = company ?? "personal";
|
|
166
166
|
const sid = `hqd-${scope}-${caller}`;
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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;
|
|
@@ -105,8 +105,6 @@ export interface SyncHealthDeps {
|
|
|
105
105
|
* outside `companies/`.
|
|
106
106
|
*/
|
|
107
107
|
export declare function isSafeCompanySlug(slug: string): boolean;
|
|
108
|
-
/** True when `hqRoot/companies/<slug>` exists as a directory. Never throws. */
|
|
109
|
-
export declare function defaultLocalCompanyExists(hqRoot: string, slug: string): boolean;
|
|
110
108
|
/** True when this journal slug names a company rather than the personal tree. */
|
|
111
109
|
export declare function isCompanyJournalSlug(slug: string): boolean;
|
|
112
110
|
/** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
|
|
@@ -68,7 +68,7 @@ export function isSafeCompanySlug(slug) {
|
|
|
68
68
|
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug);
|
|
69
69
|
}
|
|
70
70
|
/** True when `hqRoot/companies/<slug>` exists as a directory. Never throws. */
|
|
71
|
-
|
|
71
|
+
function defaultLocalCompanyExists(hqRoot, slug) {
|
|
72
72
|
if (!isSafeCompanySlug(slug))
|
|
73
73
|
return false;
|
|
74
74
|
try {
|
|
@@ -6,8 +6,6 @@ export interface AdmissionHost {
|
|
|
6
6
|
}
|
|
7
7
|
export type AdmissionCapReason = "provider_cap" | "account_cap" | "profile_cap";
|
|
8
8
|
export declare const ADMISSION_LOCK_STALE_MS = 30000;
|
|
9
|
-
export declare const ADMISSION_LOCK_TIMEOUT_MS = 5000;
|
|
10
|
-
export declare const ADMISSION_RESERVATION_GRACE_MS = 30000;
|
|
11
9
|
export declare function withLaneAdmissionLock<T>(hqRoot: string, provider: LaneRecord["provider"], fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
|
|
12
10
|
export declare function withAllLaneAdmissionLocks<T>(hqRoot: string, fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
|
|
13
11
|
export declare function laneRunsOnHost(lane: LaneRecord, host: AdmissionHost): boolean;
|
|
@@ -5,8 +5,8 @@ import { LanesError } from "./errors.js";
|
|
|
5
5
|
import { quotaBlockFor } from "./quota.js";
|
|
6
6
|
import { isTerminalLaneState } from "./types.js";
|
|
7
7
|
export const ADMISSION_LOCK_STALE_MS = 30_000;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
|
|
9
|
+
const ADMISSION_RESERVATION_GRACE_MS = 30_000;
|
|
10
10
|
function defaultAdmissionSleep(ms) {
|
|
11
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
12
|
}
|