@indigoai-us/hq-cli 5.281.0 → 5.283.0
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 +37 -0
- package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
- package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
- package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
- package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
- package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
- package/dist/command-catalog.generated.d.ts +67 -0
- package/dist/command-catalog.generated.js +83 -0
- package/dist/commands/core-knowledge-prefs.d.ts +10 -0
- package/dist/commands/core-knowledge-prefs.js +125 -0
- package/dist/commands/core-native-policy-flag.d.ts +9 -0
- package/dist/commands/core-native-policy-flag.js +12 -0
- package/dist/commands/core-ontology-native.d.ts +0 -1
- package/dist/commands/core-ontology-native.js +2 -30
- package/dist/commands/core-ontology.d.ts +4 -0
- package/dist/commands/core-ontology.js +8 -3
- package/dist/commands/core-policy-frontmatter.d.ts +8 -0
- package/dist/commands/core-policy-frontmatter.js +52 -0
- package/dist/commands/core-policy.d.ts +15 -0
- package/dist/commands/core-policy.js +982 -0
- package/dist/commands/core.js +14 -1
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-move.js +2 -0
- package/dist/commands/lanes-resume.js +1 -0
- package/dist/commands/lanes-shared.d.ts +10 -1
- package/dist/commands/lanes-shared.js +2 -1
- package/dist/commands/lanes.d.ts +8 -0
- package/dist/commands/lanes.js +324 -93
- package/dist/fast-lanes-list-route.d.ts +8 -0
- package/dist/fast-lanes-list-route.js +30 -0
- package/dist/fast-lanes-list.d.ts +3 -0
- package/dist/fast-lanes-list.js +252 -0
- package/dist/fast-scaffold.d.ts +5 -0
- package/dist/fast-scaffold.js +10 -0
- package/dist/index.js +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
- package/dist/lib/lanes/flag-keys.d.ts +3 -0
- package/dist/lib/lanes/flag-keys.js +3 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +1 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -1
- package/dist/lib/lanes/outpost-dispatch.js +4 -2
- package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
- package/dist/lib/lanes/outpost-hire-cache.js +11 -3
- package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
- package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
- package/dist/lib/lanes/reconcile.d.ts +7 -1
- package/dist/lib/lanes/reconcile.js +88 -16
- package/dist/lib/lanes/spawn.js +5 -0
- package/dist/lib/lanes/store.d.ts +4 -0
- package/dist/lib/lanes/store.js +66 -0
- package/dist/lib/lanes/throttle.d.ts +29 -2
- package/dist/lib/lanes/throttle.js +145 -5
- package/dist/lib/lanes/types.d.ts +8 -1
- package/dist/lib/lanes/types.js +2 -1
- package/dist/lib/lanes/watcher.js +1 -0
- package/dist/lib/policy-validator.d.ts +2 -0
- package/dist/lib/policy-validator.js +136 -0
- package/dist/native-fast-route.d.ts +2 -0
- package/dist/native-fast-route.js +6 -2
- package/dist/utils/contribution-table.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3000;
|
|
2
|
+
export declare const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45000;
|
|
3
|
+
export interface OutpostSnapshotCachePolicy {
|
|
4
|
+
ttlMs?: number;
|
|
5
|
+
staleLockMs?: number;
|
|
6
|
+
lockWaitMs?: number;
|
|
7
|
+
pollMs?: number;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
acquireLock?: (lockPath: string, token: string, nowMs: number) => void;
|
|
11
|
+
}
|
|
12
|
+
/** Deduplicates identical remote snapshot queries within one command invocation. */
|
|
13
|
+
export declare class OutpostSnapshotInvocationMemo {
|
|
14
|
+
private readonly requests;
|
|
15
|
+
getOrCreate<T>(outpostId: string, query: unknown, fetch: () => Promise<T>): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
export declare function outpostLaneListSnapshotKey(outpostId: string, normalizedQuery: unknown): string;
|
|
18
|
+
export declare function outpostSnapshotCachePath(hqRoot: string, key: string): string;
|
|
19
|
+
export declare function getOrFetchOutpostSnapshot<T>(input: {
|
|
20
|
+
hqRoot: string;
|
|
21
|
+
key: string;
|
|
22
|
+
isValue: (value: unknown) => value is T;
|
|
23
|
+
fetch: () => Promise<T>;
|
|
24
|
+
policy?: OutpostSnapshotCachePolicy;
|
|
25
|
+
}): Promise<T>;
|
|
26
|
+
//# sourceMappingURL=outpost-snapshot-cache.d.ts.map
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
export const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3_000;
|
|
6
|
+
export const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45_000;
|
|
7
|
+
const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-snapshots");
|
|
8
|
+
const CACHE_SCHEMA_VERSION = "hq-lanes-outpost-snapshot-cache.v1";
|
|
9
|
+
const LOCK_POLL_MS = 20;
|
|
10
|
+
const LOCK_WAIT_MS = 60_000;
|
|
11
|
+
/** Deduplicates identical remote snapshot queries within one command invocation. */
|
|
12
|
+
export class OutpostSnapshotInvocationMemo {
|
|
13
|
+
requests = new Map();
|
|
14
|
+
getOrCreate(outpostId, query, fetch) {
|
|
15
|
+
const key = JSON.stringify([outpostId, query]);
|
|
16
|
+
const existing = this.requests.get(key);
|
|
17
|
+
if (existing)
|
|
18
|
+
return existing;
|
|
19
|
+
const request = fetch();
|
|
20
|
+
this.requests.set(key, request);
|
|
21
|
+
return request;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function outpostLaneListSnapshotKey(outpostId, normalizedQuery) {
|
|
25
|
+
return JSON.stringify(["lane-list", outpostId, normalizedQuery]);
|
|
26
|
+
}
|
|
27
|
+
export function outpostSnapshotCachePath(hqRoot, key) {
|
|
28
|
+
const digest = createKeyDigest(key);
|
|
29
|
+
return path.join(hqRoot, CACHE_RELATIVE_PATH, `${digest}.json`);
|
|
30
|
+
}
|
|
31
|
+
export async function getOrFetchOutpostSnapshot(input) {
|
|
32
|
+
const policy = input.policy ?? {};
|
|
33
|
+
const now = policy.now ?? Date.now;
|
|
34
|
+
const ttlMs = policy.ttlMs ?? OUTPOST_SNAPSHOT_CACHE_TTL_MS;
|
|
35
|
+
const staleLockMs = policy.staleLockMs ?? OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS;
|
|
36
|
+
const lockWaitMs = policy.lockWaitMs ?? LOCK_WAIT_MS;
|
|
37
|
+
const pollMs = policy.pollMs ?? LOCK_POLL_MS;
|
|
38
|
+
const sleep = policy.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
39
|
+
const filePath = outpostSnapshotCachePath(input.hqRoot, input.key);
|
|
40
|
+
const cached = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
41
|
+
if (cached !== undefined)
|
|
42
|
+
return cached;
|
|
43
|
+
const lockPath = `${filePath}.lock`;
|
|
44
|
+
const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
45
|
+
const startedAt = now();
|
|
46
|
+
const acquireLock = policy.acquireLock ?? createCacheLock;
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
acquireLock(lockPath, token, now());
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const code = err.code;
|
|
54
|
+
if (code !== "EEXIST") {
|
|
55
|
+
process.stderr.write(`${JSON.stringify({
|
|
56
|
+
event: "lanes.outpost_snapshot_cache_lock_unavailable",
|
|
57
|
+
error: typeof code === "string" ? code : "UnknownError",
|
|
58
|
+
fallback: "uncached",
|
|
59
|
+
})}\n`);
|
|
60
|
+
const value = await input.fetch();
|
|
61
|
+
if (!input.isValue(value)) {
|
|
62
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
if (recoverStaleLock(lockPath, staleLockMs, now()))
|
|
67
|
+
continue;
|
|
68
|
+
const afterWait = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
69
|
+
if (afterWait !== undefined)
|
|
70
|
+
return afterWait;
|
|
71
|
+
if (now() - startedAt >= lockWaitMs) {
|
|
72
|
+
throw new LanesError("outpost_snapshot_cache_lock_timeout", "Timed out waiting for an Outpost snapshot refresh", { path: lockPath });
|
|
73
|
+
}
|
|
74
|
+
await sleep(pollMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const afterLock = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
79
|
+
if (afterLock !== undefined)
|
|
80
|
+
return afterLock;
|
|
81
|
+
const value = await input.fetch();
|
|
82
|
+
if (!input.isValue(value)) {
|
|
83
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
84
|
+
}
|
|
85
|
+
writeCache(filePath, input.key, value, now());
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
releaseLock(lockPath, token);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function createCacheLock(lockPath, token, nowMs) {
|
|
93
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
94
|
+
const lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
fs.writeSync(lockFd, `${token}\n${nowMs}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
fs.closeSync(lockFd);
|
|
101
|
+
}
|
|
102
|
+
catch (closeErr) {
|
|
103
|
+
process.stderr.write(`lanes snapshot cache: lock close failed: ${errorText(closeErr)}\n`);
|
|
104
|
+
}
|
|
105
|
+
releaseLock(lockPath, token);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
fs.closeSync(lockFd);
|
|
109
|
+
}
|
|
110
|
+
function createKeyDigest(key) {
|
|
111
|
+
// A lane-list query can contain lane ids. Keep raw ids out of cache filenames.
|
|
112
|
+
return createHash("sha256").update(key).digest("hex");
|
|
113
|
+
}
|
|
114
|
+
function readFreshCache(filePath, key, nowMs, ttlMs, isValue) {
|
|
115
|
+
let contents;
|
|
116
|
+
try {
|
|
117
|
+
contents = fs.readFileSync(filePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code === "ENOENT")
|
|
121
|
+
return undefined;
|
|
122
|
+
process.stderr.write(`lanes snapshot cache: read failed: ${errorText(err)}\n`);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = JSON.parse(contents);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
process.stderr.write(`lanes snapshot cache: ignoring malformed cache: ${errorText(err)}\n`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
134
|
+
return undefined;
|
|
135
|
+
const entry = raw;
|
|
136
|
+
if (Object.keys(entry).length !== 4 ||
|
|
137
|
+
entry.schema_version !== CACHE_SCHEMA_VERSION ||
|
|
138
|
+
entry.key !== key ||
|
|
139
|
+
!Number.isSafeInteger(entry.cached_at) ||
|
|
140
|
+
entry.cached_at > nowMs ||
|
|
141
|
+
nowMs - entry.cached_at >= ttlMs ||
|
|
142
|
+
!isValue(entry.value))
|
|
143
|
+
return undefined;
|
|
144
|
+
return entry.value;
|
|
145
|
+
}
|
|
146
|
+
function writeCache(filePath, key, value, nowMs) {
|
|
147
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
const fd = fs.openSync(temporaryPath, "wx", 0o600);
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(fd, `${JSON.stringify({
|
|
152
|
+
schema_version: CACHE_SCHEMA_VERSION,
|
|
153
|
+
key,
|
|
154
|
+
cached_at: nowMs,
|
|
155
|
+
value,
|
|
156
|
+
})}\n`);
|
|
157
|
+
fs.fsyncSync(fd);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
fs.closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
fs.renameSync(temporaryPath, filePath);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(temporaryPath);
|
|
167
|
+
}
|
|
168
|
+
catch (cleanupErr) {
|
|
169
|
+
if (cleanupErr.code !== "ENOENT") {
|
|
170
|
+
process.stderr.write(`lanes snapshot cache: temp cleanup failed: ${errorText(cleanupErr)}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The cache is an optimization; a successful, validated remote result stays usable.
|
|
174
|
+
process.stderr.write(`lanes snapshot cache: write failed: ${errorText(err)}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function recoverStaleLock(lockPath, staleLockMs, nowMs) {
|
|
178
|
+
let stat;
|
|
179
|
+
try {
|
|
180
|
+
stat = fs.statSync(lockPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return err.code === "ENOENT";
|
|
184
|
+
}
|
|
185
|
+
if (nowMs - stat.mtimeMs <= staleLockMs)
|
|
186
|
+
return false;
|
|
187
|
+
const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(lockPath, stalePath);
|
|
190
|
+
fs.unlinkSync(stalePath);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const code = err.code;
|
|
195
|
+
if (code === "ENOENT")
|
|
196
|
+
return true;
|
|
197
|
+
throw new LanesError("outpost_snapshot_cache_lock_recovery_failed", `Could not recover a stale Outpost snapshot lock: ${errorText(err)}`, { path: lockPath });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function releaseLock(lockPath, token) {
|
|
201
|
+
try {
|
|
202
|
+
const contents = fs.readFileSync(lockPath, "utf8");
|
|
203
|
+
if (contents.split("\n", 1)[0] === token)
|
|
204
|
+
fs.unlinkSync(lockPath);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err.code !== "ENOENT") {
|
|
208
|
+
process.stderr.write(`lanes snapshot cache: lock release failed: ${errorText(err)}\n`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function errorText(err) {
|
|
213
|
+
return err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=outpost-snapshot-cache.js.map
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type LivenessDeps } from "./liveness.js";
|
|
9
9
|
import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
|
|
10
|
-
import { type ThrottleDeps } from "./throttle.js";
|
|
10
|
+
import { type ThrottleDeps, type CpuBusyObservation } from "./throttle.js";
|
|
11
11
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
12
12
|
import { type RunawayProcessInfo } from "./runaway-tool-guard.js";
|
|
13
13
|
import { type KeepAliveBlockedReason } from "./keep-alive.js";
|
|
@@ -78,6 +78,12 @@ export interface ReconcileDeps {
|
|
|
78
78
|
readSessionSenior?: (sessionId: string) => SeniorRef;
|
|
79
79
|
presenceConnected?: boolean;
|
|
80
80
|
env?: NodeJS.ProcessEnv;
|
|
81
|
+
/** CPU sample taken before acquiring provider admission locks. */
|
|
82
|
+
cpuBusyFraction?: number | null;
|
|
83
|
+
/** CPU observation and cumulative counters taken before provider locks. */
|
|
84
|
+
cpuObservation?: CpuBusyObservation;
|
|
85
|
+
/** Skip admission this pass if candidates appeared after the pre-sample snapshot. */
|
|
86
|
+
skipAdmissionWithoutCpuSample?: boolean;
|
|
81
87
|
/** Flag reader seam used to assert rollout reads happen before admission locks. */
|
|
82
88
|
readRegistryFlag?: typeof readLanesRegistryFlag;
|
|
83
89
|
/** Pre-read manager gate decisions, scoped to each lane for this pass. */
|
|
@@ -20,7 +20,7 @@ import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./gr
|
|
|
20
20
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
21
21
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readProcStarttime, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
22
22
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
23
|
-
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
23
|
+
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyObservation, recordCpuBusyObservation, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
24
24
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
25
25
|
import { resolveLaneCompany } from "./lane-company.js";
|
|
26
26
|
import { appendDropboxMessage } from "./dropbox.js";
|
|
@@ -358,7 +358,7 @@ function casLaneIf(hqRoot, laneId, shouldWrite, updater, dryRun) {
|
|
|
358
358
|
function writeQueueReason(hqRoot, lane, reason, dryRun) {
|
|
359
359
|
if (lane.queued_reason === reason)
|
|
360
360
|
return;
|
|
361
|
-
casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" && current.queued_reason !== reason, (current) => ({ ...current, queued_reason: reason, admission_reserved_at: undefined }), dryRun);
|
|
361
|
+
casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" && current.queued_reason !== reason, (current) => ({ ...current, queued_reason: reason, queued_details: undefined, admission_reserved_at: undefined }), dryRun);
|
|
362
362
|
}
|
|
363
363
|
function availableBalancedAccount(hqRoot, lane, occupying, config, host, profileMaxConcurrent, nowMs) {
|
|
364
364
|
if (!lane.account || !lane.admission_accounts?.includes(lane.account))
|
|
@@ -482,6 +482,7 @@ function expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now) {
|
|
|
482
482
|
...current,
|
|
483
483
|
state: "failed",
|
|
484
484
|
queued_reason: undefined,
|
|
485
|
+
queued_details: undefined,
|
|
485
486
|
pickup_validation_pending: undefined,
|
|
486
487
|
pickup_validation_pending_since: undefined,
|
|
487
488
|
terminal_reason: PICKUP_VALIDATION_TIMEOUT_REASON,
|
|
@@ -998,14 +999,40 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
998
999
|
keepAliveFlagByCompany.set(key, keepAliveEnabled);
|
|
999
1000
|
supersessionLineageAwareByCompany.set(key, lineageAware);
|
|
1000
1001
|
}));
|
|
1002
|
+
const admissionThrottleDeps = startDeps.throttleDeps ?? defaultThrottleDeps(startDeps.env ?? process.env);
|
|
1003
|
+
const hasAdmissionCandidates = lanes.some((lane) => laneRunsHere(lane, startDeps.host) &&
|
|
1004
|
+
(lane.state === "queued" || lane.state === "awaiting_input")) || [...keepAliveFlagByCompany.values()].some(Boolean);
|
|
1005
|
+
const priorThrottleState = readThrottleState(throttleStatePath(hqRoot), admissionThrottleDeps);
|
|
1006
|
+
const priorThrottleSnapshot = priorThrottleState === "missing" || priorThrottleState === "unreadable"
|
|
1007
|
+
? undefined
|
|
1008
|
+
: priorThrottleState.cpu_snapshot;
|
|
1009
|
+
const cpuObservation = await sampleCpuBusyObservation(admissionThrottleDeps, priorThrottleSnapshot, { measureBusy: hasAdmissionCandidates });
|
|
1010
|
+
const admissionDeps = {
|
|
1011
|
+
...startDeps,
|
|
1012
|
+
cpuBusyFraction: cpuObservation.busy,
|
|
1013
|
+
cpuObservation,
|
|
1014
|
+
skipAdmissionWithoutCpuSample: !hasAdmissionCandidates,
|
|
1015
|
+
};
|
|
1001
1016
|
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
1002
1017
|
const freshLanes = loadLaneRecords(hqRoot, summary);
|
|
1003
1018
|
const freshQuestions = loadQuestionRecords(hqRoot, summary);
|
|
1004
|
-
await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary,
|
|
1019
|
+
await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
|
|
1005
1020
|
const drainLanes = loadLaneRecords(hqRoot, summary);
|
|
1006
|
-
await admitQueued(hqRoot, drainLanes, summary,
|
|
1007
|
-
await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary,
|
|
1021
|
+
await admitQueued(hqRoot, drainLanes, summary, admissionDeps, now);
|
|
1022
|
+
await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, admissionDeps);
|
|
1008
1023
|
});
|
|
1024
|
+
if (!deps.dryRun && cpuObservation.snapshot !== undefined) {
|
|
1025
|
+
const latestThrottleState = readThrottleState(throttleStatePath(hqRoot), admissionThrottleDeps);
|
|
1026
|
+
if (latestThrottleState === "unreadable") {
|
|
1027
|
+
markError(summary, new LanesError("throttle_state_unreadable", "Throttle state became unreadable while reconciling"));
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
const currentThrottleState = latestThrottleState === "missing"
|
|
1031
|
+
? emptyThrottleState()
|
|
1032
|
+
: latestThrottleState;
|
|
1033
|
+
writeThrottleState(throttleStatePath(hqRoot), recordCpuBusyObservation(currentThrottleState, cpuObservation), admissionThrottleDeps);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1009
1036
|
if (!deps.dryRun && summary.errors.length === 0) {
|
|
1010
1037
|
try {
|
|
1011
1038
|
writeHeartbeatFile(hqRoot, summary, nowIso);
|
|
@@ -1246,6 +1273,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1246
1273
|
...current,
|
|
1247
1274
|
state: terminalClassification.state,
|
|
1248
1275
|
queued_reason: undefined,
|
|
1276
|
+
queued_details: undefined,
|
|
1249
1277
|
terminal_reason: terminalClassification.reason ?? "host_lost",
|
|
1250
1278
|
interrupted_at: terminalClassification.state === "interrupted" ? nowIso : undefined,
|
|
1251
1279
|
...(providerRejection
|
|
@@ -1390,6 +1418,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1390
1418
|
...current,
|
|
1391
1419
|
state: "interrupted",
|
|
1392
1420
|
queued_reason: undefined,
|
|
1421
|
+
queued_details: undefined,
|
|
1393
1422
|
terminal_reason: "host_lost",
|
|
1394
1423
|
interrupted_at: nowIso,
|
|
1395
1424
|
}), deps.dryRun);
|
|
@@ -1448,7 +1477,7 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1448
1477
|
laneRunsHere(lane, deps.host))
|
|
1449
1478
|
.filter((lane) => !hasFreshAdmissionReservation(lane, now.getTime()))
|
|
1450
1479
|
.sort(compareLaneQueueOrder);
|
|
1451
|
-
if (localQueued.length === 0)
|
|
1480
|
+
if (localQueued.length === 0 || deps.skipAdmissionWithoutCpuSample === true)
|
|
1452
1481
|
return;
|
|
1453
1482
|
const config = loadLanesConfig(hqRoot);
|
|
1454
1483
|
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
@@ -1459,7 +1488,9 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1459
1488
|
: loaded === "missing"
|
|
1460
1489
|
? emptyThrottleState()
|
|
1461
1490
|
: loaded;
|
|
1462
|
-
const busy =
|
|
1491
|
+
const busy = deps.cpuBusyFraction !== undefined
|
|
1492
|
+
? deps.cpuBusyFraction
|
|
1493
|
+
: await resolveCpuBusyFraction(throttleDeps);
|
|
1463
1494
|
const availableMemMb = readAvailableMemMb(throttleDeps);
|
|
1464
1495
|
const nowMs = now.getTime();
|
|
1465
1496
|
const decision = evaluateThrottle({
|
|
@@ -1476,7 +1507,6 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1476
1507
|
hasFreshAdmissionReservation(lane, now.getTime()) ||
|
|
1477
1508
|
hasActivePendingResume(lane, now.getTime())) &&
|
|
1478
1509
|
laneRunsHere(lane, deps.host));
|
|
1479
|
-
const starvation = decision.admit && decision.reason === "starvation_bound";
|
|
1480
1510
|
const spawned = [];
|
|
1481
1511
|
if (!decision.admit) {
|
|
1482
1512
|
for (const lane of localQueued) {
|
|
@@ -1543,8 +1573,6 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1543
1573
|
reason: decision.reason,
|
|
1544
1574
|
});
|
|
1545
1575
|
spawned.push({ ...admittedLane, state: "running" });
|
|
1546
|
-
if (starvation)
|
|
1547
|
-
break;
|
|
1548
1576
|
continue;
|
|
1549
1577
|
}
|
|
1550
1578
|
const started = spawnFn(hqRoot, admittedLane);
|
|
@@ -1564,10 +1592,38 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1564
1592
|
: {}),
|
|
1565
1593
|
});
|
|
1566
1594
|
spawned.push(started);
|
|
1567
|
-
if (starvation)
|
|
1568
|
-
break;
|
|
1569
1595
|
}
|
|
1570
1596
|
catch (err) {
|
|
1597
|
+
if (err instanceof LanesError && err.error === "worker_profile_hash_mismatch") {
|
|
1598
|
+
const expectedHash = typeof err.details.expected_hash === "string"
|
|
1599
|
+
? err.details.expected_hash
|
|
1600
|
+
: null;
|
|
1601
|
+
const actualHash = typeof err.details.actual_hash === "string"
|
|
1602
|
+
? err.details.actual_hash
|
|
1603
|
+
: undefined;
|
|
1604
|
+
const workerProfile = typeof err.details.worker === "string"
|
|
1605
|
+
? err.details.worker
|
|
1606
|
+
: lane.worker_profile;
|
|
1607
|
+
if (actualHash !== undefined && workerProfile !== undefined) {
|
|
1608
|
+
casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued", (current) => ({
|
|
1609
|
+
...current,
|
|
1610
|
+
queued_reason: "worker_profile_changed",
|
|
1611
|
+
queued_details: {
|
|
1612
|
+
reason: "worker_profile_changed",
|
|
1613
|
+
expected_hash: expectedHash,
|
|
1614
|
+
actual_hash: actualHash,
|
|
1615
|
+
recovery_command: `hq lanes resume ${lane.lane_id} --worker ${workerProfile}`,
|
|
1616
|
+
},
|
|
1617
|
+
admission_reserved_at: undefined,
|
|
1618
|
+
}), false);
|
|
1619
|
+
summary.skipped.push({
|
|
1620
|
+
kind: "lane",
|
|
1621
|
+
id: lane.lane_id,
|
|
1622
|
+
why: "worker_profile_changed",
|
|
1623
|
+
});
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1571
1627
|
terminalizeModelUnsupported(hqRoot, lane, err);
|
|
1572
1628
|
markDirty(summary, {
|
|
1573
1629
|
kind: "lane",
|
|
@@ -2273,6 +2329,7 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
|
|
|
2273
2329
|
...latestLane,
|
|
2274
2330
|
state: "queued",
|
|
2275
2331
|
queued_reason: undefined,
|
|
2332
|
+
queued_details: undefined,
|
|
2276
2333
|
queued_at: nowIso,
|
|
2277
2334
|
admission_reserved_at: undefined,
|
|
2278
2335
|
terminal_reason: undefined,
|
|
@@ -2348,7 +2405,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2348
2405
|
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
|
|
2349
2406
|
laneRunsHere(lane, deps.host) &&
|
|
2350
2407
|
(onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)));
|
|
2351
|
-
if (awaiting.length === 0)
|
|
2408
|
+
if (awaiting.length === 0 || deps.skipAdmissionWithoutCpuSample === true)
|
|
2352
2409
|
return;
|
|
2353
2410
|
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
2354
2411
|
let throttleDecision;
|
|
@@ -2356,7 +2413,9 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2356
2413
|
const loaded = readThrottleState(throttleStatePath(hqRoot), throttleDeps);
|
|
2357
2414
|
const state = loaded === "missing" ? emptyThrottleState() : loaded;
|
|
2358
2415
|
throttleDecision = evaluateThrottle({
|
|
2359
|
-
busy:
|
|
2416
|
+
busy: deps.cpuBusyFraction !== undefined
|
|
2417
|
+
? deps.cpuBusyFraction
|
|
2418
|
+
: deps.cpuObservation?.busy ?? await resolveCpuBusyFraction(throttleDeps),
|
|
2360
2419
|
availableMemMb: readAvailableMemMb(throttleDeps),
|
|
2361
2420
|
cpuCheckEnabled: isCpuCheckEnabled(throttleDeps.env),
|
|
2362
2421
|
state,
|
|
@@ -2365,7 +2424,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2365
2424
|
});
|
|
2366
2425
|
}
|
|
2367
2426
|
else {
|
|
2368
|
-
const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps);
|
|
2427
|
+
const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps, deps.cpuBusyFraction, deps.cpuObservation);
|
|
2369
2428
|
throttleDecision = throttle.decision;
|
|
2370
2429
|
}
|
|
2371
2430
|
const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps, lane)));
|
|
@@ -2444,6 +2503,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2444
2503
|
...latest,
|
|
2445
2504
|
state: "queued",
|
|
2446
2505
|
queued_reason: queueReason,
|
|
2506
|
+
queued_details: undefined,
|
|
2447
2507
|
queued_at: answeredAt,
|
|
2448
2508
|
pending_resume: {
|
|
2449
2509
|
...(latest.provider_session_id
|
|
@@ -2503,14 +2563,26 @@ export async function runReconcilePass(hqRoot, deps) {
|
|
|
2503
2563
|
/** Run the reconcile answer-resume path for one lane before questions answer returns. */
|
|
2504
2564
|
export async function wakeAnsweredLaneNow(hqRoot, laneId, deps) {
|
|
2505
2565
|
const summary = emptySummary(deps.dryRun);
|
|
2566
|
+
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
2506
2567
|
await withReconcileLock(hqRoot, async () => {
|
|
2568
|
+
const previousState = readThrottleState(throttleStatePath(hqRoot), throttleDeps);
|
|
2569
|
+
const previousSnapshot = previousState === "missing" || previousState === "unreadable"
|
|
2570
|
+
? undefined
|
|
2571
|
+
: previousState.cpu_snapshot;
|
|
2572
|
+
const cpuObservation = await sampleCpuBusyObservation(throttleDeps, previousSnapshot);
|
|
2573
|
+
const admissionDeps = {
|
|
2574
|
+
...deps,
|
|
2575
|
+
cpuBusyFraction: cpuObservation.busy,
|
|
2576
|
+
cpuObservation,
|
|
2577
|
+
skipAdmissionWithoutCpuSample: false,
|
|
2578
|
+
};
|
|
2507
2579
|
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
2508
2580
|
const lanes = loadLaneRecords(hqRoot, summary);
|
|
2509
2581
|
const questions = loadQuestionRecords(hqRoot, summary);
|
|
2510
2582
|
if (!lanes.some((lane) => lane.lane_id === laneId)) {
|
|
2511
2583
|
throw new LanesError("lane_not_found", `Lane ${laneId} was not found`, { lane_id: laneId });
|
|
2512
2584
|
}
|
|
2513
|
-
await resumeAwaiting(hqRoot, lanes, questions, summary,
|
|
2585
|
+
await resumeAwaiting(hqRoot, lanes, questions, summary, admissionDeps, new Set([laneId]));
|
|
2514
2586
|
});
|
|
2515
2587
|
});
|
|
2516
2588
|
const latest = readLane(hqRoot, laneId);
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -476,6 +476,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
|
|
|
476
476
|
...row,
|
|
477
477
|
state: "queued",
|
|
478
478
|
queued_reason: "account_retry",
|
|
479
|
+
queued_details: undefined,
|
|
479
480
|
queued_at: at,
|
|
480
481
|
admission_reserved_at: undefined,
|
|
481
482
|
terminal_reason: undefined,
|
|
@@ -1360,6 +1361,7 @@ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details) {
|
|
|
1360
1361
|
...current,
|
|
1361
1362
|
state: "failed",
|
|
1362
1363
|
queued_reason: undefined,
|
|
1364
|
+
queued_details: undefined,
|
|
1363
1365
|
queued_at: undefined,
|
|
1364
1366
|
admission_reserved_at: undefined,
|
|
1365
1367
|
terminal_reason: reason,
|
|
@@ -1425,6 +1427,7 @@ function markOutpostSelfTarget(hqRoot, laneId, details) {
|
|
|
1425
1427
|
...current,
|
|
1426
1428
|
state: "cancelled",
|
|
1427
1429
|
queued_reason: undefined,
|
|
1430
|
+
queued_details: undefined,
|
|
1428
1431
|
terminal_reason: "outpost_self_target",
|
|
1429
1432
|
terminal_details: details,
|
|
1430
1433
|
};
|
|
@@ -1948,6 +1951,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1948
1951
|
...current,
|
|
1949
1952
|
state: "queued",
|
|
1950
1953
|
queued_reason: "provider_binary_missing",
|
|
1954
|
+
queued_details: undefined,
|
|
1951
1955
|
terminal_reason: undefined,
|
|
1952
1956
|
terminal_details: {
|
|
1953
1957
|
provider: lane.provider,
|
|
@@ -2024,6 +2028,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2024
2028
|
...current,
|
|
2025
2029
|
state: "running",
|
|
2026
2030
|
queued_reason: undefined,
|
|
2031
|
+
queued_details: undefined,
|
|
2027
2032
|
queued_at: undefined,
|
|
2028
2033
|
admission_reserved_at: undefined,
|
|
2029
2034
|
terminal_reason: undefined,
|
|
@@ -22,6 +22,8 @@ export interface StoreDeps {
|
|
|
22
22
|
unlinkSync: typeof fs.unlinkSync;
|
|
23
23
|
existsSync: typeof fs.existsSync;
|
|
24
24
|
readdirSync: typeof fs.readdirSync;
|
|
25
|
+
/** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
|
|
26
|
+
conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
|
|
25
27
|
openSync: typeof fs.openSync;
|
|
26
28
|
closeSync: typeof fs.closeSync;
|
|
27
29
|
fsyncSync: typeof fs.fsyncSync;
|
|
@@ -50,6 +52,8 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
|
|
|
50
52
|
export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
|
|
51
53
|
export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
|
|
52
54
|
export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
|
|
55
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
56
|
+
export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
|
|
53
57
|
export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
|
|
54
58
|
export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
|
|
55
59
|
/** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -528,6 +528,33 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
528
528
|
value: raw.queued_reason,
|
|
529
529
|
});
|
|
530
530
|
}
|
|
531
|
+
if (raw.queued_details !== undefined) {
|
|
532
|
+
const details = raw.queued_details;
|
|
533
|
+
if (details === null || typeof details !== "object" || Array.isArray(details) ||
|
|
534
|
+
typeof details.reason !== "string") {
|
|
535
|
+
throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
|
|
536
|
+
path: filePath,
|
|
537
|
+
field: "queued_details",
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const detailsRecord = details;
|
|
541
|
+
if (detailsRecord.reason !== "worker_profile_changed" ||
|
|
542
|
+
(detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
|
|
543
|
+
typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
|
|
544
|
+
typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
|
|
545
|
+
throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
|
|
546
|
+
path: filePath,
|
|
547
|
+
field: "queued_details",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (raw.queued_reason !== "worker_profile_changed") {
|
|
551
|
+
throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
|
|
552
|
+
path: filePath,
|
|
553
|
+
field: "queued_details",
|
|
554
|
+
queued_reason: raw.queued_reason,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
531
558
|
if (raw.queued_at !== undefined)
|
|
532
559
|
requireTimestamp(raw.queued_at, "queued_at", filePath);
|
|
533
560
|
if (raw.admission_reserved_at !== undefined) {
|
|
@@ -927,6 +954,9 @@ export function isConflictSidecarName(recordBasename, name) {
|
|
|
927
954
|
return name.startsWith(`${recordBasename}.conflict-`);
|
|
928
955
|
}
|
|
929
956
|
function sidecarPathsFor(recordPath, deps) {
|
|
957
|
+
if (deps.conflictSidecarPathsFor) {
|
|
958
|
+
return [...deps.conflictSidecarPathsFor(recordPath)];
|
|
959
|
+
}
|
|
930
960
|
const dir = path.dirname(recordPath);
|
|
931
961
|
const base = path.basename(recordPath);
|
|
932
962
|
if (!deps.existsSync(dir))
|
|
@@ -1130,6 +1160,42 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
|
|
|
1130
1160
|
throw new LaneNotFoundError("lane", laneId);
|
|
1131
1161
|
return record;
|
|
1132
1162
|
}
|
|
1163
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
1164
|
+
export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
|
|
1165
|
+
const laneIds = listLaneIds(hqRoot, deps);
|
|
1166
|
+
if (laneIds.length === 0)
|
|
1167
|
+
return [];
|
|
1168
|
+
const laneDir = path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR);
|
|
1169
|
+
let names;
|
|
1170
|
+
try {
|
|
1171
|
+
names = deps.readdirSync(laneDir);
|
|
1172
|
+
}
|
|
1173
|
+
catch (err) {
|
|
1174
|
+
if (err.code === "ENOENT")
|
|
1175
|
+
names = [];
|
|
1176
|
+
else
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
1179
|
+
const sidecarsByRecord = new Map();
|
|
1180
|
+
for (const name of names) {
|
|
1181
|
+
if (typeof name !== "string")
|
|
1182
|
+
continue;
|
|
1183
|
+
const marker = name.indexOf(".json.conflict-");
|
|
1184
|
+
if (marker < 0 || name.startsWith("."))
|
|
1185
|
+
continue;
|
|
1186
|
+
const recordBasename = name.slice(0, marker + ".json".length);
|
|
1187
|
+
if (!isConflictSidecarName(recordBasename, name))
|
|
1188
|
+
continue;
|
|
1189
|
+
const sidecars = sidecarsByRecord.get(recordBasename) ?? [];
|
|
1190
|
+
sidecars.push(path.join(laneDir, name));
|
|
1191
|
+
sidecarsByRecord.set(recordBasename, sidecars);
|
|
1192
|
+
}
|
|
1193
|
+
const indexedDeps = {
|
|
1194
|
+
...deps,
|
|
1195
|
+
conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
|
|
1196
|
+
};
|
|
1197
|
+
return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
|
|
1198
|
+
}
|
|
1133
1199
|
export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
|
|
1134
1200
|
const filePath = questionRecordPath(hqRoot, questionId);
|
|
1135
1201
|
return readMergedRecord(filePath, parseQuestionRecord, mergeQuestionRecords, deps);
|