@indigoai-us/hq-cli 5.214.0 → 5.216.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.
@@ -35,7 +35,7 @@ import * as fs from "node:fs";
35
35
  import * as path from "node:path";
36
36
  import { LanesError } from "./errors.js";
37
37
  import { raiseEnvelopeQuestions } from "./questions.js";
38
- import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, } from "./types.js";
38
+ import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
39
39
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
40
40
  import { readLane, retryUpdateLane } from "./store.js";
41
41
  import { readWorkerRunResult } from "./delivery.js";
@@ -44,7 +44,7 @@ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, provide
44
44
  import { readToolActivity, toolActivityPath } from "./dropbox.js";
45
45
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
46
46
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
47
- import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, progressEventDue, } from "./mesh-events.js";
47
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
48
48
  import { envelopeQuestionId } from "./questions.js";
49
49
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
50
50
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
@@ -313,7 +313,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
313
313
  classification.state === "awaiting_input" &&
314
314
  opts.envelope &&
315
315
  (opts.envelope.questions?.length ?? 0) > 0) {
316
- raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at);
316
+ raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes);
317
317
  }
318
318
  let changed = false;
319
319
  const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
@@ -336,6 +336,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
336
336
  stalled: undefined,
337
337
  stalled_at: undefined,
338
338
  stalled_reason: undefined,
339
+ envelope_validation_errors: opts.envelopeValidationErrors,
339
340
  };
340
341
  changed = true;
341
342
  if (classification.reason) {
@@ -432,6 +433,81 @@ function fileNonEmpty(filePath) {
432
433
  throw err;
433
434
  }
434
435
  }
436
+ function isPlainObject(value) {
437
+ return value !== null && typeof value === "object" && !Array.isArray(value);
438
+ }
439
+ /** Keep only questions that the normal envelope parser accepts. */
440
+ function recoverAskEnvelope(text, validationErrors) {
441
+ const raw = JSON.parse(text);
442
+ if (!isPlainObject(raw) || raw.decision !== "ask" || !Array.isArray(raw.questions)) {
443
+ return null;
444
+ }
445
+ const validQuestions = [];
446
+ const questionIndexes = [];
447
+ const questionErrors = [];
448
+ for (let index = 0; index < raw.questions.length; index += 1) {
449
+ const question = raw.questions[index];
450
+ try {
451
+ const parsed = parseLaneEnvelope({
452
+ schema_version: "hq-lane-envelope.v1",
453
+ decision: "ask",
454
+ summary: "Recovered operator question",
455
+ story_status: "in_progress",
456
+ artifacts: [],
457
+ questions: [question],
458
+ });
459
+ const item = parsed.questions?.[0];
460
+ if (item === undefined) {
461
+ throw new Error("question parser returned no question");
462
+ }
463
+ validQuestions.push(item);
464
+ questionIndexes.push(index);
465
+ }
466
+ catch (err) {
467
+ questionErrors.push(`questions[${index}] is invalid: ${err instanceof Error ? err.message : String(err)}`);
468
+ }
469
+ }
470
+ if (validQuestions.length === 0) {
471
+ return {
472
+ envelope: {
473
+ schema_version: "hq-lane-envelope.v1",
474
+ decision: "ask",
475
+ summary: "The worker needs operator input after envelope validation failed.",
476
+ story_status: STORY_STATUSES.includes(raw.story_status)
477
+ ? raw.story_status
478
+ : "in_progress",
479
+ artifacts: [],
480
+ questions: [],
481
+ },
482
+ questionIndexes,
483
+ questionErrors,
484
+ sourceQuestionCount: raw.questions.length,
485
+ };
486
+ }
487
+ const contextErrors = [...new Set([...validationErrors, ...questionErrors])];
488
+ const storyStatus = STORY_STATUSES.includes(raw.story_status) && raw.story_status !== "done"
489
+ ? raw.story_status
490
+ : "in_progress";
491
+ const questions = validQuestions.map((question) => ({
492
+ ...question,
493
+ context: `${question.context}\n\nEnvelope validation errors:\n${contextErrors.map((error) => `- ${error}`).join("\n")}`,
494
+ }));
495
+ return {
496
+ envelope: {
497
+ schema_version: "hq-lane-envelope.v1",
498
+ decision: "ask",
499
+ summary: typeof raw.summary === "string" && raw.summary.length > 0
500
+ ? raw.summary
501
+ : "The worker needs operator input after envelope validation failed.",
502
+ story_status: storyStatus,
503
+ artifacts: [],
504
+ questions,
505
+ },
506
+ questionIndexes,
507
+ questionErrors,
508
+ sourceQuestionCount: raw.questions.length,
509
+ };
510
+ }
435
511
  export function readLatestEnvelope(hqRoot, laneId, minName) {
436
512
  return readLatestEnvelopeRecord(hqRoot, laneId, minName)?.envelope ?? null;
437
513
  }
@@ -570,8 +646,8 @@ function readLaneSoft(hqRoot, laneId) {
570
646
  }
571
647
  }
572
648
  /** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
573
- export function envelopeQuestionIds(laneId, envelopeRef, envelope) {
574
- return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, i));
649
+ export function envelopeQuestionIds(laneId, envelopeRef, envelope, questionIndexes) {
650
+ return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, questionIndexes?.[i] ?? i));
575
651
  }
576
652
  function defaultSleep(ms) {
577
653
  return new Promise((resolve) => {
@@ -604,6 +680,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
604
680
  const progressFiles = [...logFiles, ...activityFiles];
605
681
  // Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
606
682
  const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
683
+ const storyStatusSuppressions = new Set();
607
684
  const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
608
685
  const initialLane = readLaneSoft(hqRoot, laneId);
609
686
  if (mesh && initialLane) {
@@ -631,6 +708,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
631
708
  }
632
709
  catch (err) {
633
710
  const message = err instanceof Error ? err.message : String(err);
711
+ if (company && initialLane.story_id &&
712
+ err instanceof LanesError && err.error === "story_not_on_board") {
713
+ storyStatusSuppressions.add(laneStoryStatusSuppressionKey(company, initialLane.project_id, initialLane.story_id));
714
+ }
634
715
  process.stderr.write(`hq lanes watcher: mesh thread ensure failed for ${laneId}: ${message}\n`);
635
716
  writeWatcherDecision(outPath, {
636
717
  event: "mesh_event_failed",
@@ -701,10 +782,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
701
782
  const missingExpected = !processAlive && latestFile === null
702
783
  ? missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)
703
784
  : null;
704
- const latest = latestFile && latestFile.envelope
785
+ let latest = latestFile && latestFile.envelope
705
786
  ? { envelope: latestFile.envelope, ref: latestFile.ref }
706
787
  : null;
707
- const envelope = latest?.envelope ?? null;
788
+ let envelope = latest?.envelope ?? null;
708
789
  if (latest && latest.ref !== lastEnvelopeRef) {
709
790
  logDecision({
710
791
  event: "envelope_observed",
@@ -751,6 +832,9 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
751
832
  // fails the same way, and would end the lane envelope_invalid without
752
833
  // ever recording the hold. The quota result applies immediately instead.
753
834
  let invalidEnvelope = null;
835
+ let envelopeValidationErrors;
836
+ let questionIndexes;
837
+ let recoveredAsk = false;
754
838
  const repairCandidate = latestFile ?? missingExpected;
755
839
  if (!processAlive &&
756
840
  !envelope &&
@@ -771,11 +855,70 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
771
855
  await sleep(pollMs);
772
856
  continue;
773
857
  }
774
- if (!repairCandidate.missing)
775
- invalidEnvelope = { errors: repair.errors };
858
+ if (!repairCandidate.missing) {
859
+ const validationErrors = repair.errors.filter((error) => !error.startsWith("repair_spawn_failed:"));
860
+ let recovery = null;
861
+ try {
862
+ recovery = recoverAskEnvelope(repairCandidate.text, validationErrors);
863
+ }
864
+ catch (err) {
865
+ const message = err instanceof Error ? err.message : String(err);
866
+ process.stderr.write(`hq lanes watcher: invalid ask fallback could not parse ${repairCandidate.path} for ${laneId}: ${message}\n`);
867
+ logDecision({
868
+ event: "envelope_repair",
869
+ at: new Date(now()).toISOString(),
870
+ outcome: "ask_fallback_skipped",
871
+ reason: "invalid_json",
872
+ error: message,
873
+ validation_errors: validationErrors,
874
+ });
875
+ }
876
+ if (recovery !== null && (recovery.envelope.questions?.length ?? 0) > 0) {
877
+ const errors = [...new Set([...validationErrors, ...recovery.questionErrors])];
878
+ envelopeValidationErrors = errors;
879
+ questionIndexes = recovery.questionIndexes;
880
+ latest = { envelope: recovery.envelope, ref: repairCandidate.ref };
881
+ envelope = recovery.envelope;
882
+ recoveredAsk = true;
883
+ const message = `hq lanes watcher: routing invalid ask envelope for ${laneId} to awaiting_input ` +
884
+ `with ${recovery.envelope.questions?.length ?? 0} validated question(s); errors: ${errors.join("; ")}`;
885
+ process.stderr.write(`${message}\n`);
886
+ logDecision({
887
+ event: "envelope_repair",
888
+ at: new Date(now()).toISOString(),
889
+ outcome: "ask_fallback_routed",
890
+ state: "awaiting_input",
891
+ questions: recovery.envelope.questions?.length ?? 0,
892
+ validation_errors: errors,
893
+ });
894
+ }
895
+ else {
896
+ const errors = recovery === null
897
+ ? repair.errors
898
+ : [...new Set([...repair.errors, ...recovery.questionErrors])];
899
+ invalidEnvelope = { errors };
900
+ if (recovery !== null) {
901
+ const message = `hq lanes watcher: invalid ask envelope for ${laneId} remains failed; ` +
902
+ `0 of ${recovery.sourceQuestionCount} questions passed validation`;
903
+ process.stderr.write(`${message}\n`);
904
+ logDecision({
905
+ event: "envelope_repair",
906
+ at: new Date(now()).toISOString(),
907
+ outcome: "ask_fallback_skipped",
908
+ reason: "no_valid_questions",
909
+ questions: 0,
910
+ validation_errors: errors,
911
+ });
912
+ }
913
+ }
914
+ }
776
915
  }
777
916
  }
778
- if (processAlive === false && envelope && latestFile) {
917
+ if (processAlive === false &&
918
+ envelope &&
919
+ latestFile !== null &&
920
+ latestFile.envelope !== null &&
921
+ !recoveredAsk) {
779
922
  markRepairSucceeded(hqRoot, laneId, latestFile.ref);
780
923
  }
781
924
  const tick = tickWatcher({
@@ -913,6 +1056,8 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
913
1056
  const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
914
1057
  envelope,
915
1058
  envelopeRef: latest?.ref,
1059
+ envelopeValidationErrors,
1060
+ questionIndexes,
916
1061
  at: tick.emit.at,
917
1062
  quota,
918
1063
  providerRejection,
@@ -930,9 +1075,13 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
930
1075
  if (mesh && terminalWrite.changed) {
931
1076
  if (envelope && latest) {
932
1077
  const questionIds = envelope.decision === "ask"
933
- ? envelopeQuestionIds(laneId, latest.ref, envelope)
1078
+ ? envelopeQuestionIds(laneId, latest.ref, envelope, questionIndexes)
934
1079
  : undefined;
935
- await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
1080
+ await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, {
1081
+ questionIds,
1082
+ signal,
1083
+ storyStatusSuppressions,
1084
+ }), meshRequestTimeoutMs);
936
1085
  }
937
1086
  else if (updated.state === "failed" || updated.state === "interrupted" ||
938
1087
  updated.state === "cancelled" || updated.state === "killed") {
@@ -973,9 +1122,11 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
973
1122
  const runDir = laneRunDir(hqRoot, laneId);
974
1123
  const manifest = readSpawnManifest(runDir);
975
1124
  const prior = lane.envelope_repair;
976
- const repairRef = missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)?.ref ??
977
- prior?.envelope_ref ??
978
- latest.ref;
1125
+ // A resumed worker can write its next envelope before spawn.json points to
1126
+ // that slot. Use the newest of the manifest's expected slot and the actual
1127
+ // latest file so an older manifest or repair record cannot spend its budget.
1128
+ const expectedRef = missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)?.ref;
1129
+ const repairRef = expectedRef !== undefined && expectedRef > latest.ref ? expectedRef : latest.ref;
979
1130
  const priorAppliesToSlot = prior?.envelope_ref === undefined || prior.envelope_ref === repairRef;
980
1131
  if (prior && prior.attempted && priorAppliesToSlot) {
981
1132
  // The repair turn ran and its output does not parse either.
@@ -1025,6 +1176,14 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
1025
1176
  error,
1026
1177
  rawText: latest.text,
1027
1178
  missing: latest.missing,
1179
+ onProfileWarning: (fields) => {
1180
+ logDecision({
1181
+ event: "envelope_repair",
1182
+ at,
1183
+ outcome: "profile_unknown_meta_fields_ignored",
1184
+ fields: [...fields],
1185
+ });
1186
+ },
1028
1187
  });
1029
1188
  logDecision({ event: "envelope_repair", at, outcome: "spawned", mode, pid, session_id: sessionId, error });
1030
1189
  return { action: "spawned" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.214.0",
3
+ "version": "5.216.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {