@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -0,0 +1,859 @@
1
+ /**
2
+ * Per-lane watcher (spec §5.7).
3
+ *
4
+ * Armed at spawn, detached from the caller. Emits on progress, terminal, and
5
+ * silence (20 minutes with the process still alive). Terminal classification
6
+ * follows spec §5: an envelope decides done/ask/blocked; a process that exits
7
+ * with partial output and no envelope is `interrupted`; silence is never
8
+ * success.
9
+ *
10
+ * Progress has two sources. Output files (worker.out, worker.err,
11
+ * last-message.txt) cover codex and grok, which stream as they work. They do
12
+ * not cover Claude: `claude -p --output-format json` writes nothing until it
13
+ * exits, so a healthy Claude lane used to hit the silence deadline at exactly
14
+ * 20 minutes every time, and a hung one looked identical. The second source
15
+ * is logs/tool-activity.json, which the drain hook rewrites on every
16
+ * PreToolUse call (dropbox.ts). A worker that is working calls tools; a
17
+ * worker that has not called a tool or written output for the whole window
18
+ * is `stalled`, and the watcher says so on the lane record.
19
+ *
20
+ * What this cannot tell apart: a worker hung inside one tool call from a
21
+ * worker running one legitimately long tool call (a 25-minute build); a
22
+ * worker looping on tool calls without making progress from a working one;
23
+ * a model stuck mid-generation with no tool call from one that is writing a
24
+ * long answer. It also depends on the drain hook being registered in the
25
+ * provider home (hooks-install.ts): with no hook there is no touch, and the
26
+ * lane reads as stalled every window, which is the pre-existing behaviour.
27
+ * Distinguishing a hung tool from a slow one needs the provider to report
28
+ * per-call state, which none of the three CLIs expose today. `stalled` is
29
+ * therefore a flag for the senior to look at, never a kill.
30
+ *
31
+ * Queue admission of the next lane when a slot frees is owned by the
32
+ * periodic reconcile pass and is out of scope here.
33
+ */
34
+ import * as fs from "node:fs";
35
+ import * as path from "node:path";
36
+ import { LanesError } from "./errors.js";
37
+ import { raiseEnvelopeQuestions } from "./questions.js";
38
+ import { isTerminalLaneState, parseLaneEnvelope, } from "./types.js";
39
+ import { readLane, retryUpdateLane } from "./store.js";
40
+ import { readWorkerRunResult } from "./delivery.js";
41
+ import { readQuotaSignal, recordAccountQuota } from "./quota.js";
42
+ import { readToolActivity, toolActivityPath } from "./dropbox.js";
43
+ import { loadLanesConfig, providerProgressEventMinIntervalMs } from "./config.js";
44
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
45
+ import { envelopeQuestionId } from "./questions.js";
46
+ import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, } from "./spawn.js";
47
+ /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
48
+ export const SILENCE_MS = 20 * 60 * 1000;
49
+ export const WATCHER_POLL_MS = 5_000;
50
+ /** Synced last_host_report_at cadence. Reconcile dead-host TTL is 20 minutes. */
51
+ export const WATCHER_HEARTBEAT_MS = 60 * 1000;
52
+ /**
53
+ * Cap on logs/watcher.out. A decision line is typically under 300 bytes, so
54
+ * 256 KiB holds hundreds of transitions. A stuck lane cannot fill the disk:
55
+ * once the file would exceed this, the oldest complete lines are dropped.
56
+ */
57
+ export const WATCHER_OUT_MAX_BYTES = 256 * 1024;
58
+ export const WATCHER_DECISION_EVENTS = [
59
+ "liveness_transition",
60
+ "silence_deadline",
61
+ "envelope_observed",
62
+ "envelope_invalid",
63
+ "envelope_repair",
64
+ "classification",
65
+ "terminal_write",
66
+ "mesh_event",
67
+ "mesh_event_failed",
68
+ ];
69
+ /** Terminal reason when the envelope failed to parse after the repair turn (spec §4). */
70
+ export const ENVELOPE_INVALID_REASON = "envelope_invalid";
71
+ export const WATCHER_SIGNAL_KINDS = ["progress", "terminal", "silence"];
72
+ export function parseWatcherSignalKind(value) {
73
+ if (typeof value === "string" &&
74
+ WATCHER_SIGNAL_KINDS.includes(value)) {
75
+ return value;
76
+ }
77
+ throw new LanesError("unknown_watcher_signal", `Unknown watcher signal ${JSON.stringify(value)}. Accepted: ${WATCHER_SIGNAL_KINDS.join(", ")}`, { accepted: [...WATCHER_SIGNAL_KINDS], value });
78
+ }
79
+ export function classifyLaneExit(input) {
80
+ if (input.envelope) {
81
+ if (input.envelope.decision === "done")
82
+ return { state: "done" };
83
+ if (input.envelope.decision === "ask")
84
+ return { state: "awaiting_input" };
85
+ if (input.envelope.decision === "blocked")
86
+ return { state: "blocked" };
87
+ throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
88
+ }
89
+ if (input.invalidEnvelope) {
90
+ return { state: "failed", reason: ENVELOPE_INVALID_REASON };
91
+ }
92
+ if (input.quota) {
93
+ return {
94
+ state: input.hasPartialOutput ? "interrupted" : "failed",
95
+ reason: input.quota.reason,
96
+ };
97
+ }
98
+ if (input.hasPartialOutput) {
99
+ return { state: "interrupted", reason: "exited_with_partial_output" };
100
+ }
101
+ return { state: "failed", reason: "exited_without_envelope" };
102
+ }
103
+ export function tickWatcher(input) {
104
+ const silenceMs = input.silenceMs ?? SILENCE_MS;
105
+ const at = new Date(input.nowMs).toISOString();
106
+ if (!input.processAlive) {
107
+ const classification = classifyLaneExit({
108
+ envelope: input.envelope,
109
+ hasPartialOutput: input.hasPartialOutput,
110
+ quota: input.quota,
111
+ invalidEnvelope: input.invalidEnvelope,
112
+ });
113
+ return {
114
+ emit: { kind: "terminal", at, classification },
115
+ nextLastSignalMs: input.nowMs,
116
+ done: true,
117
+ };
118
+ }
119
+ if (input.progressed) {
120
+ return {
121
+ emit: { kind: "progress", at },
122
+ nextLastSignalMs: input.nowMs,
123
+ done: false,
124
+ };
125
+ }
126
+ if (input.nowMs - input.lastSignalMs >= silenceMs) {
127
+ return {
128
+ emit: { kind: "silence", at },
129
+ nextLastSignalMs: input.nowMs,
130
+ done: false,
131
+ };
132
+ }
133
+ return { nextLastSignalMs: input.lastSignalMs, done: false };
134
+ }
135
+ export function watcherEventsPath(hqRoot, laneId) {
136
+ return path.join(laneRunDir(hqRoot, laneId), "watcher", "events.jsonl");
137
+ }
138
+ export function watcherOutLogPath(hqRoot, laneId) {
139
+ return path.join(laneRunDir(hqRoot, laneId), "logs", "watcher.out");
140
+ }
141
+ export function appendCappedLog(filePath, line, maxBytes = WATCHER_OUT_MAX_BYTES) {
142
+ const payload = line.endsWith("\n") ? line : `${line}\n`;
143
+ const payloadBytes = Buffer.byteLength(payload, "utf8");
144
+ if (payloadBytes > maxBytes)
145
+ return;
146
+ let existing = "";
147
+ try {
148
+ existing = fs.readFileSync(filePath, "utf8");
149
+ }
150
+ catch (err) {
151
+ const code = err.code;
152
+ if (code !== "ENOENT")
153
+ throw err;
154
+ }
155
+ let body = existing;
156
+ while (Buffer.byteLength(body, "utf8") + payloadBytes > maxBytes) {
157
+ const nl = body.indexOf("\n");
158
+ if (nl === -1) {
159
+ body = "";
160
+ break;
161
+ }
162
+ body = body.slice(nl + 1);
163
+ }
164
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
165
+ fs.writeFileSync(filePath, body + payload, "utf8");
166
+ }
167
+ export function writeWatcherDecision(filePath, record, maxBytes = WATCHER_OUT_MAX_BYTES) {
168
+ const compact = {};
169
+ for (const [key, value] of Object.entries(record)) {
170
+ if (value !== undefined)
171
+ compact[key] = value;
172
+ }
173
+ appendCappedLog(filePath, JSON.stringify(compact), maxBytes);
174
+ }
175
+ export function watcherLastSignalPath(hqRoot, laneId) {
176
+ return path.join(laneRunDir(hqRoot, laneId), "watcher", "last-signal.json");
177
+ }
178
+ export function appendWatcherSignal(hqRoot, laneId, signal) {
179
+ const eventsPath = watcherEventsPath(hqRoot, laneId);
180
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
181
+ fs.appendFileSync(eventsPath, `${JSON.stringify(signal)}\n`, "utf8");
182
+ fs.writeFileSync(watcherLastSignalPath(hqRoot, laneId), `${JSON.stringify(signal, null, 2)}\n`, "utf8");
183
+ }
184
+ /**
185
+ * Slot-free notification. The periodic reconcile pass admits the next queued
186
+ * lane; this watcher must not do it. Intentionally empty.
187
+ */
188
+ export function notifySlotFreed(_input) {
189
+ // Reconcile lane owns admission when a running slot frees (spec §5.7).
190
+ }
191
+ /**
192
+ * On exit, read the worker's own result for a resumable provider and copy
193
+ * only the session id and the two cache counters onto the record. A missing
194
+ * or unparseable id is recorded as absent with its reason, never invented.
195
+ * Non-resumable providers return null and the record is left as it was.
196
+ */
197
+ function runResultFields(hqRoot, lane) {
198
+ const runDir = laneRunDir(hqRoot, lane.lane_id);
199
+ const result = readWorkerRunResult(runDir, lane.provider, readSpawnManifest(runDir).worker_out_offset ?? 0);
200
+ if (result === null)
201
+ return {};
202
+ const fields = {
203
+ last_run_usage: {
204
+ cache_read_input_tokens: result.cache_read_input_tokens,
205
+ cache_creation_input_tokens: result.cache_creation_input_tokens,
206
+ },
207
+ };
208
+ if (result.session_id !== null) {
209
+ fields.provider_session_id = result.session_id;
210
+ fields.provider_session_id_absent = undefined;
211
+ }
212
+ else {
213
+ fields.provider_session_id = undefined;
214
+ fields.provider_session_id_absent = result.session_id_absent ?? "no_result_json";
215
+ }
216
+ return fields;
217
+ }
218
+ function maybeHeartbeatLane(hqRoot, laneId, nowMs) {
219
+ try {
220
+ retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
221
+ if (current.state !== "running")
222
+ return current;
223
+ const last = Date.parse(current.last_host_report_at ?? "");
224
+ if (Number.isFinite(last) && nowMs - last < WATCHER_HEARTBEAT_MS) {
225
+ return current;
226
+ }
227
+ return { ...current, last_host_report_at: new Date(nowMs).toISOString() };
228
+ });
229
+ }
230
+ catch (err) {
231
+ process.stderr.write(`hq lanes watcher: heartbeat failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
232
+ }
233
+ }
234
+ export function stalledReason(silenceMs) {
235
+ return `no_tool_call_or_output_for_${Math.round(silenceMs / 1000)}s`;
236
+ }
237
+ /**
238
+ * The consumer of the silence signal. A silence emit on a running lane sets
239
+ * `stalled` on the record so `hq lanes list` and `show` surface it; the next
240
+ * progress emit clears it. Nothing here changes `state`.
241
+ */
242
+ export function markLaneStalled(hqRoot, laneId, at, reason) {
243
+ return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
244
+ if (current.state !== "running")
245
+ return current;
246
+ if (current.stalled === true && current.stalled_reason === reason)
247
+ return current;
248
+ return { ...current, stalled: true, stalled_at: at, stalled_reason: reason };
249
+ });
250
+ }
251
+ export function clearLaneStalled(hqRoot, laneId) {
252
+ return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
253
+ if (current.stalled === undefined && current.stalled_at === undefined)
254
+ return current;
255
+ return { ...current, stalled: undefined, stalled_at: undefined, stalled_reason: undefined };
256
+ });
257
+ }
258
+ export function applyTerminalToLane(hqRoot, laneId, classification, opts = {}) {
259
+ return applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts).record;
260
+ }
261
+ /** Terminal write outcome for emitters: only the CAS winner may publish mesh effects. */
262
+ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}) {
263
+ const at = opts.at ?? new Date().toISOString();
264
+ const existing = readLane(hqRoot, laneId);
265
+ if (!isTerminalLaneState(existing.state) &&
266
+ classification.state === "awaiting_input" &&
267
+ opts.envelope &&
268
+ (opts.envelope.questions?.length ?? 0) > 0) {
269
+ raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at);
270
+ }
271
+ let changed = false;
272
+ const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
273
+ const terminal = current.state === "done" ||
274
+ current.state === "failed" ||
275
+ current.state === "interrupted" ||
276
+ current.state === "cancelled" ||
277
+ current.state === "killed";
278
+ if (terminal)
279
+ return current;
280
+ if (current.state === "awaiting_input" || current.state === "blocked") {
281
+ if (current.state === classification.state)
282
+ return current;
283
+ }
284
+ const next = {
285
+ ...current,
286
+ ...runResultFields(hqRoot, current),
287
+ state: classification.state,
288
+ queued_reason: undefined,
289
+ stalled: undefined,
290
+ stalled_at: undefined,
291
+ stalled_reason: undefined,
292
+ };
293
+ changed = true;
294
+ if (classification.reason) {
295
+ next.terminal_reason =
296
+ classification.reason;
297
+ }
298
+ if (opts.quota?.quota_reset_at) {
299
+ next.quota_reset_at = opts.quota.quota_reset_at;
300
+ }
301
+ if (opts.quota?.marker) {
302
+ next.model_quota_marker = opts.quota.marker;
303
+ }
304
+ if (opts.envelope && opts.envelopeRef) {
305
+ next.last_envelope = {
306
+ ref: opts.envelopeRef,
307
+ decision: opts.envelope.decision,
308
+ at,
309
+ };
310
+ next.story_status = opts.envelope.story_status;
311
+ if (opts.envelope.story_complete !== undefined) {
312
+ next.story_complete = opts.envelope.story_complete;
313
+ next.story_complete_reason = opts.envelope.story_complete_reason;
314
+ }
315
+ }
316
+ return next;
317
+ });
318
+ notifySlotFreed({
319
+ hqRoot,
320
+ provider: updated.provider,
321
+ account: updated.account,
322
+ });
323
+ return { record: updated, changed };
324
+ }
325
+ export function isPidAlive(pid) {
326
+ try {
327
+ process.kill(pid, 0);
328
+ return true;
329
+ }
330
+ catch (err) {
331
+ const code = err.code;
332
+ if (code === "ESRCH")
333
+ return false;
334
+ if (code === "EPERM")
335
+ return true;
336
+ process.stderr.write(`hq lanes watcher: liveness probe for pid ${pid} failed: ${err instanceof Error ? err.message : String(err)}\n`);
337
+ throw err;
338
+ }
339
+ }
340
+ function fileMtimeMs(filePath) {
341
+ try {
342
+ return fs.statSync(filePath).mtimeMs;
343
+ }
344
+ catch (err) {
345
+ const code = err.code;
346
+ if (code === "ENOENT")
347
+ return null;
348
+ process.stderr.write(`hq lanes watcher: failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
349
+ throw err;
350
+ }
351
+ }
352
+ function fileNonEmpty(filePath) {
353
+ try {
354
+ return fs.statSync(filePath).size > 0;
355
+ }
356
+ catch (err) {
357
+ const code = err.code;
358
+ if (code === "ENOENT")
359
+ return false;
360
+ process.stderr.write(`hq lanes watcher: failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
361
+ throw err;
362
+ }
363
+ }
364
+ export function readLatestEnvelope(hqRoot, laneId, minName) {
365
+ return readLatestEnvelopeRecord(hqRoot, laneId, minName)?.envelope ?? null;
366
+ }
367
+ /** Valid-only view over `readLatestEnvelopeFile`; an unparseable file reads as absent. */
368
+ export function readLatestEnvelopeRecord(hqRoot, laneId, minName) {
369
+ const latest = readLatestEnvelopeFile(hqRoot, laneId, minName);
370
+ if (latest === null)
371
+ return null;
372
+ if (latest.envelope === null) {
373
+ process.stderr.write(`hq lanes watcher: envelope ${latest.path} is not a valid hq-lane-envelope.v1: ${latest.error}\n`);
374
+ return null;
375
+ }
376
+ return { envelope: latest.envelope, ref: latest.ref };
377
+ }
378
+ /**
379
+ * `minName` is this run's envelope slot (basename from spawn.json). Envelopes
380
+ * that sort before it belong to an earlier run of the same lane and must not
381
+ * classify this exit: a resumed run that dies before writing its own envelope
382
+ * would otherwise be marked done again from the previous one.
383
+ */
384
+ export function readLatestEnvelopeFile(hqRoot, laneId, minName) {
385
+ const dir = path.join(laneRunDir(hqRoot, laneId), "envelopes");
386
+ let names;
387
+ try {
388
+ names = fs
389
+ .readdirSync(dir)
390
+ .filter((name) => name.endsWith(".json"))
391
+ .filter((name) => minName === undefined || name >= minName)
392
+ .sort();
393
+ }
394
+ catch (err) {
395
+ const code = err.code;
396
+ if (code === "ENOENT")
397
+ return null;
398
+ process.stderr.write(`hq lanes watcher: failed to list envelopes for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
399
+ throw err;
400
+ }
401
+ if (names.length === 0)
402
+ return null;
403
+ const latest = names[names.length - 1];
404
+ const filePath = path.join(dir, latest);
405
+ let text;
406
+ try {
407
+ text = fs.readFileSync(filePath, "utf8");
408
+ }
409
+ catch (err) {
410
+ process.stderr.write(`hq lanes watcher: failed to read envelope ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
411
+ throw err;
412
+ }
413
+ const ref = path.posix.join("envelopes", latest);
414
+ try {
415
+ return {
416
+ ref,
417
+ path: filePath,
418
+ text,
419
+ envelope: parseLaneEnvelope(JSON.parse(text)),
420
+ error: null,
421
+ };
422
+ }
423
+ catch (err) {
424
+ return {
425
+ ref,
426
+ path: filePath,
427
+ text,
428
+ envelope: null,
429
+ error: err instanceof Error ? err.message : String(err),
430
+ };
431
+ }
432
+ }
433
+ /**
434
+ * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
435
+ * the event name and the error message, and never propagates: the lane's
436
+ * state is decided by the envelope and the process, not by the network.
437
+ */
438
+ export async function emitMeshSoft(event, logDecision, at, fn, timeoutMs = 10_000) {
439
+ let timer;
440
+ try {
441
+ const controller = new AbortController();
442
+ await Promise.race([
443
+ fn(controller.signal),
444
+ new Promise((_resolve, reject) => {
445
+ timer = setTimeout(() => { controller.abort(); reject(new Error(`mesh request timed out after ${timeoutMs}ms`)); }, timeoutMs);
446
+ }),
447
+ ]);
448
+ logDecision({ event: "mesh_event", at, mesh_event: event });
449
+ return true;
450
+ }
451
+ catch (err) {
452
+ const message = err instanceof Error ? err.message : String(err);
453
+ logDecision({ event: "mesh_event_failed", at, mesh_event: event, error: message });
454
+ process.stderr.write(`hq lanes watcher: mesh_event_failed ${event}: ${message}\n`);
455
+ return false;
456
+ }
457
+ finally {
458
+ if (timer)
459
+ clearTimeout(timer);
460
+ }
461
+ }
462
+ function readProgressIntervalMs(hqRoot, lane) {
463
+ try {
464
+ const config = loadLanesConfig(hqRoot);
465
+ if (lane)
466
+ return providerProgressEventMinIntervalMs(config.providers[lane.provider]);
467
+ }
468
+ catch (err) {
469
+ process.stderr.write(`hq lanes watcher: lanes config unreadable, using default progress interval: ${err instanceof Error ? err.message : String(err)}\n`);
470
+ }
471
+ return providerProgressEventMinIntervalMs({ max_running: 1, max_queued: 1, accounts: {} });
472
+ }
473
+ function readLaneSoft(hqRoot, laneId) {
474
+ try {
475
+ return readLane(hqRoot, laneId);
476
+ }
477
+ catch (err) {
478
+ process.stderr.write(`hq lanes watcher: lane read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
479
+ return null;
480
+ }
481
+ }
482
+ /** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
483
+ export function envelopeQuestionIds(laneId, envelopeRef, envelope) {
484
+ return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, i));
485
+ }
486
+ function defaultSleep(ms) {
487
+ return new Promise((resolve) => {
488
+ setTimeout(resolve, ms);
489
+ });
490
+ }
491
+ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
492
+ const sleep = deps.sleep ?? defaultSleep;
493
+ const now = deps.now ?? (() => Date.now());
494
+ const isAlive = deps.isAlive ?? isPidAlive;
495
+ const readStarttime = deps.readStarttime ?? readProcStarttime;
496
+ const pollMs = deps.pollMs ?? WATCHER_POLL_MS;
497
+ const silenceMs = deps.silenceMs ?? SILENCE_MS;
498
+ const runDir = laneRunDir(hqRoot, laneId);
499
+ const outPath = deps.watcherOutPath ?? watcherOutLogPath(hqRoot, laneId);
500
+ const outMax = deps.watcherOutMaxBytes ?? WATCHER_OUT_MAX_BYTES;
501
+ const workerPidPath = path.join(runDir, "worker.pid");
502
+ const manifest = readSpawnManifest(runDir);
503
+ const minEnvelopeName = manifest.envelope_path !== undefined
504
+ ? path.basename(manifest.envelope_path)
505
+ : undefined;
506
+ const logFiles = [
507
+ path.join(runDir, "logs", "worker.out"),
508
+ path.join(runDir, "logs", "worker.err"),
509
+ path.join(runDir, "logs", "last-message.txt"),
510
+ ];
511
+ // Progress only. A tool call is not partial output: a worker that called
512
+ // tools and exited with no envelope and no output is still `failed`.
513
+ const activityFiles = [toolActivityPath(hqRoot, laneId)];
514
+ const progressFiles = [...logFiles, ...activityFiles];
515
+ // Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
516
+ const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
517
+ const progressIntervalMs = deps.progressEventMinIntervalMs ?? readProgressIntervalMs(hqRoot, readLaneSoft(hqRoot, laneId));
518
+ const spawnRepair = deps.spawnRepair ?? spawnEnvelopeRepair;
519
+ const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
520
+ let lastSignalMs = now();
521
+ let lastSeenMtime = 0;
522
+ let lastAlive = null;
523
+ let lastEnvelopeRef = null;
524
+ let lastInvalidRef = null;
525
+ let lastProgressEmitMs = (() => {
526
+ const stamp = readLaneSoft(hqRoot, laneId)?.last_progress_event_at;
527
+ return stamp ? Date.parse(stamp) || null : null;
528
+ })();
529
+ for (const file of progressFiles) {
530
+ const mtime = fileMtimeMs(file);
531
+ if (mtime !== null && mtime > lastSeenMtime)
532
+ lastSeenMtime = mtime;
533
+ }
534
+ const logDecision = (record) => {
535
+ writeWatcherDecision(outPath, record, outMax);
536
+ };
537
+ while (true) {
538
+ const worker = readPidRecord(workerPidPath);
539
+ const probe = worker === null
540
+ ? missingPidProbe()
541
+ : probePidRecord(worker, isAlive, readStarttime);
542
+ const processAlive = probe.alive;
543
+ if (lastAlive !== false && !processAlive) {
544
+ logDecision({
545
+ event: "liveness_transition",
546
+ at: new Date(now()).toISOString(),
547
+ pid: probe.pid,
548
+ recorded_starttime: probe.recorded_starttime,
549
+ live_starttime: probe.live_starttime,
550
+ starttime_error: probe.starttime_error,
551
+ decided_by: probe.decided_by,
552
+ });
553
+ }
554
+ lastAlive = processAlive;
555
+ let progressed = false;
556
+ let newestMtime = lastSeenMtime;
557
+ let newestFile = null;
558
+ for (const file of progressFiles) {
559
+ const mtime = fileMtimeMs(file);
560
+ if (mtime !== null && mtime > newestMtime) {
561
+ newestMtime = mtime;
562
+ newestFile = file;
563
+ progressed = true;
564
+ }
565
+ }
566
+ const latestFile = readLatestEnvelopeFile(hqRoot, laneId, minEnvelopeName);
567
+ const latest = latestFile && latestFile.envelope
568
+ ? { envelope: latestFile.envelope, ref: latestFile.ref }
569
+ : null;
570
+ const envelope = latest?.envelope ?? null;
571
+ if (latest && latest.ref !== lastEnvelopeRef) {
572
+ logDecision({
573
+ event: "envelope_observed",
574
+ at: new Date(now()).toISOString(),
575
+ filename: path.posix.basename(latest.ref),
576
+ decision: latest.envelope.decision,
577
+ });
578
+ lastEnvelopeRef = latest.ref;
579
+ }
580
+ if (latestFile && latestFile.envelope === null && latestFile.ref !== lastInvalidRef) {
581
+ logDecision({
582
+ event: "envelope_invalid",
583
+ at: new Date(now()).toISOString(),
584
+ filename: path.posix.basename(latestFile.ref),
585
+ error: latestFile.error,
586
+ });
587
+ lastInvalidRef = latestFile.ref;
588
+ }
589
+ const hasPartialOutput = logFiles.some((file) => fileNonEmpty(file));
590
+ let quota = null;
591
+ let quotaLane = null;
592
+ if (!processAlive && !envelope) {
593
+ try {
594
+ quotaLane = readLane(hqRoot, laneId);
595
+ quota = readQuotaSignal(runDir, quotaLane.model, new Date(now()), readSpawnManifest(runDir).worker_out_offset ?? 0);
596
+ }
597
+ catch (err) {
598
+ process.stderr.write(`hq lanes watcher: quota read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
599
+ }
600
+ }
601
+ // Spec §4: the process exited and its envelope file does not parse.
602
+ // Exactly one repair turn, then failed with both errors.
603
+ let invalidEnvelope = null;
604
+ if (!processAlive && !envelope && latestFile && latestFile.envelope === null) {
605
+ const repair = decideEnvelopeRepair(hqRoot, laneId, latestFile, now(), spawnRepair, logDecision);
606
+ if (repair.action === "spawned") {
607
+ lastSignalMs = now();
608
+ lastAlive = null;
609
+ if (progressed)
610
+ lastSeenMtime = newestMtime;
611
+ await sleep(pollMs);
612
+ continue;
613
+ }
614
+ invalidEnvelope = { errors: repair.errors };
615
+ }
616
+ if (processAlive === false && envelope && latestFile) {
617
+ markRepairSucceeded(hqRoot, laneId, latestFile.ref);
618
+ }
619
+ const tick = tickWatcher({
620
+ nowMs: now(),
621
+ processAlive,
622
+ progressed,
623
+ lastSignalMs,
624
+ envelope,
625
+ hasPartialOutput,
626
+ silenceMs,
627
+ quota,
628
+ invalidEnvelope,
629
+ });
630
+ if (processAlive) {
631
+ maybeHeartbeatLane(hqRoot, laneId, now());
632
+ }
633
+ if (tick.emit) {
634
+ parseWatcherSignalKind(tick.emit.kind);
635
+ appendWatcherSignal(hqRoot, laneId, tick.emit);
636
+ if (tick.emit.kind === "silence") {
637
+ const reason = stalledReason(silenceMs);
638
+ logDecision({
639
+ event: "silence_deadline",
640
+ at: tick.emit.at,
641
+ last_activity_at: new Date(lastSignalMs).toISOString(),
642
+ last_tool_call_at: readToolActivity(hqRoot, laneId)?.at ?? null,
643
+ deadline_at: new Date(lastSignalMs + silenceMs).toISOString(),
644
+ silence_ms: silenceMs,
645
+ stalled_reason: reason,
646
+ });
647
+ try {
648
+ markLaneStalled(hqRoot, laneId, tick.emit.at, reason);
649
+ }
650
+ catch (err) {
651
+ process.stderr.write(`hq lanes watcher: stalled write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
652
+ }
653
+ }
654
+ if (tick.emit.kind === "progress") {
655
+ const progressEmitAt = tick.emit.at;
656
+ try {
657
+ clearLaneStalled(hqRoot, laneId);
658
+ }
659
+ catch (err) {
660
+ process.stderr.write(`hq lanes watcher: stalled clear failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
661
+ }
662
+ // Spec §8 progress row. Only a tick that saw new tool activity or
663
+ // new output gets here (tickWatcher emits progress on `progressed`
664
+ // alone), and the throttle bounds the rate. Silence never reaches
665
+ // this branch.
666
+ if (mesh &&
667
+ progressEventDue({
668
+ lastEmitMs: lastProgressEmitMs,
669
+ nowMs: progressEmitAt ? Date.parse(progressEmitAt) : now(),
670
+ minIntervalMs: progressIntervalMs,
671
+ })) {
672
+ const lane = readLaneSoft(hqRoot, laneId);
673
+ if (lane) {
674
+ const detail = newestFile && activityFiles.includes(newestFile)
675
+ ? `tool call at ${readToolActivity(hqRoot, laneId)?.at ?? tick.emit.at}`
676
+ : envelope
677
+ ? "envelope written"
678
+ : "new output";
679
+ const sent = await emitMeshSoft("lane_progress", logDecision, progressEmitAt, (signal) => emitLaneProgressEvent(mesh, lane, detail, signal), meshRequestTimeoutMs);
680
+ if (sent) {
681
+ lastProgressEmitMs = Date.parse(progressEmitAt);
682
+ try {
683
+ retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
684
+ ...current,
685
+ last_progress_event_at: progressEmitAt,
686
+ }));
687
+ }
688
+ catch (err) {
689
+ process.stderr.write(`hq lanes watcher: progress timestamp write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
690
+ }
691
+ }
692
+ }
693
+ }
694
+ }
695
+ if (tick.emit.kind === "terminal" && tick.emit.classification) {
696
+ const classification = tick.emit.classification;
697
+ logDecision({
698
+ event: "classification",
699
+ at: tick.emit.at,
700
+ envelope_present: envelope !== null,
701
+ has_partial_output: hasPartialOutput,
702
+ state: classification.state,
703
+ reason: classification.reason,
704
+ ...(invalidEnvelope ? { envelope_errors: invalidEnvelope.errors } : {}),
705
+ });
706
+ if (quota &&
707
+ quotaLane?.account &&
708
+ (classification.reason === "account_quota_exhausted" ||
709
+ classification.reason === "account_model_unavailable")) {
710
+ try {
711
+ recordAccountQuota(hqRoot, quotaLane.provider, quotaLane.account, quota);
712
+ }
713
+ catch (err) {
714
+ process.stderr.write(`hq lanes watcher: quota record failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
715
+ }
716
+ }
717
+ const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
718
+ envelope,
719
+ envelopeRef: latest?.ref,
720
+ at: tick.emit.at,
721
+ quota,
722
+ });
723
+ const updated = terminalWrite.record;
724
+ logDecision({
725
+ event: "terminal_write",
726
+ at: tick.emit.at,
727
+ state: updated.state,
728
+ version: updated.version,
729
+ });
730
+ // Spec §8 terminal rows: done → lane_done + story review; blocked →
731
+ // lane_blocked; ask → lane_ask with the queue ids. After the state
732
+ // write, so a mesh failure can never leave the lane unclassified.
733
+ if (mesh && envelope && latest && terminalWrite.changed) {
734
+ const questionIds = envelope.decision === "ask"
735
+ ? envelopeQuestionIds(laneId, latest.ref, envelope)
736
+ : undefined;
737
+ await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
738
+ }
739
+ }
740
+ }
741
+ lastSignalMs = tick.nextLastSignalMs;
742
+ if (progressed)
743
+ lastSeenMtime = newestMtime;
744
+ if (tick.done)
745
+ return;
746
+ await sleep(pollMs);
747
+ }
748
+ }
749
+ /**
750
+ * Spec §4 repair decision for an exited worker whose envelope file does not
751
+ * parse. First failure on a resumable provider with a recorded session id:
752
+ * record `envelope_repair` on the lane and spawn the repair turn. A provider
753
+ * with no session resume gets the same single attempt as a fresh turn. Anything
754
+ * else (already attempted, no session id on a resumable provider, spawn
755
+ * failure): give up with every error collected, and the caller classifies
756
+ * the lane `failed` with reason `envelope_invalid`.
757
+ */
758
+ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair, logDecision) {
759
+ const at = new Date(nowMs).toISOString();
760
+ const error = latest.error ?? "unparseable envelope";
761
+ const lane = readLaneSoft(hqRoot, laneId);
762
+ if (!lane)
763
+ return { action: "give_up", errors: [error] };
764
+ const prior = lane.envelope_repair;
765
+ if (prior && prior.attempted) {
766
+ // The repair turn ran and its output does not parse either.
767
+ if (prior.attempted) {
768
+ const errors = [prior.first_error, error];
769
+ try {
770
+ retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
771
+ ...current,
772
+ envelope_repair: { ...prior, succeeded: false, second_error: error },
773
+ }));
774
+ }
775
+ catch (err) {
776
+ process.stderr.write(`hq lanes watcher: envelope_repair write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
777
+ }
778
+ logDecision({ event: "envelope_repair", at, outcome: "second_failure", errors });
779
+ return { action: "give_up", errors };
780
+ }
781
+ return { action: "give_up", errors: [prior.first_error] };
782
+ }
783
+ const runDir = laneRunDir(hqRoot, laneId);
784
+ // `resume` (claude, codex) continues the recorded session; `fresh_turn`
785
+ // (grok) runs the repair prompt as a new single turn on the same account.
786
+ const mode = envelopeRepairMode(lane.provider);
787
+ let skipped;
788
+ let sessionId = null;
789
+ if (mode === "resume") {
790
+ const result = readWorkerRunResult(runDir, lane.provider, readSpawnManifest(runDir).worker_out_offset ?? 0);
791
+ sessionId = result?.session_id ?? null;
792
+ if (sessionId === null)
793
+ skipped = result?.session_id_absent ?? "no_result_json";
794
+ }
795
+ const base = {
796
+ first_error: error,
797
+ envelope_ref: latest.ref,
798
+ at,
799
+ };
800
+ if (skipped !== undefined) {
801
+ writeRepairRecord(hqRoot, laneId, { ...base, attempted: false, succeeded: false, skipped_reason: skipped });
802
+ logDecision({ event: "envelope_repair", at, outcome: "skipped", skipped_reason: skipped, error });
803
+ return { action: "give_up", errors: [error] };
804
+ }
805
+ writeRepairRecord(hqRoot, laneId, { ...base, mode, attempted: true, succeeded: false });
806
+ try {
807
+ const pid = spawnRepair(hqRoot, lane, {
808
+ session_id: sessionId,
809
+ envelopePath: latest.path,
810
+ error,
811
+ rawText: latest.text,
812
+ });
813
+ logDecision({ event: "envelope_repair", at, outcome: "spawned", mode, pid, session_id: sessionId, error });
814
+ return { action: "spawned" };
815
+ }
816
+ catch (err) {
817
+ const message = err instanceof Error ? err.message : String(err);
818
+ process.stderr.write(`hq lanes watcher: envelope repair spawn failed for ${laneId}: ${message}\n`);
819
+ writeRepairRecord(hqRoot, laneId, {
820
+ ...base,
821
+ mode,
822
+ attempted: true,
823
+ succeeded: false,
824
+ second_error: `repair_spawn_failed: ${message}`,
825
+ });
826
+ logDecision({ event: "envelope_repair", at, outcome: "spawn_failed", error: message });
827
+ return { action: "give_up", errors: [error, `repair_spawn_failed: ${message}`] };
828
+ }
829
+ }
830
+ function writeRepairRecord(hqRoot, laneId, record) {
831
+ retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({ ...current, envelope_repair: record }));
832
+ }
833
+ /** The repair run wrote a parseable envelope: close the record as succeeded. */
834
+ function markRepairSucceeded(hqRoot, laneId, _envelopeRef) {
835
+ try {
836
+ retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
837
+ const prior = current.envelope_repair;
838
+ if (!prior || !prior.attempted || prior.succeeded) {
839
+ return current;
840
+ }
841
+ return { ...current, envelope_repair: { ...prior, succeeded: true, second_error: undefined } };
842
+ });
843
+ }
844
+ catch (err) {
845
+ process.stderr.write(`hq lanes watcher: envelope_repair success write failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
846
+ }
847
+ }
848
+ /** Exported so tests can prove the watcher reads the live record, not spawn env. */
849
+ export function laneIdentityFromRecord(hqRoot, laneId) {
850
+ const lane = readLane(hqRoot, laneId);
851
+ return {
852
+ lane_id: lane.lane_id,
853
+ company: lane.company,
854
+ project_id: lane.project_id,
855
+ story_id: lane.story_id,
856
+ senior: lane.senior,
857
+ };
858
+ }
859
+ //# sourceMappingURL=watcher.js.map