@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,1363 @@
1
+ /**
2
+ * Detached lane-worker spawn (spec §5, §5.1, §5.4, §5.5).
3
+ *
4
+ * The worker must leave the caller's process tree (`detached: true` / setsid)
5
+ * with stdio pointed at files in the run directory. Account selection is the
6
+ * provider home environment variable only. Provider credential files are
7
+ * probed for existence and never read. Identity in the child is `HQ_LANE_ID`;
8
+ * mutable fields are re-read from the lane record (spec §5.4).
9
+ */
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { PROVIDER_HOME_ENV, accountStatus, isLoggedOut, resolveAccount, } from "./accounts.js";
15
+ import { COMPACTION_PROMPT, compactionAfterUsage, compactionDue, compactLaneSession, } from "./compaction.js";
16
+ import { installDrainHook } from "./hooks-install.js";
17
+ import { loadLanesConfig, providerCompactAfterMs } from "./config.js";
18
+ import { appendEffortFlag, resolveLaneEffort } from "./effort.js";
19
+ import { claimableStatesFor, planLaneLaunch, providerSupportsSessionResume, } from "./delivery.js";
20
+ import { AccountResolutionError, LanesError } from "./errors.js";
21
+ import { resolveMachineIdentity, } from "./machine-identity.js";
22
+ import { readLane, retryUpdateLane } from "./store.js";
23
+ import { LANE_ENVELOPE_JSON_SCHEMA, parseLaneProvider, } from "./types.js";
24
+ export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
25
+ /** Written by reconcile before a resume spawn; the worker prompt names this path. */
26
+ export const ANSWERED_QUESTIONS_FILE = "answered-questions.json";
27
+ export const SPAWN_TERMINAL_REASONS = [
28
+ "account_not_logged_in",
29
+ "unknown_account",
30
+ "account_disabled",
31
+ "no_enabled_account",
32
+ "spawn_failed",
33
+ "not_detached",
34
+ "watcher_not_armed",
35
+ ];
36
+ const PROVIDER_COMMAND = {
37
+ claude: "claude",
38
+ codex: "codex",
39
+ grok: "grok",
40
+ };
41
+ export function providerCommandOnPath(command, pathEnv) {
42
+ // Test and embedded callers can intentionally omit PATH and delegate lookup
43
+ // to their spawn implementation. A present PATH is authoritative.
44
+ if (pathEnv === undefined)
45
+ return true;
46
+ if (!pathEnv)
47
+ return false;
48
+ return pathEnv.split(path.delimiter).some((dir) => {
49
+ if (!dir)
50
+ return false;
51
+ const candidate = path.join(dir, command);
52
+ try {
53
+ fs.accessSync(candidate, fs.constants.X_OK);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ });
60
+ }
61
+ /** Per-provider model flag. A missing entry means --model is refused. */
62
+ export const PROVIDER_MODEL_FLAG = {
63
+ claude: "--model",
64
+ codex: "-m",
65
+ grok: "-m",
66
+ };
67
+ export function parseCliModel(raw) {
68
+ if (raw === undefined)
69
+ return undefined;
70
+ const trimmed = raw.trim();
71
+ if (trimmed.length === 0) {
72
+ throw new LanesError("empty_model", "--model is empty or whitespace-only", {
73
+ value: raw,
74
+ });
75
+ }
76
+ return trimmed;
77
+ }
78
+ export function resolveLaneModel(input) {
79
+ if (input.cliModel !== undefined)
80
+ return input.cliModel;
81
+ if (input.accountModel !== undefined && input.accountModel.trim().length > 0) {
82
+ return input.accountModel.trim();
83
+ }
84
+ if (input.providerModel !== undefined && input.providerModel.trim().length > 0) {
85
+ return input.providerModel.trim();
86
+ }
87
+ return undefined;
88
+ }
89
+ export function appendModelFlag(provider, args, model) {
90
+ if (model === undefined)
91
+ return args;
92
+ const flag = PROVIDER_MODEL_FLAG[provider];
93
+ if (flag === undefined) {
94
+ throw new LanesError("model_unsupported", `Provider ${provider} does not accept --model`, { provider, model });
95
+ }
96
+ if (provider === "codex") {
97
+ // `codex exec -- <prompt>`: an option after `--` is read as a positional.
98
+ const end = args.indexOf("--");
99
+ if (end !== -1)
100
+ return [...args.slice(0, end), flag, model, ...args.slice(end)];
101
+ }
102
+ return [...args, flag, model];
103
+ }
104
+ export const PID_LIVENESS_DECISIONS = [
105
+ "esrch",
106
+ "starttime_mismatch",
107
+ "eperm",
108
+ "starttime_match",
109
+ "no_recorded_starttime",
110
+ "starttime_unreadable",
111
+ "missing_pid_file",
112
+ ];
113
+ export function missingPidProbe() {
114
+ return {
115
+ pid: null,
116
+ recorded_starttime: null,
117
+ live_starttime: null,
118
+ starttime_error: "ENOENT",
119
+ alive: false,
120
+ decided_by: "missing_pid_file",
121
+ };
122
+ }
123
+ export function defaultPidIsAlive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ }
128
+ catch (err) {
129
+ const code = err.code;
130
+ if (code === "ESRCH")
131
+ return false;
132
+ if (code === "EPERM")
133
+ return true;
134
+ throw err;
135
+ }
136
+ }
137
+ export const STOP_TERM_WAIT_MS = 500;
138
+ export const STOP_KILL_WAIT_MS = 200;
139
+ const defaultSpawnProcess = (command, args, options) => spawn(command, args, options);
140
+ function defaultKillProcess(pid, signal = "SIGTERM") {
141
+ process.kill(pid, signal);
142
+ }
143
+ export function parseSpawnTerminalReason(value) {
144
+ if (SPAWN_TERMINAL_REASONS.includes(value)) {
145
+ return value;
146
+ }
147
+ throw new LanesError("unknown_spawn_reason", `Unknown spawn terminal reason ${JSON.stringify(value)}. Accepted: ${SPAWN_TERMINAL_REASONS.join(", ")}`, { accepted: [...SPAWN_TERMINAL_REASONS], value });
148
+ }
149
+ export function laneRunDir(hqRoot, laneId) {
150
+ return path.join(hqRoot, LANES_RUNS_REL, laneId);
151
+ }
152
+ export function watcherMainPath() {
153
+ return fileURLToPath(new URL("./watcher-main.js", import.meta.url));
154
+ }
155
+ export function parseProcStat(text) {
156
+ const open = text.indexOf("(");
157
+ const close = text.lastIndexOf(")");
158
+ if (open <= 0 || close <= open) {
159
+ throw new LanesError("proc_stat_unreadable", "Could not parse /proc/<pid>/stat", { text: text.slice(0, 120) });
160
+ }
161
+ const pid = Number(text.slice(0, open).trim());
162
+ const rest = text.slice(close + 1).trim().split(/\s+/);
163
+ const ppid = Number(rest[1]);
164
+ const pgrp = Number(rest[2]);
165
+ const session = Number(rest[3]);
166
+ if (![pid, ppid, pgrp, session].every((n) => Number.isInteger(n))) {
167
+ throw new LanesError("proc_stat_unreadable", "Could not parse pid/ppid/pgrp/session from /proc/<pid>/stat", { text: text.slice(0, 120) });
168
+ }
169
+ // /proc/<pid>/stat fields are 1-indexed and `rest` begins at field 3 (state),
170
+ // so field N lives at rest[N - 3]. starttime is field 22 -> rest[19].
171
+ // rest[21] is field 24 (rss), which at spawn is a page count like 1 and grows
172
+ // as the process runs — recording it made every fresh lane look dead within
173
+ // seconds of starting.
174
+ const startRaw = rest[19];
175
+ const starttime = startRaw !== undefined ? Number(startRaw) : undefined;
176
+ return {
177
+ pid,
178
+ ppid,
179
+ pgrp,
180
+ session,
181
+ starttime: Number.isInteger(starttime) ? starttime : undefined,
182
+ };
183
+ }
184
+ export function readProcStarttimeDetailed(pid) {
185
+ const proc = path.join("/proc", String(pid), "stat");
186
+ try {
187
+ const starttime = parseProcStat(fs.readFileSync(proc, "utf8")).starttime;
188
+ if (starttime === undefined)
189
+ return { starttime: null, error: "missing_field" };
190
+ return { starttime, error: null };
191
+ }
192
+ catch (err) {
193
+ const code = err.code;
194
+ if (code === "ENOENT")
195
+ return { starttime: null, error: "ENOENT" };
196
+ process.stderr.write(`hq lanes: failed to read starttime for pid ${pid}: ${err instanceof Error ? err.message : String(err)}\n`);
197
+ return {
198
+ starttime: null,
199
+ error: typeof code === "string" ? code : "unreadable",
200
+ };
201
+ }
202
+ }
203
+ export function readProcStarttime(pid) {
204
+ return readProcStarttimeDetailed(pid).starttime;
205
+ }
206
+ export function formatPidRecord(rec) {
207
+ return rec.starttime !== undefined ? `${rec.pid} ${rec.starttime}\n` : `${rec.pid}\n`;
208
+ }
209
+ export function parsePidRecord(text) {
210
+ const tokens = text.trim().split(/\s+/);
211
+ const pid = Number(tokens[0]);
212
+ if (!Number.isInteger(pid) || pid <= 0)
213
+ return null;
214
+ const starttime = tokens[1] !== undefined ? Number(tokens[1]) : undefined;
215
+ return {
216
+ pid,
217
+ starttime: Number.isInteger(starttime) ? starttime : undefined,
218
+ };
219
+ }
220
+ export function readPidRecord(filePath) {
221
+ try {
222
+ return parsePidRecord(fs.readFileSync(filePath, "utf8"));
223
+ }
224
+ catch (err) {
225
+ const code = err.code;
226
+ if (code === "ENOENT")
227
+ return null;
228
+ process.stderr.write(`hq lanes: failed to read pid file ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
229
+ throw err;
230
+ }
231
+ }
232
+ export function writePidRecord(filePath, rec) {
233
+ writeText(filePath, formatPidRecord(rec));
234
+ }
235
+ export function probePidRecord(rec, isAlive = defaultPidIsAlive, readStarttime = readProcStarttime) {
236
+ const recorded = rec.starttime ?? null;
237
+ const base = { pid: rec.pid, recorded_starttime: recorded };
238
+ let killAlive;
239
+ let killDecision = "starttime_match";
240
+ try {
241
+ killAlive = isAlive(rec.pid);
242
+ }
243
+ catch (err) {
244
+ const code = err.code;
245
+ if (code === "ESRCH") {
246
+ return {
247
+ ...base,
248
+ live_starttime: null,
249
+ starttime_error: null,
250
+ alive: false,
251
+ decided_by: "esrch",
252
+ };
253
+ }
254
+ if (code === "EPERM") {
255
+ killAlive = true;
256
+ killDecision = "eperm";
257
+ }
258
+ else {
259
+ throw err;
260
+ }
261
+ }
262
+ if (!killAlive) {
263
+ return {
264
+ ...base,
265
+ live_starttime: null,
266
+ starttime_error: null,
267
+ alive: false,
268
+ decided_by: "esrch",
269
+ };
270
+ }
271
+ if (recorded === null) {
272
+ return {
273
+ ...base,
274
+ live_starttime: null,
275
+ starttime_error: null,
276
+ alive: true,
277
+ decided_by: killDecision === "eperm" ? "eperm" : "no_recorded_starttime",
278
+ };
279
+ }
280
+ let live;
281
+ let starttime_error = null;
282
+ try {
283
+ live = readStarttime(rec.pid);
284
+ if (live === null)
285
+ starttime_error = "unreadable";
286
+ }
287
+ catch (err) {
288
+ const code = err.code;
289
+ live = null;
290
+ starttime_error = typeof code === "string" ? code : "unreadable";
291
+ }
292
+ if (live === null) {
293
+ return {
294
+ ...base,
295
+ live_starttime: null,
296
+ starttime_error,
297
+ alive: true,
298
+ decided_by: "starttime_unreadable",
299
+ };
300
+ }
301
+ if (live !== recorded) {
302
+ return {
303
+ ...base,
304
+ live_starttime: live,
305
+ starttime_error: null,
306
+ alive: false,
307
+ decided_by: "starttime_mismatch",
308
+ };
309
+ }
310
+ return {
311
+ ...base,
312
+ live_starttime: live,
313
+ starttime_error: null,
314
+ alive: true,
315
+ decided_by: killDecision === "eperm" ? "eperm" : "starttime_match",
316
+ };
317
+ }
318
+ export function isPidRecordAlive(rec, isAlive = defaultPidIsAlive, readStarttime = readProcStarttime) {
319
+ return probePidRecord(rec, isAlive, readStarttime).alive;
320
+ }
321
+ function blockingSleep(ms) {
322
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
323
+ }
324
+ export function signalProcessGroup(pid, signal, killProcess) {
325
+ try {
326
+ killProcess(-pid, signal);
327
+ return;
328
+ }
329
+ catch (err) {
330
+ const code = err.code;
331
+ if (code === "ESRCH")
332
+ return;
333
+ if (code !== "EINVAL" && code !== "EPERM") {
334
+ process.stderr.write(`hq lanes: process-group ${signal} for pid ${pid} failed: ${err instanceof Error ? err.message : String(err)}\n`);
335
+ }
336
+ }
337
+ try {
338
+ killProcess(pid, signal);
339
+ }
340
+ catch (err) {
341
+ const code = err.code;
342
+ if (code === "ESRCH")
343
+ return;
344
+ process.stderr.write(`hq lanes: ${signal} for pid ${pid} failed: ${err instanceof Error ? err.message : String(err)}\n`);
345
+ throw err;
346
+ }
347
+ }
348
+ export function stopLaneProcesses(hqRoot, laneId, deps = {}) {
349
+ const killProcess = deps.killProcess ?? defaultKillProcess;
350
+ const isAlive = deps.isAlive ??
351
+ ((pid) => {
352
+ try {
353
+ process.kill(pid, 0);
354
+ return true;
355
+ }
356
+ catch (err) {
357
+ const code = err.code;
358
+ if (code === "ESRCH")
359
+ return false;
360
+ if (code === "EPERM")
361
+ return true;
362
+ throw err;
363
+ }
364
+ });
365
+ const sleepMs = deps.sleepMs ?? blockingSleep;
366
+ const readStarttime = deps.readStarttime ?? readProcStarttime;
367
+ const runDir = laneRunDir(hqRoot, laneId);
368
+ const worker = readPidRecord(path.join(runDir, "worker.pid"));
369
+ const watcher = readPidRecord(path.join(runDir, "watcher.pid"));
370
+ const signals = [];
371
+ const targets = [worker, watcher].filter((rec) => rec !== null);
372
+ const fire = (rec, signal) => {
373
+ if (!isPidRecordAlive(rec, isAlive, readStarttime))
374
+ return;
375
+ signals.push({ pid: rec.pid, signal, group: true });
376
+ signalProcessGroup(rec.pid, signal, killProcess);
377
+ };
378
+ for (const rec of targets)
379
+ fire(rec, "SIGTERM");
380
+ if (targets.some((rec) => isPidRecordAlive(rec, isAlive, readStarttime))) {
381
+ sleepMs(STOP_TERM_WAIT_MS);
382
+ }
383
+ for (const rec of targets) {
384
+ if (isPidRecordAlive(rec, isAlive, readStarttime))
385
+ fire(rec, "SIGKILL");
386
+ }
387
+ return { worker, watcher, signals };
388
+ }
389
+ export function defaultProveDetached(pid, deps = {}) {
390
+ const proc = path.join("/proc", String(pid), "stat");
391
+ try {
392
+ const text = deps.readProcStatText
393
+ ? deps.readProcStatText(pid)
394
+ : fs.readFileSync(proc, "utf8");
395
+ const parsed = parseProcStat(text);
396
+ const leader = parsed.pid === pid && parsed.pgrp === parsed.pid && parsed.session === parsed.pgrp;
397
+ return {
398
+ detached: leader,
399
+ pid,
400
+ pgid: parsed.pgrp,
401
+ sid: parsed.session,
402
+ method: "proc_stat_session_leader",
403
+ reason: leader ? undefined : "not_session_leader",
404
+ };
405
+ }
406
+ catch (err) {
407
+ const code = err.code;
408
+ if (code !== "ENOENT") {
409
+ process.stderr.write(`hq lanes: failed to read ${proc}: ${err instanceof Error ? err.message : String(err)}\n`);
410
+ throw err;
411
+ }
412
+ }
413
+ try {
414
+ process.kill(pid, 0);
415
+ }
416
+ catch (err) {
417
+ const code = err.code;
418
+ if (code === "ESRCH") {
419
+ return { detached: false, pid, method: "pid_alive", reason: "pid_not_alive" };
420
+ }
421
+ if (code !== "EPERM") {
422
+ process.stderr.write(`hq lanes: liveness probe for pid ${pid} failed: ${err instanceof Error ? err.message : String(err)}\n`);
423
+ throw err;
424
+ }
425
+ }
426
+ const psProof = proveDetachedViaPs(pid);
427
+ if (psProof)
428
+ return psProof;
429
+ return {
430
+ detached: false,
431
+ pid,
432
+ method: "pid_alive_without_proc",
433
+ reason: "not_session_leader",
434
+ };
435
+ }
436
+ function proveDetachedViaPs(pid) {
437
+ try {
438
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "pid=,pgid="], {
439
+ encoding: "utf8",
440
+ timeout: 500,
441
+ });
442
+ if (result.error || result.status !== 0)
443
+ return null;
444
+ const parts = (result.stdout ?? "").trim().split(/\s+/).map(Number);
445
+ if (parts.length < 2 || parts.some((n) => !Number.isInteger(n)))
446
+ return null;
447
+ const psPid = parts[0];
448
+ const pgid = parts[1];
449
+ const leader = psPid === pid && pgid === pid;
450
+ return {
451
+ detached: leader,
452
+ pid,
453
+ pgid,
454
+ method: "ps_session_leader",
455
+ reason: leader ? undefined : "not_session_leader",
456
+ };
457
+ }
458
+ catch (err) {
459
+ process.stderr.write(`hq lanes: ps detach proof failed for pid ${pid}: ${err instanceof Error ? err.message : String(err)}\n`);
460
+ return null;
461
+ }
462
+ }
463
+ export function nextEnvelopePath(runDir) {
464
+ const dir = path.join(runDir, "envelopes");
465
+ let n = 1;
466
+ while (true) {
467
+ const name = `${String(n).padStart(3, "0")}.json`;
468
+ const filePath = path.join(dir, name);
469
+ if (!fs.existsSync(filePath))
470
+ return filePath;
471
+ n += 1;
472
+ if (n > 999) {
473
+ throw new LanesError("envelope_sequence_exhausted", `No free envelope slot under ${dir}`, { dir });
474
+ }
475
+ }
476
+ }
477
+ export function envelopeInstruction(envelopePath) {
478
+ return (`When finished, write a JSON object to ${envelopePath} using these key names exactly: ` +
479
+ `schema_version, decision, summary, story_status, artifacts, story_complete, story_complete_reason, questions, blocked_reason. ` +
480
+ `schema_version must be the exact string "hq-lane-envelope.v1". ` +
481
+ `decision must be one of "done", "ask", "blocked". ` +
482
+ `summary is a string. ` +
483
+ `story_status must be one of "queued", "in_progress", "review", "done". ` +
484
+ `artifacts is an array. ` +
485
+ `story_complete is a boolean and story_complete_reason is a non-empty string. ` +
486
+ `story_complete true requires story_status done and a reason stating what was delivered and how it was verified. ` +
487
+ `story_complete false requires story_status other than done and a reason stating what is still missing. ` +
488
+ `questions is present and non-empty exactly when decision is "ask". ` +
489
+ `blocked_reason is present exactly when decision is "blocked".`);
490
+ }
491
+ /**
492
+ * Standing authorization carried by every brief and every resume turn. A
493
+ * lane that ends each turn asking "continue or stop" has asked the operator
494
+ * to re-grant what the brief already granted; the paragraph names that as
495
+ * out of bounds and says what a `decision: ask` envelope is for.
496
+ */
497
+ export const STANDING_AUTHORIZATION = "STANDING AUTHORIZATION. This brief authorises completing every item it lists; " +
498
+ "you do not need to ask before finishing any of them. " +
499
+ 'A "decision": "ask" envelope is only for a decision this brief does not settle, ' +
500
+ "and it must name that decision and its options. " +
501
+ 'Do not raise a question whose options amount to "continue" or "stop" the work this brief describes; ' +
502
+ "continue that work until it is complete or you are blocked on something only the operator can supply.";
503
+ /**
504
+ * Foreground-only rule carried next to the standing authorization. Two lanes
505
+ * ended `interrupted` after backgrounding a slow test suite and ending the
506
+ * turn while it was still running; nothing in the brief said not to.
507
+ */
508
+ export const FOREGROUND_ONLY_RULE = "FOREGROUND ONLY. Run every command, test suite and check in the foreground and wait for it to finish. " +
509
+ "Never background a process and end the turn while it is still running. " +
510
+ "A turn that ends before the envelope is written is classified interrupted, " +
511
+ "and whatever the background process produces afterwards is not read.";
512
+ /**
513
+ * The answers as written by the reconcile pass (`answered-questions.json`),
514
+ * reduced to id + choice. A malformed file reads as no inline answers; the
515
+ * path is still named so the worker can read it.
516
+ */
517
+ export function readAnsweredQuestionLines(answersPath) {
518
+ let text;
519
+ try {
520
+ text = fs.readFileSync(answersPath, "utf8");
521
+ }
522
+ catch (err) {
523
+ const code = err.code;
524
+ if (code === "ENOENT")
525
+ return [];
526
+ process.stderr.write(`hq lanes: failed to read ${answersPath}: ${err instanceof Error ? err.message : String(err)}\n`);
527
+ throw err;
528
+ }
529
+ let parsed;
530
+ try {
531
+ parsed = JSON.parse(text);
532
+ }
533
+ catch (err) {
534
+ process.stderr.write(`hq lanes: ${answersPath} is not JSON: ${err instanceof Error ? err.message : String(err)}\n`);
535
+ return [];
536
+ }
537
+ if (!Array.isArray(parsed))
538
+ return [];
539
+ const out = [];
540
+ for (const item of parsed) {
541
+ if (!item || typeof item !== "object")
542
+ continue;
543
+ const rec = item;
544
+ if (typeof rec.question_id !== "string")
545
+ continue;
546
+ const line = {
547
+ question_id: rec.question_id,
548
+ choice: typeof rec.choice === "string" ? rec.choice : JSON.stringify(rec.choice ?? null),
549
+ };
550
+ if (typeof rec.notes === "string" && rec.notes.length > 0)
551
+ line.notes = rec.notes;
552
+ out.push(line);
553
+ }
554
+ return out;
555
+ }
556
+ export function resumePrompt(input) {
557
+ const parts = [];
558
+ if (input.resumedFrom !== undefined) {
559
+ parts.push(`The operator resumed this lane from state ${input.resumedFrom}. ` +
560
+ (input.resumedFrom === "interrupted"
561
+ ? "The previous run ended without writing its envelope; continue the work from where it stopped."
562
+ : "Continue the work from where the previous run stopped."));
563
+ }
564
+ if (input.operatorMessage !== undefined) {
565
+ parts.push("OPERATOR INSTRUCTION (outranks the lane brief). " +
566
+ "The following message was sent to this lane by the operator. Treat it as an operator instruction that outranks the brief.\n\n" +
567
+ input.operatorMessage);
568
+ }
569
+ if (input.answersPath !== undefined) {
570
+ const lines = (input.answers ?? []).map((item) => item.notes !== undefined
571
+ ? `- ${item.question_id}: ${item.choice}\n Operator notes: ${item.notes}`
572
+ : `- ${item.question_id}: ${item.choice}`);
573
+ parts.push([
574
+ `Previously answered questions are at ${input.answersPath}; treat those choices and the operator notes under them as operator decisions that outrank the brief. Continue the work.`,
575
+ ...(lines.length > 0 ? ["Answers:", ...lines] : []),
576
+ ].join("\n"));
577
+ }
578
+ // Every resume turn carries the authorization, including a textless
579
+ // `hq lanes resume`: a pre-upgrade session's brief never had the paragraph.
580
+ if (input.operatorMessage !== undefined ||
581
+ input.answersPath !== undefined ||
582
+ input.resumedFrom !== undefined) {
583
+ parts.push(STANDING_AUTHORIZATION);
584
+ parts.push(FOREGROUND_ONLY_RULE);
585
+ }
586
+ parts.push(envelopeInstruction(input.envelopePath));
587
+ return parts.join("\n\n");
588
+ }
589
+ /** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
590
+ export function envelopeRepairPrompt(input) {
591
+ const maxRawTextChars = 24_000;
592
+ const rawText = input.rawText.length <= maxRawTextChars
593
+ ? input.rawText
594
+ : `${input.rawText.slice(0, 12_000)}\n\n[... raw envelope truncated; inspect ${input.envelopePath} for the complete file ...]\n\n${input.rawText.slice(-12_000)}`;
595
+ return [
596
+ `The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. ` +
597
+ `This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
598
+ `Parser error: ${input.error}`,
599
+ "Schema:",
600
+ JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
601
+ "Your previous output was:",
602
+ rawText,
603
+ `Write the corrected JSON object to ${input.envelopePath}, overwriting it. ` +
604
+ `Output only that JSON. Do not add keys the schema does not name, and do not resume the task.`,
605
+ ].join("\n\n");
606
+ }
607
+ export function buildProviderCommand(provider, input) {
608
+ const parsed = parseLaneProvider(provider);
609
+ const command = PROVIDER_COMMAND[parsed];
610
+ if (input.resume && !providerSupportsSessionResume(parsed)) {
611
+ throw new LanesError("resume_unsupported", `Provider ${parsed} cannot resume an exited worker from a session id`, { provider: parsed });
612
+ }
613
+ if (input.resume && input.freshTurnPrompt !== undefined) {
614
+ throw new LanesError("invalid_launch", "resume and freshTurnPrompt cannot both be set on one launch", { provider: parsed });
615
+ }
616
+ let prompt;
617
+ if (input.freshTurnPrompt !== undefined) {
618
+ prompt = input.freshTurnPrompt;
619
+ }
620
+ else {
621
+ prompt = `Execute the lane brief at ${input.briefPath}. ${envelopeInstruction(input.envelopePath)}`;
622
+ if (input.extraPrompt)
623
+ prompt += ` ${input.extraPrompt}`;
624
+ }
625
+ let args;
626
+ if (input.resume && parsed === "codex") {
627
+ // `codex exec resume` (0.155.1) takes `[SESSION_ID] [PROMPT]` and has
628
+ // neither `-C` nor `--color`; the session keeps its recorded cwd. `--json`
629
+ // makes the resumed run re-emit `thread.started` with the same id.
630
+ args = [
631
+ "exec",
632
+ "resume",
633
+ "--dangerously-bypass-hook-trust",
634
+ "--skip-git-repo-check",
635
+ "--dangerously-bypass-approvals-and-sandbox",
636
+ "--json",
637
+ "--output-last-message",
638
+ input.lastMessagePath,
639
+ "--",
640
+ input.resume.session_id,
641
+ input.resume.prompt,
642
+ ];
643
+ }
644
+ else if (input.resume) {
645
+ args = [
646
+ "-p",
647
+ "--resume",
648
+ input.resume.session_id,
649
+ input.resume.prompt,
650
+ "--permission-mode",
651
+ "bypassPermissions",
652
+ "--output-format",
653
+ "json",
654
+ ];
655
+ }
656
+ else if (parsed === "codex") {
657
+ // `--json` puts the thread id on stdout (`thread.started`); that is the
658
+ // only place `codex exec` exposes the id a later resume needs.
659
+ args = [
660
+ "exec",
661
+ "--dangerously-bypass-hook-trust",
662
+ "--skip-git-repo-check",
663
+ "--dangerously-bypass-approvals-and-sandbox",
664
+ "--color",
665
+ "never",
666
+ "--json",
667
+ "-C",
668
+ input.hqRoot,
669
+ "--output-last-message",
670
+ input.lastMessagePath,
671
+ "--",
672
+ prompt,
673
+ ];
674
+ }
675
+ else if (parsed === "grok") {
676
+ args = [
677
+ "--single",
678
+ prompt,
679
+ "--permission-mode",
680
+ "bypassPermissions",
681
+ "--always-approve",
682
+ "--output-format",
683
+ "json",
684
+ ];
685
+ }
686
+ else {
687
+ args = [
688
+ "-p",
689
+ prompt,
690
+ "--permission-mode",
691
+ "bypassPermissions",
692
+ "--output-format",
693
+ "json",
694
+ ];
695
+ }
696
+ return {
697
+ command,
698
+ args: appendEffortFlag(parsed, appendModelFlag(parsed, args, input.model), input.effort),
699
+ };
700
+ }
701
+ export function workerEnv(base, resolved, laneId, hqRoot) {
702
+ const accepted = Object.values(PROVIDER_HOME_ENV);
703
+ if (!accepted.includes(resolved.envVar)) {
704
+ throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
705
+ }
706
+ const env = { ...base };
707
+ delete env.HQ_PROJECT_ID;
708
+ delete env.HQ_STORY_ID;
709
+ env[resolved.envVar] = resolved.home;
710
+ env.HQ_LANE_ID = laneId;
711
+ env.HQ_ROOT = hqRoot;
712
+ return env;
713
+ }
714
+ function ensureDir(dir) {
715
+ fs.mkdirSync(dir, { recursive: true });
716
+ }
717
+ function fileSizeOrZero(filePath) {
718
+ try {
719
+ return fs.statSync(filePath).size;
720
+ }
721
+ catch (err) {
722
+ const code = err.code;
723
+ if (code === "ENOENT")
724
+ return 0;
725
+ process.stderr.write(`hq lanes: failed to stat ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
726
+ throw err;
727
+ }
728
+ }
729
+ function writeText(filePath, text) {
730
+ ensureDir(path.dirname(filePath));
731
+ fs.writeFileSync(filePath, text, "utf8");
732
+ }
733
+ function openLog(filePath) {
734
+ ensureDir(path.dirname(filePath));
735
+ return fs.openSync(filePath, "a");
736
+ }
737
+ function closeFd(fd, label) {
738
+ try {
739
+ fs.closeSync(fd);
740
+ }
741
+ catch (err) {
742
+ process.stderr.write(`hq lanes: failed to close ${label} fd ${fd}: ${err instanceof Error ? err.message : String(err)}\n`);
743
+ throw err;
744
+ }
745
+ }
746
+ function killQuietly(kill, pid, label) {
747
+ try {
748
+ signalProcessGroup(pid, "SIGTERM", kill);
749
+ }
750
+ catch (err) {
751
+ const code = err.code;
752
+ if (code === "ESRCH")
753
+ return;
754
+ process.stderr.write(`hq lanes: failed to stop ${label} pid ${pid}: ${err instanceof Error ? err.message : String(err)}\n`);
755
+ throw err;
756
+ }
757
+ }
758
+ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details) {
759
+ const reason = parseSpawnTerminalReason(reasonRaw);
760
+ return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
761
+ const existing = current
762
+ .terminal_reason;
763
+ if (current.state === "failed" && existing === reason)
764
+ return current;
765
+ return {
766
+ ...current,
767
+ state: "failed",
768
+ queued_reason: undefined,
769
+ terminal_reason: reason,
770
+ terminal_details: details,
771
+ };
772
+ });
773
+ }
774
+ function spawnDetached(spawnProcess, command, args, env, cwd, stdoutPath, stderrPath) {
775
+ const outFd = openLog(stdoutPath);
776
+ let errFd;
777
+ try {
778
+ errFd = openLog(stderrPath);
779
+ const child = spawnProcess(command, args, {
780
+ detached: true,
781
+ stdio: ["ignore", outFd, errFd],
782
+ env,
783
+ cwd,
784
+ });
785
+ child.unref();
786
+ if (typeof child.pid !== "number" || child.pid <= 0) {
787
+ throw new LanesError("spawn_failed", `Failed to start ${command} (no pid)`, { command, args });
788
+ }
789
+ return child.pid;
790
+ }
791
+ catch (err) {
792
+ if (err instanceof LanesError)
793
+ throw err;
794
+ throw new LanesError("spawn_failed", `Failed to start ${command}: ${err instanceof Error ? err.message : String(err)}`, {
795
+ command,
796
+ args,
797
+ message: err instanceof Error ? err.message : String(err),
798
+ });
799
+ }
800
+ finally {
801
+ closeFd(outFd, stdoutPath);
802
+ if (errFd !== undefined)
803
+ closeFd(errFd, stderrPath);
804
+ }
805
+ }
806
+ function requireDetached(proveDetached, pid, kind) {
807
+ const proof = proveDetached(pid);
808
+ if (proof.detached)
809
+ return proof;
810
+ const error = kind === "watcher" ? "watcher_not_armed" : "not_detached";
811
+ throw new LanesError(error, `${kind} pid ${pid} was not proven detached`, { pid, kind, reason: proof.reason ?? error, method: proof.method });
812
+ }
813
+ function originHostOf(lane) {
814
+ const value = lane.origin_host;
815
+ return typeof value === "string" ? value : "";
816
+ }
817
+ function originHostSourceOf(lane) {
818
+ const value = lane.origin_host_source;
819
+ return value === "imds" || value === "hostname" ? value : "";
820
+ }
821
+ function markOutpostSelfTarget(hqRoot, laneId, details) {
822
+ return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
823
+ const existing = current
824
+ .terminal_reason;
825
+ if (current.state === "cancelled" && existing === "outpost_self_target") {
826
+ return current;
827
+ }
828
+ return {
829
+ ...current,
830
+ state: "cancelled",
831
+ queued_reason: undefined,
832
+ terminal_reason: "outpost_self_target",
833
+ terminal_details: details,
834
+ };
835
+ });
836
+ }
837
+ /**
838
+ * Spec §5.2 executing-side check. Compares this machine's identity to
839
+ * `origin_host` (never to `host.id`). Unresolvable identity is a cancel.
840
+ * Must run before the worker is spawned.
841
+ */
842
+ export function refuseOutpostSelfTarget(hqRoot, lane, deps) {
843
+ const identity = (deps.resolveMachineIdentity ?? resolveMachineIdentity)();
844
+ const origin = originHostOf(lane);
845
+ const originSource = originHostSourceOf(lane);
846
+ let selfTargeted = origin === "" || identity.status !== "ok";
847
+ if (!selfTargeted && identity.status === "ok") {
848
+ if (originSource === "imds") {
849
+ const execImds = identity.imds ?? (identity.source === "imds" ? identity.id : null);
850
+ selfTargeted = execImds === null || execImds === origin;
851
+ }
852
+ else if (originSource === "hostname") {
853
+ const execHost = identity.hostname ??
854
+ (identity.source === "hostname" ? identity.id : null);
855
+ selfTargeted = execHost === null ? identity.id === origin : execHost === origin;
856
+ }
857
+ else {
858
+ selfTargeted = identity.id === origin;
859
+ }
860
+ }
861
+ if (!selfTargeted)
862
+ return;
863
+ const cancelled = markOutpostSelfTarget(hqRoot, lane.lane_id, {
864
+ origin_host: origin,
865
+ identity,
866
+ });
867
+ throw new LanesError("outpost_self_target", "Outpost exec came back to the caller; lane cancelled before work", {
868
+ origin_host: origin,
869
+ identity,
870
+ lane_id: lane.lane_id,
871
+ lane: cancelled,
872
+ });
873
+ }
874
+ function writeSpawnManifest(runDir, input) {
875
+ writeText(path.join(runDir, "spawn.json"), `${JSON.stringify({
876
+ command: input.command,
877
+ args: input.args,
878
+ env_var: input.envVar,
879
+ account: input.account,
880
+ worker_pid: input.workerPid,
881
+ watcher_pid: input.watcherPid,
882
+ detached: true,
883
+ envelope_path: input.envelopePath,
884
+ resume: input.resume,
885
+ worker_out_offset: input.workerOutOffset,
886
+ }, null, 2)}\n`);
887
+ }
888
+ /** Arm (or re-arm) the detached per-lane watcher. */
889
+ export function armLaneWatcher(hqRoot, laneId, deps = {}) {
890
+ const env = deps.env ?? process.env;
891
+ const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
892
+ const proveDetached = deps.proveDetached ?? defaultProveDetached;
893
+ const runDir = laneRunDir(hqRoot, laneId);
894
+ const logsDir = path.join(runDir, "logs");
895
+ ensureDir(logsDir);
896
+ const watcherPid = spawnDetached(spawnProcess, process.execPath, [watcherMainPath(), laneId], {
897
+ ...env,
898
+ HQ_ROOT: hqRoot,
899
+ HQ_LANE_ID: laneId,
900
+ }, hqRoot, path.join(logsDir, "watcher.out"), path.join(logsDir, "watcher.err"));
901
+ writePidRecord(path.join(runDir, "watcher.pid"), {
902
+ pid: watcherPid,
903
+ starttime: readProcStarttime(watcherPid) ?? undefined,
904
+ });
905
+ try {
906
+ requireDetached(proveDetached, watcherPid, "watcher");
907
+ }
908
+ catch (err) {
909
+ killQuietly(deps.killProcess ?? defaultKillProcess, watcherPid, "watcher");
910
+ throw err;
911
+ }
912
+ return watcherPid;
913
+ }
914
+ /** Read the per-run manifest. Missing file or field reads as absent. */
915
+ export function readSpawnManifest(runDir) {
916
+ const filePath = path.join(runDir, "spawn.json");
917
+ let text;
918
+ try {
919
+ text = fs.readFileSync(filePath, "utf8");
920
+ }
921
+ catch (err) {
922
+ const code = err.code;
923
+ if (code === "ENOENT")
924
+ return {};
925
+ process.stderr.write(`hq lanes: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
926
+ throw err;
927
+ }
928
+ let parsed;
929
+ try {
930
+ parsed = JSON.parse(text);
931
+ }
932
+ catch (err) {
933
+ process.stderr.write(`hq lanes: ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
934
+ return {};
935
+ }
936
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
937
+ return {};
938
+ const rec = parsed;
939
+ const out = {};
940
+ if (typeof rec.envelope_path === "string")
941
+ out.envelope_path = rec.envelope_path;
942
+ if (typeof rec.worker_out_offset === "number" &&
943
+ Number.isInteger(rec.worker_out_offset) &&
944
+ rec.worker_out_offset >= 0) {
945
+ out.worker_out_offset = rec.worker_out_offset;
946
+ }
947
+ if (rec.resume &&
948
+ typeof rec.resume === "object" &&
949
+ typeof rec.resume.session_id === "string") {
950
+ out.resume = { session_id: rec.resume.session_id };
951
+ }
952
+ else if (rec.resume === null) {
953
+ out.resume = null;
954
+ }
955
+ return out;
956
+ }
957
+ function inferTrigger(lane) {
958
+ if (lane.state === "awaiting_input")
959
+ return { kind: "answered_questions" };
960
+ return { kind: "admit" };
961
+ }
962
+ /**
963
+ * Bind the account, spawn the worker and its watcher outside the caller's
964
+ * tree, then CAS the record to `running`. A spawn that cannot be proven
965
+ * started and detached is a named terminal reason, never a bare `failed`.
966
+ *
967
+ * Fresh-vs-resume is decided by `planLaneLaunch` (delivery.ts), never here.
968
+ */
969
+ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
970
+ const env = deps.env ?? process.env;
971
+ const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
972
+ const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
973
+ const proveDetached = deps.proveDetached ?? defaultProveDetached;
974
+ const killProcess = deps.killProcess ?? defaultKillProcess;
975
+ const existing = readLane(hqRoot, lane.lane_id);
976
+ if (existing.state === "running") {
977
+ if (launch.trigger?.kind === "resume") {
978
+ throw new LanesError("lane_not_resumable", `Lane ${lane.lane_id} is already running`, { lane_id: lane.lane_id, state: existing.state });
979
+ }
980
+ return existing;
981
+ }
982
+ const trigger = launch.trigger ?? inferTrigger(existing);
983
+ // A message into a finished lane must not turn that lane `failed` when the
984
+ // account cannot be bound: nothing has started yet, so the record keeps its
985
+ // state and the caller sees the error. Admission and question resume keep
986
+ // today's behaviour of naming the terminal reason on the record.
987
+ const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
988
+ const plan = planLaneLaunch(existing, trigger);
989
+ if (existing.host.kind === "outpost") {
990
+ refuseOutpostSelfTarget(hqRoot, existing, deps);
991
+ }
992
+ const runDir = laneRunDir(hqRoot, lane.lane_id);
993
+ const logsDir = path.join(runDir, "logs");
994
+ ensureDir(logsDir);
995
+ ensureDir(path.join(runDir, "envelopes"));
996
+ const config = loadLanesConfig(hqRoot);
997
+ let resolved;
998
+ try {
999
+ resolved = resolveAccount(lane.provider, lane.account, config, {
1000
+ hqRoot,
1001
+ existsSync,
1002
+ model: lane.model,
1003
+ });
1004
+ }
1005
+ catch (err) {
1006
+ if (err instanceof AccountResolutionError) {
1007
+ if (markPreSpawnFailure) {
1008
+ markSpawnFailure(hqRoot, lane.lane_id, err.error, err.details);
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ process.stderr.write(`hq lanes: account bind failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
1013
+ throw err;
1014
+ }
1015
+ const status = accountStatus(lane.provider, resolved.id, config, { existsSync });
1016
+ if (isLoggedOut(status)) {
1017
+ const failed = markPreSpawnFailure
1018
+ ? markSpawnFailure(hqRoot, lane.lane_id, "account_not_logged_in", {
1019
+ provider: lane.provider,
1020
+ account: resolved.id,
1021
+ })
1022
+ : existing;
1023
+ throw new LanesError("account_not_logged_in", `Account ${resolved.id} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: resolved.id, lane_id: lane.lane_id, lane: failed });
1024
+ }
1025
+ if (lane.account !== resolved.id) {
1026
+ retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
1027
+ if (current.account === resolved.id)
1028
+ return current;
1029
+ return { ...current, account: resolved.id };
1030
+ });
1031
+ }
1032
+ try {
1033
+ installDrainHook(lane.provider, resolved.home);
1034
+ }
1035
+ catch (err) {
1036
+ process.stderr.write(`hq lanes: drain hook install failed for ${resolved.home}: ${err instanceof Error ? err.message : String(err)}\n`);
1037
+ throw err;
1038
+ }
1039
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot);
1040
+ const briefPath = path.join(runDir, "brief.md");
1041
+ const envelopePath = nextEnvelopePath(runDir);
1042
+ const lastMessagePath = path.join(logsDir, "last-message.txt");
1043
+ const answersPath = path.join(runDir, ANSWERED_QUESTIONS_FILE);
1044
+ const extraPrompt = existsSync(answersPath)
1045
+ ? `Previously answered questions are at ${answersPath}; treat those choices as operator decisions that outrank the brief.`
1046
+ : undefined;
1047
+ const accountCfg = config.providers[lane.provider].accounts[resolved.id];
1048
+ const model = resolveLaneModel({
1049
+ cliModel: lane.model,
1050
+ accountModel: accountCfg?.model,
1051
+ providerModel: config.providers[lane.provider].model,
1052
+ });
1053
+ const effort = resolveLaneEffort({
1054
+ cliEffort: existing.effort_source === "cli" ? existing.effort : undefined,
1055
+ accountEffort: accountCfg?.effort,
1056
+ providerEffort: config.providers[lane.provider].effort,
1057
+ model,
1058
+ });
1059
+ const resume = plan.mode === "resume"
1060
+ ? {
1061
+ session_id: plan.session_id,
1062
+ prompt: resumePrompt({
1063
+ envelopePath,
1064
+ operatorMessage: trigger.kind === "message"
1065
+ ? trigger.text
1066
+ : trigger.kind === "resume"
1067
+ ? trigger.text
1068
+ : undefined,
1069
+ resumedFrom: trigger.kind === "resume" ? trigger.from : undefined,
1070
+ answersPath: trigger.kind === "answered_questions" && existsSync(answersPath)
1071
+ ? answersPath
1072
+ : undefined,
1073
+ answers: trigger.kind === "answered_questions" && existsSync(answersPath)
1074
+ ? readAnsweredQuestionLines(answersPath)
1075
+ : undefined,
1076
+ }),
1077
+ }
1078
+ : undefined;
1079
+ const command = buildProviderCommand(lane.provider, {
1080
+ hqRoot,
1081
+ briefPath,
1082
+ envelopePath,
1083
+ lastMessagePath,
1084
+ extraPrompt,
1085
+ model,
1086
+ effort: effort.effort,
1087
+ resume,
1088
+ });
1089
+ // A systemd PATH mismatch is an environment defect, not a lane failure.
1090
+ // Keep the lane eligible for a later reconcile after the installer repairs it.
1091
+ if (!providerCommandOnPath(command.command, childEnv.PATH)) {
1092
+ const searchedPath = childEnv.PATH ?? "";
1093
+ // Only admission may re-queue. A resume (answered questions, operator
1094
+ // message) must keep the recorded state so the next reconcile does not
1095
+ // infer `admit` and start a fresh run in place of the recorded session;
1096
+ // the caller keeps the message text and sees the named error.
1097
+ if (trigger.kind !== "admit") {
1098
+ throw new LanesError("provider_binary_missing", `Provider binary ${command.command} for ${lane.provider} is not executable on PATH ${JSON.stringify(searchedPath)}; lane ${lane.lane_id} keeps state ${existing.state}`, {
1099
+ provider: lane.provider,
1100
+ command: command.command,
1101
+ searched_path: searchedPath,
1102
+ lane_id: lane.lane_id,
1103
+ state: existing.state,
1104
+ trigger: trigger.kind,
1105
+ });
1106
+ }
1107
+ return retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => ({
1108
+ ...current,
1109
+ state: "queued",
1110
+ queued_reason: "provider_binary_missing",
1111
+ terminal_reason: undefined,
1112
+ terminal_details: {
1113
+ provider: lane.provider,
1114
+ command: command.command,
1115
+ searched_path: searchedPath,
1116
+ },
1117
+ }));
1118
+ }
1119
+ // Resume-time compaction (compaction.ts). Runs before the claim so the
1120
+ // lane is never `running` without a worker while `/compact` works.
1121
+ let compaction;
1122
+ if (resume && launch.compact !== false) {
1123
+ const nowFn = deps.now ?? (() => new Date());
1124
+ const decision = compactionDue(existing, nowFn().getTime(), providerCompactAfterMs(config.providers[lane.provider]));
1125
+ if (decision.due) {
1126
+ const compactCommand = buildProviderCommand(lane.provider, {
1127
+ hqRoot,
1128
+ briefPath,
1129
+ envelopePath,
1130
+ lastMessagePath,
1131
+ model,
1132
+ effort: effort.effort,
1133
+ resume: { session_id: resume.session_id, prompt: COMPACTION_PROMPT },
1134
+ });
1135
+ compaction = compactLaneSession({
1136
+ command: compactCommand.command,
1137
+ args: compactCommand.args,
1138
+ sessionId: resume.session_id,
1139
+ accountHome: resolved.home,
1140
+ env: childEnv,
1141
+ cwd: hqRoot,
1142
+ logsDir,
1143
+ decision,
1144
+ before: existing.last_run_usage ?? null,
1145
+ now: nowFn,
1146
+ run: deps.runCompaction,
1147
+ locateTranscript: deps.locateTranscript,
1148
+ });
1149
+ }
1150
+ }
1151
+ // Claim running before spawning. Two callers that both observe queued must
1152
+ // not both start processes; the CAS loser returns without spawn.
1153
+ const claimable = claimableStatesFor(trigger);
1154
+ const nowIso = (deps.now ?? (() => new Date()))().toISOString();
1155
+ let claimed = false;
1156
+ const claimedRecord = retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
1157
+ claimed = false;
1158
+ if (current.state === "running" && current.account === resolved.id) {
1159
+ if (trigger.kind === "resume") {
1160
+ throw new LanesError("lane_not_resumable", `Lane ${current.lane_id} is already running`, { lane_id: current.lane_id, state: current.state });
1161
+ }
1162
+ return current;
1163
+ }
1164
+ if (!claimable.includes(current.state)) {
1165
+ throw new LanesError("spawn_state_mismatch", `Cannot mark ${current.lane_id} running from ${current.state}`, { lane_id: current.lane_id, state: current.state, trigger: trigger.kind });
1166
+ }
1167
+ claimed = true;
1168
+ const next = {
1169
+ ...current,
1170
+ state: "running",
1171
+ queued_reason: undefined,
1172
+ account: resolved.id,
1173
+ ...(model !== undefined ? { model } : {}),
1174
+ effort_source: effort.source,
1175
+ last_host_report_at: nowIso,
1176
+ };
1177
+ if (effort.effort !== undefined)
1178
+ next.effort = effort.effort;
1179
+ else
1180
+ delete next.effort;
1181
+ // Freeze the counters of the run that followed the previous compaction
1182
+ // before this launch replaces last_resume.
1183
+ if (current.last_compaction && current.last_compaction.after === null) {
1184
+ const after = compactionAfterUsage(current);
1185
+ if (after !== null)
1186
+ next.last_compaction = { ...current.last_compaction, after };
1187
+ }
1188
+ if (compaction)
1189
+ next.last_compaction = { ...compaction, resume_at: nowIso };
1190
+ if (resume && trigger.kind !== "admit") {
1191
+ next.last_resume = {
1192
+ session_id: resume.session_id,
1193
+ at: nowIso,
1194
+ trigger: trigger.kind,
1195
+ ...(trigger.kind === "message" ? { message_id: trigger.message_id } : {}),
1196
+ ...(trigger.kind === "resume"
1197
+ ? {
1198
+ resumed_from: trigger.from,
1199
+ ...(trigger.text !== undefined ? { text: trigger.text } : {}),
1200
+ ...(trigger.message_id !== undefined ? { message_id: trigger.message_id } : {}),
1201
+ }
1202
+ : {}),
1203
+ };
1204
+ }
1205
+ return next;
1206
+ });
1207
+ if (!claimed) {
1208
+ if (compaction) {
1209
+ const record = compaction;
1210
+ return retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => ({
1211
+ ...current,
1212
+ last_compaction: record,
1213
+ }));
1214
+ }
1215
+ return claimedRecord;
1216
+ }
1217
+ const workerOutPath = path.join(logsDir, "worker.out");
1218
+ const workerOutOffset = fileSizeOrZero(workerOutPath);
1219
+ let workerPid;
1220
+ let watcherPid;
1221
+ try {
1222
+ workerPid = spawnDetached(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"));
1223
+ writePidRecord(path.join(runDir, "worker.pid"), {
1224
+ pid: workerPid,
1225
+ starttime: readProcStarttime(workerPid) ?? undefined,
1226
+ });
1227
+ requireDetached(proveDetached, workerPid, "worker");
1228
+ watcherPid = armLaneWatcher(hqRoot, lane.lane_id, deps);
1229
+ writeSpawnManifest(runDir, {
1230
+ command: command.command,
1231
+ args: command.args,
1232
+ envVar: resolved.envVar,
1233
+ account: resolved.id,
1234
+ workerPid,
1235
+ watcherPid,
1236
+ envelopePath,
1237
+ resume: resume ? { session_id: resume.session_id } : null,
1238
+ workerOutOffset,
1239
+ });
1240
+ }
1241
+ catch (err) {
1242
+ if (workerPid !== undefined)
1243
+ killQuietly(killProcess, workerPid, "worker");
1244
+ if (watcherPid !== undefined)
1245
+ killQuietly(killProcess, watcherPid, "watcher");
1246
+ const reason = err instanceof LanesError &&
1247
+ SPAWN_TERMINAL_REASONS.includes(err.error)
1248
+ ? err.error
1249
+ : "spawn_failed";
1250
+ const failed = markSpawnFailure(hqRoot, lane.lane_id, reason, {
1251
+ message: err instanceof Error ? err.message : String(err),
1252
+ ...(err instanceof LanesError ? err.details : {}),
1253
+ });
1254
+ if (err instanceof LanesError) {
1255
+ throw new LanesError(err.error, err.message, {
1256
+ ...err.details,
1257
+ lane_id: lane.lane_id,
1258
+ lane: failed,
1259
+ });
1260
+ }
1261
+ throw err;
1262
+ }
1263
+ return readLane(hqRoot, lane.lane_id);
1264
+ }
1265
+ /**
1266
+ * `resume`: claude and codex continue the recorded session with the repair
1267
+ * prompt. `fresh_turn`: grok has no session resume, so the repair is a new
1268
+ * single-turn invocation of the same provider on the same account, whose
1269
+ * prompt carries the schema, the parser error and the raw failed output.
1270
+ */
1271
+ export function envelopeRepairMode(provider) {
1272
+ return providerSupportsSessionResume(provider) ? "resume" : "fresh_turn";
1273
+ }
1274
+ /**
1275
+ * Spec §4 repair turn. The lane is still `running` (the watcher has not
1276
+ * written a terminal state), so this does not claim or transition the
1277
+ * record. It resumes the recorded provider session with the repair prompt
1278
+ * (`resume` mode), or for a provider with no session resume runs the repair
1279
+ * prompt as a fresh single turn on the same account (`fresh_turn` mode). It
1280
+ * replaces `worker.pid` and rewrites `spawn.json` so the watcher's next tick
1281
+ * follows the new process. The account is the one already bound on the record.
1282
+ */
1283
+ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
1284
+ const env = deps.env ?? process.env;
1285
+ const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
1286
+ const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
1287
+ const proveDetached = deps.proveDetached ?? defaultProveDetached;
1288
+ const killProcess = deps.killProcess ?? defaultKillProcess;
1289
+ const mode = envelopeRepairMode(lane.provider);
1290
+ const sessionId = input.session_id ?? null;
1291
+ if (mode === "resume" && sessionId === null) {
1292
+ throw new LanesError("repair_session_missing", `Provider ${lane.provider} repairs by resuming its session, but no session id was given`, { provider: lane.provider });
1293
+ }
1294
+ const runDir = laneRunDir(hqRoot, lane.lane_id);
1295
+ const logsDir = path.join(runDir, "logs");
1296
+ ensureDir(logsDir);
1297
+ const config = loadLanesConfig(hqRoot);
1298
+ const resolved = resolveAccount(lane.provider, lane.account, config, {
1299
+ hqRoot,
1300
+ existsSync,
1301
+ model: lane.model,
1302
+ });
1303
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot);
1304
+ const accountCfg = config.providers[lane.provider].accounts[resolved.id];
1305
+ const model = resolveLaneModel({
1306
+ cliModel: lane.model,
1307
+ accountModel: accountCfg?.model,
1308
+ providerModel: config.providers[lane.provider].model,
1309
+ });
1310
+ const effort = resolveLaneEffort({
1311
+ cliEffort: lane.effort_source === "cli" ? lane.effort : undefined,
1312
+ accountEffort: accountCfg?.effort,
1313
+ providerEffort: config.providers[lane.provider].effort,
1314
+ model,
1315
+ });
1316
+ const repairPrompt = envelopeRepairPrompt({
1317
+ envelopePath: input.envelopePath,
1318
+ error: input.error,
1319
+ rawText: input.rawText,
1320
+ });
1321
+ const command = buildProviderCommand(lane.provider, {
1322
+ hqRoot,
1323
+ briefPath: path.join(runDir, "brief.md"),
1324
+ envelopePath: input.envelopePath,
1325
+ lastMessagePath: path.join(logsDir, "last-message.txt"),
1326
+ model,
1327
+ effort: effort.effort,
1328
+ ...(mode === "resume" && sessionId !== null
1329
+ ? { resume: { session_id: sessionId, prompt: repairPrompt } }
1330
+ : { freshTurnPrompt: repairPrompt }),
1331
+ });
1332
+ const workerOutOffset = fileSizeOrZero(path.join(logsDir, "worker.out"));
1333
+ const previous = readSpawnManifest(runDir);
1334
+ let workerPid;
1335
+ try {
1336
+ workerPid = spawnDetached(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"));
1337
+ writePidRecord(path.join(runDir, "worker.pid"), {
1338
+ pid: workerPid,
1339
+ starttime: readProcStarttime(workerPid) ?? undefined,
1340
+ });
1341
+ requireDetached(proveDetached, workerPid, "worker");
1342
+ const watcherPid = readPidRecord(path.join(runDir, "watcher.pid"))?.pid ?? 0;
1343
+ writeSpawnManifest(runDir, {
1344
+ command: command.command,
1345
+ args: command.args,
1346
+ envVar: resolved.envVar,
1347
+ account: resolved.id,
1348
+ workerPid,
1349
+ watcherPid,
1350
+ envelopePath: previous.envelope_path ?? input.envelopePath,
1351
+ resume: mode === "resume" && sessionId !== null ? { session_id: sessionId } : null,
1352
+ workerOutOffset,
1353
+ });
1354
+ }
1355
+ catch (err) {
1356
+ if (workerPid !== undefined)
1357
+ killQuietly(killProcess, workerPid, "worker");
1358
+ throw err;
1359
+ }
1360
+ return workerPid;
1361
+ }
1362
+ export { PROVIDER_HOME_ENV, PROVIDER_COMMAND };
1363
+ //# sourceMappingURL=spawn.js.map