@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.
- package/CHANGELOG.md +23 -0
- package/dist/commands/lanes.js +39 -53
- 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 +1 -0
- package/dist/lib/lanes/keep-alive.js +1 -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 +5 -1
- package/dist/lib/lanes/profile.js +9 -6
- package/dist/lib/lanes/questions.d.ts +1 -1
- package/dist/lib/lanes/questions.js +2 -2
- package/dist/lib/lanes/reconcile.d.ts +2 -0
- package/dist/lib/lanes/reconcile.js +234 -77
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +6 -1
- package/dist/lib/lanes/store.js +8 -0
- package/dist/lib/lanes/types.d.ts +5 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.d.ts +3 -1
- package/dist/lib/lanes/watcher.js +175 -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,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
|
-
|
|
786
|
+
let latest = latestFile && latestFile.envelope
|
|
705
787
|
? { envelope: latestFile.envelope, ref: latestFile.ref }
|
|
706
788
|
: null;
|
|
707
|
-
|
|
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
|
-
|
|
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 &&
|
|
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, {
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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" };
|