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