@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.
- package/CHANGELOG.md +25 -0
- package/dist/command-catalog.generated.d.ts +22 -0
- package/dist/command-catalog.generated.js +29 -0
- package/dist/commands/lanes.js +106 -64
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +9 -0
- package/dist/lib/lanes/keep-alive.js +101 -0
- package/dist/lib/lanes/mesh-events.d.ts +3 -0
- package/dist/lib/lanes/mesh-events.js +32 -3
- package/dist/lib/lanes/profile.d.ts +8 -4
- package/dist/lib/lanes/profile.js +13 -17
- package/dist/lib/lanes/questions.d.ts +1 -1
- package/dist/lib/lanes/questions.js +2 -2
- package/dist/lib/lanes/reconcile.js +126 -40
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +6 -1
- package/dist/lib/lanes/store.js +25 -0
- package/dist/lib/lanes/types.d.ts +6 -0
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.d.ts +3 -1
- package/dist/lib/lanes/watcher.js +174 -15
- package/package.json +1 -1
|
@@ -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
|
-
|
|
785
|
+
let latest = latestFile && latestFile.envelope
|
|
705
786
|
? { envelope: latestFile.envelope, ref: latestFile.ref }
|
|
706
787
|
: null;
|
|
707
|
-
|
|
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
|
-
|
|
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 &&
|
|
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, {
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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" };
|