@indigoai-us/hq-cli 5.126.0 → 5.127.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 +211 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic reconcile pass (spec §5.7, §6.8, §9).
|
|
3
|
+
*
|
|
4
|
+
* Owned by the machine, not by any lane. Reads never mutate; every write is a
|
|
5
|
+
* compare-and-set. The per-lane watcher owns progress/terminal/silence of a
|
|
6
|
+
* live process — this pass owns the transitions no single lane can own.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
11
|
+
import { accountMaxRunning, loadLanesConfig } from "./config.js";
|
|
12
|
+
import { LanesError, isLaneVersionConflict } from "./errors.js";
|
|
13
|
+
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
|
|
14
|
+
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, spawnEnvelopeRepair, spawnLaneWorker, } from "./spawn.js";
|
|
15
|
+
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
16
|
+
import { applyThrottleDecision, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
17
|
+
import { readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
18
|
+
import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
|
|
19
|
+
import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
|
|
20
|
+
import { createLaneMesh, emitLaneDecisionEvents } from "./mesh-events.js";
|
|
21
|
+
import { decideEnvelopeRepair, envelopeQuestionIds } from "./watcher.js";
|
|
22
|
+
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
23
|
+
export const QUESTION_AGE_MS = 60 * 60 * 1000;
|
|
24
|
+
/** Spec §6.6 + §9: v1 permits exactly one aged advance. */
|
|
25
|
+
export const MAX_AGED_ADVANCES = 1;
|
|
26
|
+
/** Aligns with watcher silence: a host that has not reported for this long is dead. */
|
|
27
|
+
export const DEAD_HOST_MS = SILENCE_MS;
|
|
28
|
+
export const RECONCILE_LOCK_STALE_MS = 30_000;
|
|
29
|
+
export const RECONCILE_LOCK_TIMEOUT_MS = 5_000;
|
|
30
|
+
export const RECONCILE_UNIT_NAME = "hq-lanes-reconcile.service";
|
|
31
|
+
export const RECONCILE_TIMER_NAME = "hq-lanes-reconcile.timer";
|
|
32
|
+
function logSkip(skip) {
|
|
33
|
+
process.stderr.write(`hq lanes reconcile: skipped ${skip.kind}${skip.id ? ` ${skip.id}` : ""}: ${skip.why}\n`);
|
|
34
|
+
}
|
|
35
|
+
function emptySummary(dryRun) {
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
action: "reconcile",
|
|
39
|
+
dry_run: dryRun,
|
|
40
|
+
admitted: [],
|
|
41
|
+
overdue: [],
|
|
42
|
+
aged: [],
|
|
43
|
+
advanced: [],
|
|
44
|
+
resumed: [],
|
|
45
|
+
dead_host: [],
|
|
46
|
+
heartbeats: [],
|
|
47
|
+
skipped: [],
|
|
48
|
+
errors: [],
|
|
49
|
+
heartbeat_at: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function markDirty(summary, skip) {
|
|
53
|
+
summary.skipped.push(skip);
|
|
54
|
+
summary.ok = false;
|
|
55
|
+
logSkip(skip);
|
|
56
|
+
}
|
|
57
|
+
function markError(summary, err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
const code = err instanceof LanesError ? err.error : undefined;
|
|
60
|
+
summary.errors.push(code ? { message, code } : { message });
|
|
61
|
+
summary.ok = false;
|
|
62
|
+
process.stderr.write(`hq lanes reconcile: ${message}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
|
|
63
|
+
}
|
|
64
|
+
export function reconcileHeartbeatPath(hqRoot) {
|
|
65
|
+
return path.join(hqRoot, "workspace", "lanes-runs", "reconcile-heartbeat.json");
|
|
66
|
+
}
|
|
67
|
+
export function timeoutPath(hqRoot, laneId) {
|
|
68
|
+
return path.join(laneRunDir(hqRoot, laneId), "timeout.json");
|
|
69
|
+
}
|
|
70
|
+
export function laneRunsHere(lane, host) {
|
|
71
|
+
if (host.outpostId && lane.host.kind === "outpost") {
|
|
72
|
+
return lane.host.id === host.outpostId;
|
|
73
|
+
}
|
|
74
|
+
return lane.host.kind === "local" && lane.host.id === host.hostname;
|
|
75
|
+
}
|
|
76
|
+
export function agedAdvanceCount(question) {
|
|
77
|
+
return question.routing_history.filter((entry) => entry.reason === "unanswered_timeout").length;
|
|
78
|
+
}
|
|
79
|
+
export function currentHopStartedAtMs(question) {
|
|
80
|
+
const atCurrent = [...question.routing_history]
|
|
81
|
+
.reverse()
|
|
82
|
+
.find((entry) => entry.hop === question.hop);
|
|
83
|
+
const raw = atCurrent?.at ?? question.asked_at;
|
|
84
|
+
const ms = Date.parse(raw);
|
|
85
|
+
return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
|
|
86
|
+
}
|
|
87
|
+
export function shouldAgeQuestion(question, audience, nowMs) {
|
|
88
|
+
if (question.status !== "pending")
|
|
89
|
+
return false;
|
|
90
|
+
if (audience.kind === "user")
|
|
91
|
+
return false;
|
|
92
|
+
if (agedAdvanceCount(question) >= MAX_AGED_ADVANCES)
|
|
93
|
+
return false;
|
|
94
|
+
const started = currentHopStartedAtMs(question);
|
|
95
|
+
if (started === Number.NEGATIVE_INFINITY)
|
|
96
|
+
return false;
|
|
97
|
+
return nowMs - started >= QUESTION_AGE_MS;
|
|
98
|
+
}
|
|
99
|
+
export function shouldAdvanceAudienceEnded(audience, liveness) {
|
|
100
|
+
if (audience.kind === "user")
|
|
101
|
+
return false;
|
|
102
|
+
if (audience.kind !== "session")
|
|
103
|
+
return false;
|
|
104
|
+
return liveness === "ended" || liveness === "lost" || liveness === null;
|
|
105
|
+
}
|
|
106
|
+
export function readTimeoutDeadline(hqRoot, laneId) {
|
|
107
|
+
const filePath = timeoutPath(hqRoot, laneId);
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const code = err.code;
|
|
114
|
+
if (code === "ENOENT")
|
|
115
|
+
return null;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(text);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
throw new LanesError("timeout_unreadable", `Timeout file for ${laneId} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { lane_id: laneId, path: filePath });
|
|
124
|
+
}
|
|
125
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
126
|
+
throw new LanesError("timeout_unreadable", `Timeout file for ${laneId} is not an object`, { lane_id: laneId, path: filePath });
|
|
127
|
+
}
|
|
128
|
+
const deadline = parsed.deadline_at;
|
|
129
|
+
if (typeof deadline !== "string" || !Number.isFinite(Date.parse(deadline))) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const timeoutSecs = parsed.timeout_secs;
|
|
133
|
+
return {
|
|
134
|
+
deadline_at: deadline,
|
|
135
|
+
timeout_secs: typeof timeoutSecs === "number" && Number.isInteger(timeoutSecs)
|
|
136
|
+
? timeoutSecs
|
|
137
|
+
: undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function isDeadlinePassed(deadlineAt, nowMs) {
|
|
141
|
+
const at = Date.parse(deadlineAt);
|
|
142
|
+
if (!Number.isFinite(at))
|
|
143
|
+
return false;
|
|
144
|
+
return nowMs > at;
|
|
145
|
+
}
|
|
146
|
+
function compareQueueOrder(a, b) {
|
|
147
|
+
if (b.priority !== a.priority)
|
|
148
|
+
return b.priority - a.priority;
|
|
149
|
+
return a.created_at.localeCompare(b.created_at);
|
|
150
|
+
}
|
|
151
|
+
function readDirJsonIds(dir, label) {
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
names = fs.readdirSync(dir);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
const code = err.code;
|
|
158
|
+
if (code === "ENOENT")
|
|
159
|
+
return [];
|
|
160
|
+
throw new LanesError("store_unreadable", `Failed to read ${label} directory ${dir}: ${err instanceof Error ? err.message : String(err)}`, { path: dir });
|
|
161
|
+
}
|
|
162
|
+
return names.filter((name) => typeof name === "string" &&
|
|
163
|
+
name.endsWith(".json") &&
|
|
164
|
+
!name.startsWith(".") &&
|
|
165
|
+
!name.includes(".conflict-"));
|
|
166
|
+
}
|
|
167
|
+
function loadLaneRecords(hqRoot, summary) {
|
|
168
|
+
const dir = path.join(lanesStoreRoot(hqRoot), "lanes");
|
|
169
|
+
const names = readDirJsonIds(dir, "lane");
|
|
170
|
+
const lanes = [];
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
const filePath = path.join(dir, name);
|
|
173
|
+
const id = name.slice(0, -".json".length);
|
|
174
|
+
try {
|
|
175
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
176
|
+
const parsed = JSON.parse(text);
|
|
177
|
+
lanes.push(parseLaneRecord(parsed, filePath));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
markDirty(summary, {
|
|
181
|
+
kind: "lane",
|
|
182
|
+
id,
|
|
183
|
+
why: `unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return lanes;
|
|
188
|
+
}
|
|
189
|
+
function loadQuestionRecords(hqRoot, summary) {
|
|
190
|
+
const dir = path.join(lanesStoreRoot(hqRoot), "questions");
|
|
191
|
+
const names = readDirJsonIds(dir, "question");
|
|
192
|
+
const questions = [];
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
const filePath = path.join(dir, name);
|
|
195
|
+
const id = name.slice(0, -".json".length);
|
|
196
|
+
try {
|
|
197
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
198
|
+
const parsed = JSON.parse(text);
|
|
199
|
+
questions.push(parseQuestionRecord(parsed, filePath));
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
markDirty(summary, {
|
|
203
|
+
kind: "question",
|
|
204
|
+
id,
|
|
205
|
+
why: `unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return questions;
|
|
210
|
+
}
|
|
211
|
+
function defaultReadSessionSenior(hqRoot, sessionId) {
|
|
212
|
+
const filePath = path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
|
|
213
|
+
let text;
|
|
214
|
+
try {
|
|
215
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const code = err.code;
|
|
219
|
+
if (code === "ENOENT")
|
|
220
|
+
return { kind: "user" };
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
const doc = parseUserYaml(text, filePath);
|
|
224
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
225
|
+
return { kind: "user" };
|
|
226
|
+
}
|
|
227
|
+
const senior = doc.senior;
|
|
228
|
+
if (senior === "user" || senior === undefined || senior === null) {
|
|
229
|
+
return { kind: "user" };
|
|
230
|
+
}
|
|
231
|
+
if (typeof senior === "string") {
|
|
232
|
+
const colon = senior.indexOf(":");
|
|
233
|
+
if (colon <= 0)
|
|
234
|
+
return { kind: "user" };
|
|
235
|
+
const kind = senior.slice(0, colon);
|
|
236
|
+
const id = senior.slice(colon + 1);
|
|
237
|
+
if (kind === "session" || kind === "lane")
|
|
238
|
+
return { kind, id };
|
|
239
|
+
if (kind === "user")
|
|
240
|
+
return { kind: "user" };
|
|
241
|
+
}
|
|
242
|
+
if (typeof senior === "object" && !Array.isArray(senior)) {
|
|
243
|
+
const rec = senior;
|
|
244
|
+
if (rec.kind === "user")
|
|
245
|
+
return { kind: "user" };
|
|
246
|
+
if ((rec.kind === "session" || rec.kind === "lane") &&
|
|
247
|
+
typeof rec.id === "string" &&
|
|
248
|
+
rec.id.length > 0) {
|
|
249
|
+
return { kind: rec.kind, id: rec.id };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { kind: "user" };
|
|
253
|
+
}
|
|
254
|
+
export function resolveQuestionAudience(question, readLaneSenior, readSessionSenior) {
|
|
255
|
+
let current = { kind: "lane", id: question.origin.id };
|
|
256
|
+
for (let hop = 0; hop < question.hop; hop += 1) {
|
|
257
|
+
if (current.kind === "user")
|
|
258
|
+
return current;
|
|
259
|
+
if (current.kind === "lane") {
|
|
260
|
+
if (!current.id)
|
|
261
|
+
return current;
|
|
262
|
+
current = readLaneSenior(current.id);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!current.id)
|
|
266
|
+
return current;
|
|
267
|
+
current = readSessionSenior(current.id);
|
|
268
|
+
}
|
|
269
|
+
return current;
|
|
270
|
+
}
|
|
271
|
+
function casLaneIf(hqRoot, laneId, shouldWrite, updater, dryRun) {
|
|
272
|
+
for (let i = 0; i < 5; i += 1) {
|
|
273
|
+
const current = readLane(hqRoot, laneId);
|
|
274
|
+
if (!shouldWrite(current))
|
|
275
|
+
return { changed: false, record: current };
|
|
276
|
+
if (dryRun)
|
|
277
|
+
return { changed: true, record: updater(current) };
|
|
278
|
+
try {
|
|
279
|
+
return {
|
|
280
|
+
changed: true,
|
|
281
|
+
record: updateLane(hqRoot, laneId, current.version, "reconcile", updater),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
if (!isLaneVersionConflict(err) || i === 4)
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw new LanesError("version_conflict", `Exhausted CAS retries for lane ${laneId}`, { lane_id: laneId });
|
|
290
|
+
}
|
|
291
|
+
function casQuestionIf(hqRoot, questionId, shouldWrite, updater, dryRun) {
|
|
292
|
+
for (let i = 0; i < 5; i += 1) {
|
|
293
|
+
const current = readQuestion(hqRoot, questionId);
|
|
294
|
+
if (!shouldWrite(current))
|
|
295
|
+
return { changed: false, record: current };
|
|
296
|
+
if (dryRun)
|
|
297
|
+
return { changed: true, record: updater(current) };
|
|
298
|
+
try {
|
|
299
|
+
return {
|
|
300
|
+
changed: true,
|
|
301
|
+
record: updateQuestion(hqRoot, questionId, current.version, updater),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (!isLaneVersionConflict(err) || i === 4)
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
throw new LanesError("version_conflict", `Exhausted CAS retries for question ${questionId}`, { question_id: questionId });
|
|
310
|
+
}
|
|
311
|
+
function fileNonEmpty(filePath) {
|
|
312
|
+
try {
|
|
313
|
+
return fs.statSync(filePath).size > 0;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const code = err.code;
|
|
317
|
+
if (code === "ENOENT")
|
|
318
|
+
return false;
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function lastHostReportMs(lane) {
|
|
323
|
+
const raw = lane.last_host_report_at ?? lane.updated_at;
|
|
324
|
+
const ms = Date.parse(raw);
|
|
325
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
326
|
+
}
|
|
327
|
+
function writeHeartbeatFile(hqRoot, summary, at) {
|
|
328
|
+
const filePath = reconcileHeartbeatPath(hqRoot);
|
|
329
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
330
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
331
|
+
const body = `${JSON.stringify({ at, ok: summary.ok, action: "reconcile" }, null, 2)}\n`;
|
|
332
|
+
try {
|
|
333
|
+
fs.writeFileSync(tmp, body, { encoding: "utf8" });
|
|
334
|
+
fs.renameSync(tmp, filePath);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
try {
|
|
338
|
+
fs.unlinkSync(tmp);
|
|
339
|
+
}
|
|
340
|
+
catch (unlinkErr) {
|
|
341
|
+
const code = unlinkErr.code;
|
|
342
|
+
if (code !== "ENOENT") {
|
|
343
|
+
process.stderr.write(`hq lanes reconcile: unlink heartbeat tmp failed: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}\n`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function writeAnsweredQuestions(hqRoot, lane, questions) {
|
|
350
|
+
const filePath = path.join(laneRunDir(hqRoot, lane.lane_id), ANSWERED_QUESTIONS_FILE);
|
|
351
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
352
|
+
fs.writeFileSync(filePath, `${JSON.stringify(answeredQuestionEntries(hqRoot, lane, questions), null, 2)}\n`, "utf8");
|
|
353
|
+
}
|
|
354
|
+
async function withReconcileLock(hqRoot, fn) {
|
|
355
|
+
const lockPath = path.join(hqRoot, "workspace", "lanes-runs", ".admission-locks", "reconcile.lock");
|
|
356
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
357
|
+
const started = Date.now();
|
|
358
|
+
while (true) {
|
|
359
|
+
let fd;
|
|
360
|
+
try {
|
|
361
|
+
fd = fs.openSync(lockPath, "wx");
|
|
362
|
+
try {
|
|
363
|
+
fs.writeSync(fd, Buffer.from(`${process.pid}\n${Date.now()}\n`, "utf8"));
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
fs.closeSync(fd);
|
|
367
|
+
fd = undefined;
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
return await fn();
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
try {
|
|
375
|
+
fs.closeSync(fd);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
process.stderr.write(`hq lanes reconcile: lock close failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
fs.unlinkSync(lockPath);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
const code = err.code;
|
|
385
|
+
if (code !== "ENOENT") {
|
|
386
|
+
process.stderr.write(`hq lanes reconcile: lock unlink failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const code = err.code;
|
|
393
|
+
if (code !== "EEXIST")
|
|
394
|
+
throw err;
|
|
395
|
+
if (Date.now() - started > RECONCILE_LOCK_TIMEOUT_MS) {
|
|
396
|
+
throw new LanesError("store_lock_timeout", `Timed out waiting for reconcile lock ${lockPath}`, { path: lockPath });
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
400
|
+
const tsLine = raw.split("\n")[1];
|
|
401
|
+
const ts = tsLine ? Number(tsLine) : NaN;
|
|
402
|
+
if (Number.isFinite(ts) && Date.now() - ts > RECONCILE_LOCK_STALE_MS) {
|
|
403
|
+
try {
|
|
404
|
+
fs.unlinkSync(lockPath);
|
|
405
|
+
}
|
|
406
|
+
catch (unlinkErr) {
|
|
407
|
+
const unlinkCode = unlinkErr.code;
|
|
408
|
+
if (unlinkCode !== "ENOENT")
|
|
409
|
+
throw unlinkErr;
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (statErr) {
|
|
415
|
+
const statCode = statErr.code;
|
|
416
|
+
if (statCode !== "ENOENT") {
|
|
417
|
+
process.stderr.write(`hq lanes reconcile: stale-lock probe failed: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function classifyDeadLocalLane(hqRoot, laneId) {
|
|
425
|
+
const latest = readLatestEnvelopeRecord(hqRoot, laneId);
|
|
426
|
+
const envelope = latest?.envelope ?? readLatestEnvelope(hqRoot, laneId);
|
|
427
|
+
const runDir = laneRunDir(hqRoot, laneId);
|
|
428
|
+
const hasPartialOutput = [
|
|
429
|
+
path.join(runDir, "logs", "worker.out"),
|
|
430
|
+
path.join(runDir, "logs", "worker.err"),
|
|
431
|
+
path.join(runDir, "logs", "last-message.txt"),
|
|
432
|
+
].some((file) => fileNonEmpty(file));
|
|
433
|
+
const lane = readLane(hqRoot, laneId);
|
|
434
|
+
const quota = envelope
|
|
435
|
+
? null
|
|
436
|
+
: readQuotaSignal(runDir, lane.model, new Date(), readSpawnManifest(runDir).worker_out_offset ?? 0);
|
|
437
|
+
return {
|
|
438
|
+
classification: classifyLaneExit({ envelope, hasPartialOutput, quota }),
|
|
439
|
+
latest,
|
|
440
|
+
quota,
|
|
441
|
+
lane,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Unlocked pass body. `runReconcilePass` takes the process lock around this. */
|
|
445
|
+
export async function runPassBody(hqRoot, deps) {
|
|
446
|
+
const now = deps.now();
|
|
447
|
+
const nowMs = now.getTime();
|
|
448
|
+
const nowIso = now.toISOString();
|
|
449
|
+
const summary = emptySummary(deps.dryRun);
|
|
450
|
+
const livenessDeps = deps.livenessDeps ?? defaultLivenessDeps();
|
|
451
|
+
let lanes;
|
|
452
|
+
let questions;
|
|
453
|
+
try {
|
|
454
|
+
lanes = loadLaneRecords(hqRoot, summary);
|
|
455
|
+
questions = loadQuestionRecords(hqRoot, summary);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
markError(summary, err);
|
|
459
|
+
return summary;
|
|
460
|
+
}
|
|
461
|
+
let presenceConnected;
|
|
462
|
+
try {
|
|
463
|
+
presenceConnected =
|
|
464
|
+
deps.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
markError(summary, err);
|
|
468
|
+
return summary;
|
|
469
|
+
}
|
|
470
|
+
let livenessIndex;
|
|
471
|
+
try {
|
|
472
|
+
livenessIndex = loadLivenessIndex(hqRoot, undefined, livenessDeps, !deps.dryRun);
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
markError(summary, err);
|
|
476
|
+
return summary;
|
|
477
|
+
}
|
|
478
|
+
const config = loadLanesConfig(hqRoot);
|
|
479
|
+
const laneById = new Map(lanes.map((lane) => [lane.lane_id, lane]));
|
|
480
|
+
const readLaneSenior = (laneId) => {
|
|
481
|
+
const lane = laneById.get(laneId);
|
|
482
|
+
if (lane)
|
|
483
|
+
return lane.senior;
|
|
484
|
+
try {
|
|
485
|
+
return readLane(hqRoot, laneId).senior;
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
throw new LanesError("lane_not_found", `Cannot resolve senior for missing lane ${laneId}: ${err instanceof Error ? err.message : String(err)}`, { lane_id: laneId });
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const readSessionSenior = deps.readSessionSenior ??
|
|
492
|
+
((sessionId) => defaultReadSessionSenior(hqRoot, sessionId));
|
|
493
|
+
const sessionState = (sessionId) => {
|
|
494
|
+
const entry = livenessIndex.sessions[sessionId];
|
|
495
|
+
if (!entry)
|
|
496
|
+
return null;
|
|
497
|
+
return classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
|
|
498
|
+
};
|
|
499
|
+
if (deps.afterLoad)
|
|
500
|
+
await deps.afterLoad();
|
|
501
|
+
markOverdueLanes(hqRoot, lanes, summary, deps, now);
|
|
502
|
+
await heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now);
|
|
503
|
+
for (const question of questions) {
|
|
504
|
+
if (question.status !== "pending")
|
|
505
|
+
continue;
|
|
506
|
+
let audience;
|
|
507
|
+
try {
|
|
508
|
+
audience = resolveQuestionAudience(question, readLaneSenior, readSessionSenior);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
markDirty(summary, {
|
|
512
|
+
kind: "question",
|
|
513
|
+
id: question.question_id,
|
|
514
|
+
why: `audience unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
515
|
+
});
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const liveness = audience.kind === "session" && audience.id
|
|
519
|
+
? sessionState(audience.id)
|
|
520
|
+
: audience.kind === "user"
|
|
521
|
+
? "live"
|
|
522
|
+
: null;
|
|
523
|
+
try {
|
|
524
|
+
if (shouldAdvanceAudienceEnded(audience, liveness)) {
|
|
525
|
+
const nextHop = question.hop + 1;
|
|
526
|
+
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
527
|
+
const result = casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending" && current.hop === question.hop, (current) => {
|
|
528
|
+
const entry = {
|
|
529
|
+
hop: nextHop,
|
|
530
|
+
audience: nextAudience,
|
|
531
|
+
at: nowIso,
|
|
532
|
+
reason: "audience_ended",
|
|
533
|
+
};
|
|
534
|
+
return {
|
|
535
|
+
...current,
|
|
536
|
+
hop: nextHop,
|
|
537
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
538
|
+
};
|
|
539
|
+
}, deps.dryRun);
|
|
540
|
+
if (result.changed) {
|
|
541
|
+
summary.advanced.push({
|
|
542
|
+
id: question.question_id,
|
|
543
|
+
from: String(question.hop),
|
|
544
|
+
to: String(nextHop),
|
|
545
|
+
reason: "audience_ended",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (shouldAgeQuestion(question, audience, nowMs)) {
|
|
551
|
+
const nextHop = question.hop + 1;
|
|
552
|
+
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
553
|
+
const result = casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending" &&
|
|
554
|
+
agedAdvanceCount(current) < MAX_AGED_ADVANCES &&
|
|
555
|
+
shouldAgeQuestion(current, resolveQuestionAudience(current, readLaneSenior, readSessionSenior), nowMs), (current) => {
|
|
556
|
+
const entry = {
|
|
557
|
+
hop: current.hop + 1,
|
|
558
|
+
audience: nextAudience,
|
|
559
|
+
at: nowIso,
|
|
560
|
+
reason: "unanswered_timeout",
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
...current,
|
|
564
|
+
hop: current.hop + 1,
|
|
565
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
566
|
+
};
|
|
567
|
+
}, deps.dryRun);
|
|
568
|
+
if (result.changed) {
|
|
569
|
+
summary.aged.push({
|
|
570
|
+
id: question.question_id,
|
|
571
|
+
from: String(question.hop),
|
|
572
|
+
to: String(nextHop),
|
|
573
|
+
reason: "unanswered_timeout",
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
markDirty(summary, {
|
|
580
|
+
kind: "question",
|
|
581
|
+
id: question.question_id,
|
|
582
|
+
why: `advance failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const freshLanes = loadLaneRecords(hqRoot, summary);
|
|
587
|
+
const freshQuestions = loadQuestionRecords(hqRoot, summary);
|
|
588
|
+
await admitQueued(hqRoot, freshLanes, summary, deps, now);
|
|
589
|
+
await resumeAwaiting(hqRoot, freshLanes, freshQuestions, summary, deps);
|
|
590
|
+
if (!deps.dryRun && summary.errors.length === 0) {
|
|
591
|
+
try {
|
|
592
|
+
writeHeartbeatFile(hqRoot, summary, nowIso);
|
|
593
|
+
summary.heartbeat_at = nowIso;
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
markError(summary, err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return summary;
|
|
600
|
+
}
|
|
601
|
+
function markOverdueLanes(hqRoot, lanes, summary, deps, now) {
|
|
602
|
+
const nowMs = now.getTime();
|
|
603
|
+
const nowIso = now.toISOString();
|
|
604
|
+
for (const lane of lanes) {
|
|
605
|
+
if (lane.overdue === true)
|
|
606
|
+
continue;
|
|
607
|
+
if (!laneRunsHere(lane, deps.host))
|
|
608
|
+
continue;
|
|
609
|
+
let deadline;
|
|
610
|
+
try {
|
|
611
|
+
deadline = readTimeoutDeadline(hqRoot, lane.lane_id);
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
markDirty(summary, {
|
|
615
|
+
kind: "lane",
|
|
616
|
+
id: lane.lane_id,
|
|
617
|
+
why: `timeout unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (!deadline || !isDeadlinePassed(deadline.deadline_at, nowMs))
|
|
622
|
+
continue;
|
|
623
|
+
try {
|
|
624
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.overdue !== true, (current) => ({
|
|
625
|
+
...current,
|
|
626
|
+
overdue: true,
|
|
627
|
+
overdue_at: nowIso,
|
|
628
|
+
}), deps.dryRun);
|
|
629
|
+
if (result.changed) {
|
|
630
|
+
summary.overdue.push({
|
|
631
|
+
id: lane.lane_id,
|
|
632
|
+
from: "ok",
|
|
633
|
+
to: "overdue",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
markDirty(summary, {
|
|
639
|
+
kind: "lane",
|
|
640
|
+
id: lane.lane_id,
|
|
641
|
+
why: `overdue CAS failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
647
|
+
const nowMs = now.getTime();
|
|
648
|
+
const nowIso = now.toISOString();
|
|
649
|
+
const isAlive = deps.isAlive ?? isPidAlive;
|
|
650
|
+
for (const lane of lanes) {
|
|
651
|
+
if (lane.state !== "running")
|
|
652
|
+
continue;
|
|
653
|
+
const local = laneRunsHere(lane, deps.host);
|
|
654
|
+
if (local) {
|
|
655
|
+
const runDir = laneRunDir(hqRoot, lane.lane_id);
|
|
656
|
+
let workerPid;
|
|
657
|
+
let watcherPid;
|
|
658
|
+
let workerAlive;
|
|
659
|
+
let watcherAlive;
|
|
660
|
+
try {
|
|
661
|
+
const workerRec = readPidRecord(path.join(runDir, "worker.pid"));
|
|
662
|
+
const watcherRec = readPidRecord(path.join(runDir, "watcher.pid"));
|
|
663
|
+
workerPid = workerRec?.pid ?? null;
|
|
664
|
+
watcherPid = watcherRec?.pid ?? null;
|
|
665
|
+
workerAlive = workerRec !== null && isPidRecordAlive(workerRec, isAlive);
|
|
666
|
+
watcherAlive = watcherRec !== null && isPidRecordAlive(watcherRec, isAlive);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
markDirty(summary, {
|
|
670
|
+
kind: "lane",
|
|
671
|
+
id: lane.lane_id,
|
|
672
|
+
why: `pid file unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
673
|
+
});
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (workerAlive) {
|
|
677
|
+
try {
|
|
678
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.last_host_report_at !== nowIso, (current) => ({ ...current, last_host_report_at: nowIso }), deps.dryRun);
|
|
679
|
+
if (result.changed)
|
|
680
|
+
summary.heartbeats.push(lane.lane_id);
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
markDirty(summary, {
|
|
684
|
+
kind: "lane",
|
|
685
|
+
id: lane.lane_id,
|
|
686
|
+
why: `heartbeat CAS failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (!watcherAlive && !deps.dryRun) {
|
|
690
|
+
try {
|
|
691
|
+
armLaneWatcher(hqRoot, lane.lane_id, deps.spawnLaneDeps ?? {});
|
|
692
|
+
summary.heartbeats.push(`${lane.lane_id}:watcher-rearmed`);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
markDirty(summary, {
|
|
696
|
+
kind: "lane",
|
|
697
|
+
id: lane.lane_id,
|
|
698
|
+
why: `watcher re-arm failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (watcherAlive) {
|
|
705
|
+
// Watcher owns terminal classification of a process that has just exited.
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const hadPids = workerPid !== null || watcherPid !== null;
|
|
709
|
+
if (!hadPids && nowMs - lastHostReportMs(lane) < DEAD_HOST_MS) {
|
|
710
|
+
try {
|
|
711
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.last_host_report_at !== nowIso, (current) => ({ ...current, last_host_report_at: nowIso }), deps.dryRun);
|
|
712
|
+
if (result.changed)
|
|
713
|
+
summary.heartbeats.push(lane.lane_id);
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
markDirty(summary, {
|
|
717
|
+
kind: "lane",
|
|
718
|
+
id: lane.lane_id,
|
|
719
|
+
why: `heartbeat CAS failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const { classification, latest, quota, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
|
|
726
|
+
// Match the watcher's one-repair decision before classifying an
|
|
727
|
+
// unparseable envelope after both processes have died.
|
|
728
|
+
const rawLatest = readLatestEnvelopeFile(hqRoot, lane.lane_id);
|
|
729
|
+
let invalidEnvelope = null;
|
|
730
|
+
if (!deps.dryRun && rawLatest && rawLatest.envelope === null) {
|
|
731
|
+
const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, rawLatest, nowMs, deps.spawnRepair ?? spawnEnvelopeRepair, () => undefined);
|
|
732
|
+
if (repair.action === "spawned") {
|
|
733
|
+
armLaneWatcher(hqRoot, lane.lane_id, deps.spawnLaneDeps);
|
|
734
|
+
summary.heartbeats.push(`${lane.lane_id}:envelope-repair`);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
invalidEnvelope = { errors: repair.errors };
|
|
738
|
+
}
|
|
739
|
+
const repairedClassification = invalidEnvelope
|
|
740
|
+
? classifyLaneExit({ envelope: null, hasPartialOutput: false, invalidEnvelope })
|
|
741
|
+
: classification;
|
|
742
|
+
if (!deps.dryRun &&
|
|
743
|
+
quota &&
|
|
744
|
+
classified.account &&
|
|
745
|
+
(classification.reason === "account_quota_exhausted" ||
|
|
746
|
+
classification.reason === "account_model_unavailable")) {
|
|
747
|
+
recordAccountQuota(hqRoot, classified.provider, classified.account, quota);
|
|
748
|
+
}
|
|
749
|
+
if (!deps.dryRun &&
|
|
750
|
+
classification.state === "awaiting_input" &&
|
|
751
|
+
latest &&
|
|
752
|
+
(latest.envelope.questions?.length ?? 0) > 0) {
|
|
753
|
+
raiseEnvelopeQuestions(hqRoot, readLane(hqRoot, lane.lane_id), latest.envelope, latest.ref, nowIso);
|
|
754
|
+
}
|
|
755
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => ({
|
|
756
|
+
...current,
|
|
757
|
+
state: repairedClassification.state,
|
|
758
|
+
queued_reason: undefined,
|
|
759
|
+
terminal_reason: repairedClassification.reason ?? "host_lost",
|
|
760
|
+
...(quota?.quota_reset_at ? { quota_reset_at: quota.quota_reset_at } : {}),
|
|
761
|
+
...(quota?.marker ? { model_quota_marker: quota.marker } : {}),
|
|
762
|
+
last_host_report_at: nowIso,
|
|
763
|
+
}), deps.dryRun);
|
|
764
|
+
if (result.changed) {
|
|
765
|
+
summary.dead_host.push({
|
|
766
|
+
id: lane.lane_id,
|
|
767
|
+
from: "running",
|
|
768
|
+
to: repairedClassification.state,
|
|
769
|
+
reason: repairedClassification.reason ?? "host_lost",
|
|
770
|
+
});
|
|
771
|
+
if (latest?.envelope) {
|
|
772
|
+
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
773
|
+
if (mesh) {
|
|
774
|
+
const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
775
|
+
let timer;
|
|
776
|
+
try {
|
|
777
|
+
const questionIds = latest.envelope.decision === "ask"
|
|
778
|
+
? envelopeQuestionIds(lane.lane_id, latest.ref, latest.envelope)
|
|
779
|
+
: undefined;
|
|
780
|
+
await Promise.race([
|
|
781
|
+
emitLaneDecisionEvents(mesh, result.record, latest.envelope, { questionIds }),
|
|
782
|
+
new Promise((_resolve, reject) => {
|
|
783
|
+
timer = setTimeout(() => reject(new Error(`terminal mesh emit timed out after ${meshEmitTimeoutMs}ms`)), meshEmitTimeoutMs);
|
|
784
|
+
}),
|
|
785
|
+
]);
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
markDirty(summary, {
|
|
789
|
+
kind: "lane",
|
|
790
|
+
id: lane.lane_id,
|
|
791
|
+
why: `terminal mesh emit failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
if (timer)
|
|
796
|
+
clearTimeout(timer);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
markDirty(summary, {
|
|
804
|
+
kind: "lane",
|
|
805
|
+
id: lane.lane_id,
|
|
806
|
+
why: `dead-host classify failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (nowMs - lastHostReportMs(lane) < DEAD_HOST_MS)
|
|
812
|
+
continue;
|
|
813
|
+
try {
|
|
814
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running" &&
|
|
815
|
+
nowMs - lastHostReportMs(current) >= DEAD_HOST_MS, (current) => ({
|
|
816
|
+
...current,
|
|
817
|
+
state: "interrupted",
|
|
818
|
+
queued_reason: undefined,
|
|
819
|
+
terminal_reason: "host_lost",
|
|
820
|
+
}), deps.dryRun);
|
|
821
|
+
if (result.changed) {
|
|
822
|
+
summary.dead_host.push({
|
|
823
|
+
id: lane.lane_id,
|
|
824
|
+
from: "running",
|
|
825
|
+
to: "interrupted",
|
|
826
|
+
reason: "host_lost",
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
markDirty(summary, {
|
|
832
|
+
kind: "lane",
|
|
833
|
+
id: lane.lane_id,
|
|
834
|
+
why: `dead-host CAS failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
840
|
+
const localQueued = lanes
|
|
841
|
+
.filter((lane) => lane.state === "queued" && laneRunsHere(lane, deps.host))
|
|
842
|
+
.sort(compareQueueOrder);
|
|
843
|
+
if (localQueued.length === 0)
|
|
844
|
+
return;
|
|
845
|
+
const config = loadLanesConfig(hqRoot);
|
|
846
|
+
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
847
|
+
const filePath = throttleStatePath(hqRoot);
|
|
848
|
+
const loaded = readThrottleState(filePath, throttleDeps);
|
|
849
|
+
const throttleState = loaded === "unreadable"
|
|
850
|
+
? "unreadable"
|
|
851
|
+
: loaded === "missing"
|
|
852
|
+
? emptyThrottleState()
|
|
853
|
+
: loaded;
|
|
854
|
+
const busy = await resolveCpuBusyFraction(throttleDeps);
|
|
855
|
+
const availableMemMb = readAvailableMemMb(throttleDeps);
|
|
856
|
+
const nowMs = now.getTime();
|
|
857
|
+
const decision = evaluateThrottle({
|
|
858
|
+
busy,
|
|
859
|
+
availableMemMb,
|
|
860
|
+
cpuCheckEnabled: isCpuCheckEnabled(throttleDeps.env),
|
|
861
|
+
state: throttleState,
|
|
862
|
+
throttle: config.throttle,
|
|
863
|
+
nowMs,
|
|
864
|
+
});
|
|
865
|
+
const spawnFn = deps.spawn ??
|
|
866
|
+
((root, lane) => spawnLaneWorker(root, lane, deps.spawnLaneDeps));
|
|
867
|
+
const running = lanes.filter((lane) => lane.state === "running");
|
|
868
|
+
const starvation = decision.admit && decision.reason === "starvation_bound";
|
|
869
|
+
const candidates = starvation ? localQueued.slice(0, 1) : localQueued;
|
|
870
|
+
const spawned = [];
|
|
871
|
+
if (!decision.admit) {
|
|
872
|
+
if (!deps.dryRun && throttleState !== "unreadable") {
|
|
873
|
+
writeThrottleState(filePath, applyThrottleDecision(throttleState, decision, nowMs), throttleDeps);
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
for (const lane of candidates) {
|
|
878
|
+
const provider = lane.provider;
|
|
879
|
+
const providerCfg = config.providers[provider];
|
|
880
|
+
const providerRunning = running.filter((row) => row.provider === provider).length +
|
|
881
|
+
spawned.filter((row) => row.provider === provider).length;
|
|
882
|
+
if (providerRunning >= providerCfg.max_running && !starvation)
|
|
883
|
+
continue;
|
|
884
|
+
if (lane.account) {
|
|
885
|
+
const accountRunning = running.filter((row) => row.provider === provider && row.account === lane.account).length +
|
|
886
|
+
spawned.filter((row) => row.provider === provider && row.account === lane.account).length;
|
|
887
|
+
if (accountRunning >= accountMaxRunning(providerCfg, lane.account) &&
|
|
888
|
+
!starvation) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
if (deps.dryRun) {
|
|
894
|
+
summary.admitted.push({
|
|
895
|
+
id: lane.lane_id,
|
|
896
|
+
from: "queued",
|
|
897
|
+
to: "running",
|
|
898
|
+
reason: decision.reason,
|
|
899
|
+
});
|
|
900
|
+
spawned.push({ ...lane, state: "running" });
|
|
901
|
+
if (starvation)
|
|
902
|
+
break;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const started = spawnFn(hqRoot, lane);
|
|
906
|
+
if (decision.reason === "starvation_bound") {
|
|
907
|
+
casLaneIf(hqRoot, lane.lane_id, (current) => current.admitted_reason !== "starvation_bound", (current) => ({ ...current, admitted_reason: "starvation_bound" }), false);
|
|
908
|
+
}
|
|
909
|
+
summary.admitted.push({
|
|
910
|
+
id: started.lane_id,
|
|
911
|
+
from: "queued",
|
|
912
|
+
to: started.state,
|
|
913
|
+
reason: decision.reason,
|
|
914
|
+
...(started.queued_reason === "provider_binary_missing" &&
|
|
915
|
+
typeof started.terminal_details?.searched_path === "string"
|
|
916
|
+
? {
|
|
917
|
+
searched_path: started.terminal_details.searched_path,
|
|
918
|
+
}
|
|
919
|
+
: {}),
|
|
920
|
+
});
|
|
921
|
+
spawned.push(started);
|
|
922
|
+
if (starvation)
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
markDirty(summary, {
|
|
927
|
+
kind: "lane",
|
|
928
|
+
id: lane.lane_id,
|
|
929
|
+
why: `admit failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (!deps.dryRun) {
|
|
934
|
+
const admittedAny = spawned.length > 0;
|
|
935
|
+
const applied = admittedAny
|
|
936
|
+
? { admit: true, reason: decision.reason }
|
|
937
|
+
: decision;
|
|
938
|
+
const base = throttleState === "unreadable" ? emptyThrottleState() : throttleState;
|
|
939
|
+
writeThrottleState(filePath, applyThrottleDecision(base, applied, nowMs), throttleDeps);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
943
|
+
const spawnFn = deps.spawn ??
|
|
944
|
+
((root, lane) => spawnLaneWorker(root, lane, deps.spawnLaneDeps));
|
|
945
|
+
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" && laneRunsHere(lane, deps.host));
|
|
946
|
+
for (const lane of awaiting) {
|
|
947
|
+
const pending = questions.filter((q) => q.origin.id === lane.lane_id && q.status === "pending");
|
|
948
|
+
const answered = questions.filter((q) => q.origin.id === lane.lane_id && q.status === "answered");
|
|
949
|
+
if (pending.length > 0 || answered.length === 0)
|
|
950
|
+
continue;
|
|
951
|
+
try {
|
|
952
|
+
if (deps.dryRun) {
|
|
953
|
+
summary.resumed.push({
|
|
954
|
+
id: lane.lane_id,
|
|
955
|
+
from: "awaiting_input",
|
|
956
|
+
to: "running",
|
|
957
|
+
});
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
writeAnsweredQuestions(hqRoot, lane, questions);
|
|
961
|
+
const started = spawnFn(hqRoot, lane);
|
|
962
|
+
summary.resumed.push({
|
|
963
|
+
id: started.lane_id,
|
|
964
|
+
from: "awaiting_input",
|
|
965
|
+
to: started.state,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
markDirty(summary, {
|
|
970
|
+
kind: "lane",
|
|
971
|
+
id: lane.lane_id,
|
|
972
|
+
why: `resume failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* One idempotent reconcile pass. Throws only when the store directory itself
|
|
979
|
+
* cannot be listed; per-record failures are logged onto the summary and set
|
|
980
|
+
* `ok: false` rather than being swallowed.
|
|
981
|
+
*/
|
|
982
|
+
export async function runReconcilePass(hqRoot, deps) {
|
|
983
|
+
return withReconcileLock(hqRoot, async () => runPassBody(hqRoot, deps));
|
|
984
|
+
}
|
|
985
|
+
//# sourceMappingURL=reconcile.js.map
|