@indigoai-us/hq-cli 5.217.0 → 5.219.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 +28 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/lanes-checks.d.ts +13 -0
- package/dist/commands/lanes-checks.js +558 -0
- package/dist/commands/lanes.js +160 -44
- package/dist/lib/lanes/config.d.ts +2 -1
- package/dist/lib/lanes/config.js +30 -0
- package/dist/lib/lanes/questions.d.ts +13 -7
- package/dist/lib/lanes/questions.js +78 -18
- package/dist/lib/lanes/reconcile.d.ts +8 -1
- package/dist/lib/lanes/reconcile.js +51 -5
- package/dist/lib/lanes/spawn.js +28 -16
- package/dist/lib/lanes/store.js +15 -1
- package/dist/lib/lanes/tracker-checks.d.ts +77 -0
- package/dist/lib/lanes/tracker-checks.js +134 -0
- package/dist/lib/lanes/types.d.ts +15 -0
- package/dist/lib/lanes/watcher-main.js +17 -1
- package/dist/lib/lanes/watcher.d.ts +4 -1
- package/dist/lib/lanes/watcher.js +30 -7
- package/package.json +1 -1
|
@@ -7,8 +7,28 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { LanesError } from "./errors.js";
|
|
10
|
+
import { assertNoSecretText } from "./text-scan.js";
|
|
10
11
|
import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
|
|
11
12
|
import { QUESTION_BODY_STORES, } from "./types.js";
|
|
13
|
+
export function assertQuestionBodySecretSafe(body) {
|
|
14
|
+
assertNoSecretText("question", body.question);
|
|
15
|
+
assertNoSecretText("context", body.context);
|
|
16
|
+
body.options.forEach((option, index) => assertNoSecretText(`option[${index}]`, option));
|
|
17
|
+
if (body.recommended !== undefined) {
|
|
18
|
+
assertNoSecretText("recommended", body.recommended);
|
|
19
|
+
}
|
|
20
|
+
if (body.notes !== undefined)
|
|
21
|
+
assertNoSecretText("answer notes", body.notes);
|
|
22
|
+
}
|
|
23
|
+
/** Errors that make an envelope ask unsafe or impossible to persist. */
|
|
24
|
+
export function questionRecordRefusalCode(err) {
|
|
25
|
+
if (!(err instanceof LanesError))
|
|
26
|
+
return null;
|
|
27
|
+
if (err.error === "secret_text_refused" || err.error === "question_company_unavailable") {
|
|
28
|
+
return err.error;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
12
32
|
export function parseQuestionBodyStore(value) {
|
|
13
33
|
if (typeof value === "string" &&
|
|
14
34
|
QUESTION_BODY_STORES.includes(value)) {
|
|
@@ -16,8 +36,15 @@ export function parseQuestionBodyStore(value) {
|
|
|
16
36
|
}
|
|
17
37
|
throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
|
|
18
38
|
}
|
|
19
|
-
export function bodyStoreForLane(lane) {
|
|
20
|
-
return lane.company ? "company" : "personal";
|
|
39
|
+
export function bodyStoreForLane(lane, resolvedCompany) {
|
|
40
|
+
return lane.company || lane.mesh_company || resolvedCompany ? "company" : "personal";
|
|
41
|
+
}
|
|
42
|
+
export function resolveQuestionCompany(lane, activeCompany) {
|
|
43
|
+
const company = lane.company ?? lane.mesh_company ?? activeCompany;
|
|
44
|
+
if (!company || !company.slug.trim() || !company.uid.trim()) {
|
|
45
|
+
throw new LanesError("question_company_unavailable", `Cannot raise a question for lane ${lane.lane_id}: no lane, project, or active company could be resolved`, { lane_id: lane.lane_id });
|
|
46
|
+
}
|
|
47
|
+
return company;
|
|
21
48
|
}
|
|
22
49
|
export function questionBodyRef(questionId) {
|
|
23
50
|
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
@@ -37,6 +64,7 @@ export function questionBodyAbsPath(hqRoot, question) {
|
|
|
37
64
|
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
38
65
|
}
|
|
39
66
|
export function writeQuestionBody(hqRoot, question, body) {
|
|
67
|
+
assertQuestionBodySecretSafe(body);
|
|
40
68
|
const store = parseQuestionBodyStore(question.body.store);
|
|
41
69
|
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
42
70
|
const serialized = `${JSON.stringify(body, null, 2)}\n`;
|
|
@@ -53,12 +81,14 @@ export function writeQuestionBody(hqRoot, question, body) {
|
|
|
53
81
|
* tenant body, then CAS routing_history with reason `asked`.
|
|
54
82
|
*/
|
|
55
83
|
export function raiseLaneQuestion(input) {
|
|
56
|
-
|
|
84
|
+
assertQuestionBodySecretSafe(input.body);
|
|
85
|
+
const company = resolveQuestionCompany(input.lane, input.activeCompany);
|
|
86
|
+
const store = bodyStoreForLane(input.lane, company);
|
|
57
87
|
let questionId = input.questionId;
|
|
58
88
|
if (!questionId) {
|
|
59
89
|
const created = createQuestion(input.hqRoot, {
|
|
60
90
|
lane_id: input.lane.lane_id,
|
|
61
|
-
company
|
|
91
|
+
company,
|
|
62
92
|
project_id: input.lane.project_id,
|
|
63
93
|
story_id: input.lane.story_id,
|
|
64
94
|
thread_id: input.lane.thread_id,
|
|
@@ -77,7 +107,7 @@ export function raiseLaneQuestion(input) {
|
|
|
77
107
|
createQuestion(input.hqRoot, {
|
|
78
108
|
question_id: questionId,
|
|
79
109
|
lane_id: input.lane.lane_id,
|
|
80
|
-
company
|
|
110
|
+
company,
|
|
81
111
|
project_id: input.lane.project_id,
|
|
82
112
|
story_id: input.lane.story_id,
|
|
83
113
|
thread_id: input.lane.thread_id,
|
|
@@ -97,7 +127,7 @@ export function raiseLaneQuestion(input) {
|
|
|
97
127
|
}
|
|
98
128
|
}
|
|
99
129
|
const stored = writeQuestionBody(input.hqRoot, {
|
|
100
|
-
company
|
|
130
|
+
company,
|
|
101
131
|
question_id: questionId,
|
|
102
132
|
body: { store, ref: "", digest: "" },
|
|
103
133
|
}, input.body);
|
|
@@ -149,34 +179,40 @@ export function questionBodyPathForLane(hqRoot, lane, questionId) {
|
|
|
149
179
|
* Free-text notes the operator attached with `questions answer --notes`.
|
|
150
180
|
* Read from the lane's own store (spec §6.5): the company store for a company
|
|
151
181
|
* lane, the personal store otherwise. Returns undefined when the answer has
|
|
152
|
-
* no notes_ref or the body carries no notes
|
|
153
|
-
*
|
|
182
|
+
* no notes_ref or the body carries no notes. An unavailable or malformed body
|
|
183
|
+
* fails the resume so the lane cannot continue without its answer notes.
|
|
154
184
|
*/
|
|
155
185
|
export function readAnswerNotes(hqRoot, lane, question) {
|
|
156
186
|
if (!question.answer?.notes_ref)
|
|
157
187
|
return undefined;
|
|
158
|
-
const
|
|
188
|
+
const bodyStore = parseQuestionBodyStore(question.body.store);
|
|
189
|
+
const company = question.company ?? (bodyStore === "company" ? lane.company ?? lane.mesh_company ?? null : null);
|
|
190
|
+
if (bodyStore === "personal" && company !== question.company) {
|
|
191
|
+
throw new LanesError("question_answer_notes_store_mismatch", `Answer notes for question ${question.question_id} cannot use lane company attribution for a personal-store body`, { question_id: question.question_id });
|
|
192
|
+
}
|
|
193
|
+
const filePath = questionBodyAbsPath(hqRoot, { ...question, company });
|
|
159
194
|
let text;
|
|
160
195
|
try {
|
|
161
196
|
text = fs.readFileSync(filePath, "utf8");
|
|
162
197
|
}
|
|
163
198
|
catch (err) {
|
|
164
|
-
|
|
165
|
-
process.stderr.write(`hq lanes: answer notes for ${question.question_id} unreadable at ${filePath} (${code ?? (err instanceof Error ? err.message : String(err))})\n`);
|
|
166
|
-
return undefined;
|
|
199
|
+
throw new LanesError("question_answer_notes_unavailable", `Answer notes for question ${question.question_id} are unavailable on the owning host`, { question_id: question.question_id, code: err.code });
|
|
167
200
|
}
|
|
168
201
|
let parsed;
|
|
169
202
|
try {
|
|
170
203
|
parsed = JSON.parse(text);
|
|
171
204
|
}
|
|
172
205
|
catch (err) {
|
|
173
|
-
|
|
174
|
-
|
|
206
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are not valid JSON`, { question_id: question.question_id });
|
|
207
|
+
}
|
|
208
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
209
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} have an invalid body`, { question_id: question.question_id });
|
|
175
210
|
}
|
|
176
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
177
|
-
return undefined;
|
|
178
211
|
const notes = parsed.notes;
|
|
179
|
-
|
|
212
|
+
if (typeof notes !== "string") {
|
|
213
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are missing from the body`, { question_id: question.question_id });
|
|
214
|
+
}
|
|
215
|
+
return notes.length > 0 ? notes : undefined;
|
|
180
216
|
}
|
|
181
217
|
/**
|
|
182
218
|
* The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
|
|
@@ -200,6 +236,22 @@ export function answeredQuestionEntries(hqRoot, lane, questions) {
|
|
|
200
236
|
return entry;
|
|
201
237
|
});
|
|
202
238
|
}
|
|
239
|
+
export function markAnsweredQuestionsDelivered(hqRoot, laneId, questionIds, deliveredAt) {
|
|
240
|
+
if (!Number.isFinite(Date.parse(deliveredAt))) {
|
|
241
|
+
throw new LanesError("question_delivery_timestamp_invalid", "Cannot mark an answer delivered with an invalid timestamp", { lane_id: laneId });
|
|
242
|
+
}
|
|
243
|
+
return [...new Set(questionIds)].map((questionId) => retryUpdateQuestion(hqRoot, questionId, (current) => {
|
|
244
|
+
if (current.lane_id !== laneId || current.origin.id !== laneId) {
|
|
245
|
+
throw new LanesError("question_lane_mismatch", `Question ${questionId} does not belong to the resumed lane ${laneId}`, { lane_id: laneId, question_id: questionId });
|
|
246
|
+
}
|
|
247
|
+
if (current.status !== "answered" || !current.answer) {
|
|
248
|
+
throw new LanesError("question_not_answered", `Question ${questionId} is not answered and cannot be marked delivered`, { lane_id: laneId, question_id: questionId });
|
|
249
|
+
}
|
|
250
|
+
if (current.delivered_at)
|
|
251
|
+
return current;
|
|
252
|
+
return { ...current, delivered_at: deliveredAt };
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
203
255
|
export function envelopeQuestionId(laneId, envelopeRef, index) {
|
|
204
256
|
const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
|
|
205
257
|
const digest = createHash("sha256")
|
|
@@ -222,8 +274,15 @@ function envelopeQuestionBody(item) {
|
|
|
222
274
|
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
223
275
|
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
224
276
|
*/
|
|
225
|
-
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
|
|
277
|
+
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes, activeCompany) {
|
|
226
278
|
const items = envelope.questions ?? [];
|
|
279
|
+
const presentItems = items.filter((item) => item !== undefined);
|
|
280
|
+
if (presentItems.length === 0)
|
|
281
|
+
return [];
|
|
282
|
+
for (const item of presentItems) {
|
|
283
|
+
assertQuestionBodySecretSafe(envelopeQuestionBody(item));
|
|
284
|
+
}
|
|
285
|
+
resolveQuestionCompany(lane, activeCompany);
|
|
227
286
|
const raised = [];
|
|
228
287
|
for (let i = 0; i < items.length; i += 1) {
|
|
229
288
|
const item = items[i];
|
|
@@ -236,6 +295,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at,
|
|
|
236
295
|
raisedVia: "envelope",
|
|
237
296
|
at,
|
|
238
297
|
questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
|
|
298
|
+
activeCompany,
|
|
239
299
|
}));
|
|
240
300
|
}
|
|
241
301
|
return raised;
|
|
@@ -9,7 +9,8 @@ import { type LivenessDeps } from "./liveness.js";
|
|
|
9
9
|
import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
|
|
10
10
|
import { type ThrottleDeps } from "./throttle.js";
|
|
11
11
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
12
|
-
import
|
|
12
|
+
import { type KeepAliveBlockedReason } from "./keep-alive.js";
|
|
13
|
+
import type { LaneCompanyRef, LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
|
|
13
14
|
import { type LaneMesh } from "./mesh-events.js";
|
|
14
15
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
15
16
|
export declare const QUESTION_AGE_MS: number;
|
|
@@ -49,6 +50,10 @@ export interface ReconcileSummary {
|
|
|
49
50
|
dead_host: ReconcileChange[];
|
|
50
51
|
heartbeats: string[];
|
|
51
52
|
skipped: ReconcileSkip[];
|
|
53
|
+
keep_alive_blocked: Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
reason: KeepAliveBlockedReason;
|
|
56
|
+
}>;
|
|
52
57
|
errors: Array<{
|
|
53
58
|
message: string;
|
|
54
59
|
code?: string;
|
|
@@ -74,6 +79,8 @@ export interface ReconcileDeps {
|
|
|
74
79
|
env?: NodeJS.ProcessEnv;
|
|
75
80
|
/** Flag reader seam used to assert rollout reads happen before admission locks. */
|
|
76
81
|
readRegistryFlag?: typeof readLanesRegistryFlag;
|
|
82
|
+
/** Active-company fallback for questions raised from an unscoped lane. */
|
|
83
|
+
resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
|
|
77
84
|
/** Detached mesh seam for dead-watcher recovery. */
|
|
78
85
|
mesh?: LaneMesh | null;
|
|
79
86
|
/** Bound on one terminal mesh emit during dead-host recovery. Default 10 s. */
|
|
@@ -24,7 +24,7 @@ import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
|
24
24
|
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
|
|
25
25
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
26
26
|
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
27
|
-
import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
|
|
27
|
+
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, } from "./questions.js";
|
|
28
28
|
import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
29
29
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
30
30
|
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
@@ -66,6 +66,7 @@ function emptySummary(dryRun) {
|
|
|
66
66
|
dead_host: [],
|
|
67
67
|
heartbeats: [],
|
|
68
68
|
skipped: [],
|
|
69
|
+
keep_alive_blocked: [],
|
|
69
70
|
errors: [],
|
|
70
71
|
heartbeat_at: null,
|
|
71
72
|
};
|
|
@@ -1009,9 +1010,10 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1009
1010
|
if (!repairCandidate.missing)
|
|
1010
1011
|
invalidEnvelope = { errors: repair.errors };
|
|
1011
1012
|
}
|
|
1012
|
-
|
|
1013
|
+
let repairedClassification = invalidEnvelope
|
|
1013
1014
|
? classifyLaneExit({ envelope: null, hasPartialOutput: false, invalidEnvelope })
|
|
1014
1015
|
: classification;
|
|
1016
|
+
let questionRefusalCode = null;
|
|
1015
1017
|
const accountRetryReason = quota
|
|
1016
1018
|
? "usage_limit"
|
|
1017
1019
|
: providerRejection && classification.reason === "account_not_logged_in"
|
|
@@ -1051,7 +1053,23 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1051
1053
|
classification.state === "awaiting_input" &&
|
|
1052
1054
|
latest &&
|
|
1053
1055
|
(latest.envelope.questions?.length ?? 0) > 0) {
|
|
1054
|
-
|
|
1056
|
+
const questionLane = readLane(hqRoot, lane.lane_id);
|
|
1057
|
+
const activeCompany = questionLane.company || questionLane.mesh_company
|
|
1058
|
+
? undefined
|
|
1059
|
+
: await deps.resolveQuestionActiveCompany?.();
|
|
1060
|
+
try {
|
|
1061
|
+
raiseEnvelopeQuestions(hqRoot, questionLane, latest.envelope, latest.ref, nowIso, undefined, activeCompany);
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
questionRefusalCode = questionRecordRefusalCode(err);
|
|
1065
|
+
if (questionRefusalCode === null)
|
|
1066
|
+
throw err;
|
|
1067
|
+
repairedClassification = {
|
|
1068
|
+
state: "failed",
|
|
1069
|
+
reason: `question_record_refused:${questionRefusalCode}`,
|
|
1070
|
+
};
|
|
1071
|
+
process.stderr.write(`hq lanes reconcile: terminal ask refused for ${lane.lane_id}: ${questionRefusalCode}\n`);
|
|
1072
|
+
}
|
|
1055
1073
|
}
|
|
1056
1074
|
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
|
|
1057
1075
|
const next = {
|
|
@@ -1099,7 +1117,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1099
1117
|
to: repairedClassification.state,
|
|
1100
1118
|
reason: repairedClassification.reason ?? "host_lost",
|
|
1101
1119
|
});
|
|
1102
|
-
if (latest?.envelope) {
|
|
1120
|
+
if (latest?.envelope && questionRefusalCode === null) {
|
|
1103
1121
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
1104
1122
|
if (mesh) {
|
|
1105
1123
|
const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
@@ -1417,12 +1435,38 @@ function recordKeepAliveEvent(hqRoot, laneId, summary, event) {
|
|
|
1417
1435
|
});
|
|
1418
1436
|
}
|
|
1419
1437
|
}
|
|
1438
|
+
function keepAliveSkipGuidance(laneId, reason) {
|
|
1439
|
+
switch (reason) {
|
|
1440
|
+
case "missing_opt_in_time":
|
|
1441
|
+
return `if appropriate, run hq lanes keep-alive ${laneId} --on`;
|
|
1442
|
+
case "not_opted_in":
|
|
1443
|
+
return "explicit opt-in is required before keep-alive can resume this lane";
|
|
1444
|
+
case "envelope_before_opt_in":
|
|
1445
|
+
return `the latest envelope predates opt-in; review the lane with hq lanes show ${laneId}`;
|
|
1446
|
+
case "interruption_before_opt_in":
|
|
1447
|
+
return `the interruption predates opt-in; review the lane with hq lanes show ${laneId}`;
|
|
1448
|
+
case "missing_interruption_time":
|
|
1449
|
+
return "the interruption time is unavailable; inspect the watcher classification before resuming";
|
|
1450
|
+
case "retired":
|
|
1451
|
+
return "the lane is retired; resolve its retired status before resuming";
|
|
1452
|
+
case "superseded":
|
|
1453
|
+
return "a newer lead exists for this project; continue on that lane";
|
|
1454
|
+
case "retired_list_invalid":
|
|
1455
|
+
return "the retired lane list is invalid; repair workspace/lane-briefs/juniors.json before retrying";
|
|
1456
|
+
case "terminal_state":
|
|
1457
|
+
return `the lane is in a terminal state; review it with hq lanes show ${laneId} before starting replacement work`;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1420
1460
|
function recordKeepAliveSkip(hqRoot, lane, reason, summary, deps, detail) {
|
|
1421
1461
|
summary.skipped.push({
|
|
1422
1462
|
kind: "lane",
|
|
1423
1463
|
id: lane.lane_id,
|
|
1424
1464
|
why: `keep_alive_${reason}${detail ? `: ${detail}` : ""}`,
|
|
1425
1465
|
});
|
|
1466
|
+
if (lane.keep_alive === true) {
|
|
1467
|
+
summary.keep_alive_blocked.push({ id: lane.lane_id, reason });
|
|
1468
|
+
process.stderr.write(`hq lanes reconcile: keep-alive blocked for lane ${lane.lane_id} (${reason}); ${keepAliveSkipGuidance(lane.lane_id, reason)}\n`);
|
|
1469
|
+
}
|
|
1426
1470
|
if (deps.dryRun || lane.keep_alive_blocked_reason === reason)
|
|
1427
1471
|
return;
|
|
1428
1472
|
try {
|
|
@@ -2003,10 +2047,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2003
2047
|
}
|
|
2004
2048
|
catch (err) {
|
|
2005
2049
|
terminalizeModelUnsupported(hqRoot, lane, err);
|
|
2050
|
+
const errorName = err instanceof LanesError ? `${err.error}: ` : "";
|
|
2051
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2006
2052
|
markDirty(summary, {
|
|
2007
2053
|
kind: "lane",
|
|
2008
2054
|
id: lane.lane_id,
|
|
2009
|
-
why: `resume failed: ${
|
|
2055
|
+
why: `resume failed: ${errorName}${errorMessage}`,
|
|
2010
2056
|
});
|
|
2011
2057
|
}
|
|
2012
2058
|
}
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -27,6 +27,7 @@ import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./s
|
|
|
27
27
|
import { copySessionFiles, planSessionCopy } from "./move-account.js";
|
|
28
28
|
import { ensureSessionHome } from "./session-relink.js";
|
|
29
29
|
import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
|
|
30
|
+
import { markAnsweredQuestionsDelivered } from "./questions.js";
|
|
30
31
|
import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
|
|
31
32
|
import { resolveAdmissionHost } from "./admission-host.js";
|
|
32
33
|
import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
|
|
@@ -979,22 +980,30 @@ export function readAnsweredQuestionLines(answersPath) {
|
|
|
979
980
|
parsed = JSON.parse(text);
|
|
980
981
|
}
|
|
981
982
|
catch (err) {
|
|
982
|
-
|
|
983
|
-
|
|
983
|
+
throw new LanesError("answered_questions_invalid", `Answered questions at ${answersPath} are not valid JSON`, { path: answersPath });
|
|
984
|
+
}
|
|
985
|
+
if (!Array.isArray(parsed)) {
|
|
986
|
+
throw new LanesError("answered_questions_invalid", `Answered questions at ${answersPath} must be a JSON array`, { path: answersPath });
|
|
984
987
|
}
|
|
985
|
-
if (!Array.isArray(parsed))
|
|
986
|
-
return [];
|
|
987
988
|
const out = [];
|
|
988
|
-
for (const item of parsed) {
|
|
989
|
-
if (!item || typeof item !== "object")
|
|
990
|
-
|
|
989
|
+
for (const [index, item] of parsed.entries()) {
|
|
990
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
991
|
+
throw new LanesError("answered_questions_invalid", `Answered question ${index} at ${answersPath} must be an object`, { path: answersPath, index });
|
|
992
|
+
}
|
|
991
993
|
const rec = item;
|
|
992
|
-
if (typeof rec.question_id !== "string")
|
|
993
|
-
|
|
994
|
+
if (typeof rec.question_id !== "string" || !rec.question_id.trim()) {
|
|
995
|
+
throw new LanesError("answered_questions_invalid", `Answered question ${index} at ${answersPath} must have a question_id`, { path: answersPath, index });
|
|
996
|
+
}
|
|
997
|
+
if (typeof rec.choice !== "string") {
|
|
998
|
+
throw new LanesError("answered_questions_invalid", `Answered question ${rec.question_id} at ${answersPath} must have a choice`, { path: answersPath, question_id: rec.question_id });
|
|
999
|
+
}
|
|
994
1000
|
const line = {
|
|
995
1001
|
question_id: rec.question_id,
|
|
996
|
-
choice:
|
|
1002
|
+
choice: rec.choice,
|
|
997
1003
|
};
|
|
1004
|
+
if (rec.notes !== undefined && typeof rec.notes !== "string") {
|
|
1005
|
+
throw new LanesError("answered_questions_invalid", `Answered question ${rec.question_id} at ${answersPath} has invalid notes`, { path: answersPath, question_id: rec.question_id });
|
|
1006
|
+
}
|
|
998
1007
|
if (typeof rec.notes === "string" && rec.notes.length > 0)
|
|
999
1008
|
line.notes = rec.notes;
|
|
1000
1009
|
out.push(line);
|
|
@@ -1744,6 +1753,10 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1744
1753
|
const envelopePath = nextEnvelopePath(runDir);
|
|
1745
1754
|
const lastMessagePath = path.join(logsDir, "last-message.txt");
|
|
1746
1755
|
const answersPath = path.join(runDir, ANSWERED_QUESTIONS_FILE);
|
|
1756
|
+
const carriesAnsweredQuestions = trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions";
|
|
1757
|
+
const answeredLines = carriesAnsweredQuestions && existsSync(answersPath)
|
|
1758
|
+
? readAnsweredQuestionLines(answersPath)
|
|
1759
|
+
: undefined;
|
|
1747
1760
|
const extraPrompt = existsSync(answersPath)
|
|
1748
1761
|
? `Previously answered questions are at ${answersPath}; treat those choices as operator decisions that outrank the brief.`
|
|
1749
1762
|
: undefined;
|
|
@@ -1795,12 +1808,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1795
1808
|
? pendingResume.text
|
|
1796
1809
|
: undefined,
|
|
1797
1810
|
resumedFrom: trigger.kind === "resume" ? trigger.from : pendingResume?.resumed_from,
|
|
1798
|
-
answersPath:
|
|
1799
|
-
|
|
1800
|
-
: undefined,
|
|
1801
|
-
answers: (trigger.kind === "answered_questions" || pendingResume?.trigger === "answered_questions") && existsSync(answersPath)
|
|
1802
|
-
? readAnsweredQuestionLines(answersPath)
|
|
1803
|
-
: undefined,
|
|
1811
|
+
answersPath: answeredLines !== undefined ? answersPath : undefined,
|
|
1812
|
+
answers: answeredLines,
|
|
1804
1813
|
envelopeRepairErrors: lane.terminal_reason === ENVELOPE_INVALID_REASON &&
|
|
1805
1814
|
lane.envelope_repair?.attempted === true &&
|
|
1806
1815
|
lane.envelope_repair.succeeded === false
|
|
@@ -2053,6 +2062,9 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2053
2062
|
starttime: readProcStarttime(workerPid) ?? undefined,
|
|
2054
2063
|
});
|
|
2055
2064
|
requireDetached(proveDetached, workerPid, "worker");
|
|
2065
|
+
if (answeredLines && answeredLines.length > 0) {
|
|
2066
|
+
markAnsweredQuestionsDelivered(hqRoot, lane.lane_id, answeredLines.map(({ question_id }) => question_id), (deps.now ?? (() => new Date()))().toISOString());
|
|
2067
|
+
}
|
|
2056
2068
|
watcherPid = armLaneWatcher(hqRoot, lane.lane_id, deps);
|
|
2057
2069
|
writeSpawnManifest(runDir, {
|
|
2058
2070
|
command: command.command,
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -700,7 +700,15 @@ export function parseQuestionRecord(raw, filePath) {
|
|
|
700
700
|
path: filePath,
|
|
701
701
|
});
|
|
702
702
|
}
|
|
703
|
-
|
|
703
|
+
const delivered_at = raw.delivered_at === undefined ? null : raw.delivered_at;
|
|
704
|
+
if (delivered_at !== null &&
|
|
705
|
+
(typeof delivered_at !== "string" || !Number.isFinite(Date.parse(delivered_at)))) {
|
|
706
|
+
throw new LaneSchemaError("delivered_at must be a valid timestamp or null", {
|
|
707
|
+
path: filePath,
|
|
708
|
+
value: delivered_at,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
return { ...raw, delivered_at };
|
|
704
712
|
}
|
|
705
713
|
/**
|
|
706
714
|
* HQ Sync sibling-twin grammar from `@indigoai-us/hq-cloud` `buildConflictPath`:
|
|
@@ -845,8 +853,13 @@ export function mergeQuestionRecords(live, sidecars) {
|
|
|
845
853
|
return live;
|
|
846
854
|
const all = [live, ...sidecars];
|
|
847
855
|
const base = higherVersionRecord(live, sidecars);
|
|
856
|
+
const deliveredAt = all
|
|
857
|
+
.map((record) => record.delivered_at)
|
|
858
|
+
.filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)))
|
|
859
|
+
.sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
|
|
848
860
|
return {
|
|
849
861
|
...base,
|
|
862
|
+
delivered_at: deliveredAt,
|
|
850
863
|
answer_history: unionHistory(all.map(questionAnswerHistory), entryIdentity, questionAnswerHistory(base)),
|
|
851
864
|
routing_history: unionHistory(all.map((record) => record.routing_history), entryIdentity, base.routing_history),
|
|
852
865
|
};
|
|
@@ -1205,6 +1218,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
1205
1218
|
...init,
|
|
1206
1219
|
schema_version: init.schema_version ?? QUESTION_SCHEMA_VERSION,
|
|
1207
1220
|
question_id,
|
|
1221
|
+
delivered_at: init.delivered_at ?? null,
|
|
1208
1222
|
version: 1,
|
|
1209
1223
|
routing_history: init.routing_history ?? [],
|
|
1210
1224
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TrackerChecksConfig } from "./types.js";
|
|
2
|
+
export interface TrackerCheckQuestion {
|
|
3
|
+
id: string;
|
|
4
|
+
status: string;
|
|
5
|
+
asked_at: string;
|
|
6
|
+
routing_history: Array<{
|
|
7
|
+
at: string;
|
|
8
|
+
audience_kind: string;
|
|
9
|
+
}>;
|
|
10
|
+
surfaced_at: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface TrackerCheckTeam {
|
|
13
|
+
team: string;
|
|
14
|
+
project_id: string;
|
|
15
|
+
lead_lane?: {
|
|
16
|
+
id: string;
|
|
17
|
+
state: string;
|
|
18
|
+
};
|
|
19
|
+
open_story_ids: string[];
|
|
20
|
+
open_pr_refs: string[];
|
|
21
|
+
running_worker_ids: string[];
|
|
22
|
+
status_path: string;
|
|
23
|
+
status_updated_at: string | null;
|
|
24
|
+
task_list_path: string;
|
|
25
|
+
task_list_updated_at: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface TrackerCheckBlocker {
|
|
28
|
+
id: string;
|
|
29
|
+
opened_at: string;
|
|
30
|
+
resolved_at: string | null;
|
|
31
|
+
next_step: string | null;
|
|
32
|
+
}
|
|
33
|
+
export interface TrackerCheckKpi {
|
|
34
|
+
id: string;
|
|
35
|
+
created_at: string;
|
|
36
|
+
defined_at?: string;
|
|
37
|
+
values: Array<{
|
|
38
|
+
at: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
export interface TrackerCheckDirective {
|
|
42
|
+
id: string;
|
|
43
|
+
created_at: string;
|
|
44
|
+
teams: string[];
|
|
45
|
+
acknowledgements: Array<{
|
|
46
|
+
team: string;
|
|
47
|
+
lane_id: string;
|
|
48
|
+
at: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
export interface TrackerCheckDirectiveTeam {
|
|
52
|
+
team: string;
|
|
53
|
+
lead_lane_id?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface TrackerChecksInput {
|
|
56
|
+
now: Date;
|
|
57
|
+
thresholds: TrackerChecksConfig;
|
|
58
|
+
questions: TrackerCheckQuestion[];
|
|
59
|
+
teams: TrackerCheckTeam[];
|
|
60
|
+
blockers: TrackerCheckBlocker[];
|
|
61
|
+
kpis: TrackerCheckKpi[];
|
|
62
|
+
directives: TrackerCheckDirective[];
|
|
63
|
+
/** Company-wide mapping retained when the caller filters checks to one project. */
|
|
64
|
+
directive_teams?: TrackerCheckDirectiveTeam[];
|
|
65
|
+
}
|
|
66
|
+
export interface TrackerCheckFinding {
|
|
67
|
+
id: string;
|
|
68
|
+
reason: string;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
export interface TrackerCheckResult {
|
|
72
|
+
id: "questions" | "lead_lanes" | "freshness" | "blockers" | "kpis" | "directives";
|
|
73
|
+
result: "ok" | "failing";
|
|
74
|
+
items: TrackerCheckFinding[];
|
|
75
|
+
}
|
|
76
|
+
export declare function evaluateTrackerChecks(input: TrackerChecksInput): TrackerCheckResult[];
|
|
77
|
+
//# sourceMappingURL=tracker-checks.d.ts.map
|