@indigoai-us/hq-cli 5.218.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 +20 -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 +3 -1
- package/dist/lib/lanes/reconcile.js +24 -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;
|
|
@@ -10,7 +10,7 @@ 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
12
|
import { type KeepAliveBlockedReason } from "./keep-alive.js";
|
|
13
|
-
import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
|
|
13
|
+
import type { LaneCompanyRef, LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
|
|
14
14
|
import { type LaneMesh } from "./mesh-events.js";
|
|
15
15
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
16
16
|
export declare const QUESTION_AGE_MS: number;
|
|
@@ -79,6 +79,8 @@ export interface ReconcileDeps {
|
|
|
79
79
|
env?: NodeJS.ProcessEnv;
|
|
80
80
|
/** Flag reader seam used to assert rollout reads happen before admission locks. */
|
|
81
81
|
readRegistryFlag?: typeof readLanesRegistryFlag;
|
|
82
|
+
/** Active-company fallback for questions raised from an unscoped lane. */
|
|
83
|
+
resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
|
|
82
84
|
/** Detached mesh seam for dead-watcher recovery. */
|
|
83
85
|
mesh?: LaneMesh | null;
|
|
84
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";
|
|
@@ -1010,9 +1010,10 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1010
1010
|
if (!repairCandidate.missing)
|
|
1011
1011
|
invalidEnvelope = { errors: repair.errors };
|
|
1012
1012
|
}
|
|
1013
|
-
|
|
1013
|
+
let repairedClassification = invalidEnvelope
|
|
1014
1014
|
? classifyLaneExit({ envelope: null, hasPartialOutput: false, invalidEnvelope })
|
|
1015
1015
|
: classification;
|
|
1016
|
+
let questionRefusalCode = null;
|
|
1016
1017
|
const accountRetryReason = quota
|
|
1017
1018
|
? "usage_limit"
|
|
1018
1019
|
: providerRejection && classification.reason === "account_not_logged_in"
|
|
@@ -1052,7 +1053,23 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1052
1053
|
classification.state === "awaiting_input" &&
|
|
1053
1054
|
latest &&
|
|
1054
1055
|
(latest.envelope.questions?.length ?? 0) > 0) {
|
|
1055
|
-
|
|
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
|
+
}
|
|
1056
1073
|
}
|
|
1057
1074
|
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
|
|
1058
1075
|
const next = {
|
|
@@ -1100,7 +1117,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1100
1117
|
to: repairedClassification.state,
|
|
1101
1118
|
reason: repairedClassification.reason ?? "host_lost",
|
|
1102
1119
|
});
|
|
1103
|
-
if (latest?.envelope) {
|
|
1120
|
+
if (latest?.envelope && questionRefusalCode === null) {
|
|
1104
1121
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
1105
1122
|
if (mesh) {
|
|
1106
1123
|
const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
@@ -2030,10 +2047,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2030
2047
|
}
|
|
2031
2048
|
catch (err) {
|
|
2032
2049
|
terminalizeModelUnsupported(hqRoot, lane, err);
|
|
2050
|
+
const errorName = err instanceof LanesError ? `${err.error}: ` : "";
|
|
2051
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2033
2052
|
markDirty(summary, {
|
|
2034
2053
|
kind: "lane",
|
|
2035
2054
|
id: lane.lane_id,
|
|
2036
|
-
why: `resume failed: ${
|
|
2055
|
+
why: `resume failed: ${errorName}${errorMessage}`,
|
|
2037
2056
|
});
|
|
2038
2057
|
}
|
|
2039
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
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
function timestamp(value, field) {
|
|
2
|
+
const parsed = Date.parse(value);
|
|
3
|
+
if (!Number.isFinite(parsed))
|
|
4
|
+
throw new TypeError(`Invalid tracker check timestamp for ${field}`);
|
|
5
|
+
return parsed;
|
|
6
|
+
}
|
|
7
|
+
function check(id, items) {
|
|
8
|
+
return { id, result: items.length === 0 ? "ok" : "failing", items };
|
|
9
|
+
}
|
|
10
|
+
export function evaluateTrackerChecks(input) {
|
|
11
|
+
const now = input.now.getTime();
|
|
12
|
+
const questions = new Map();
|
|
13
|
+
for (const question of input.questions) {
|
|
14
|
+
if (question.status !== "pending")
|
|
15
|
+
continue;
|
|
16
|
+
const askedAt = timestamp(question.asked_at, `question ${question.id}.asked_at`);
|
|
17
|
+
if (now - askedAt > input.thresholds.open_question_age_ms) {
|
|
18
|
+
questions.set(question.id, {
|
|
19
|
+
id: question.id,
|
|
20
|
+
reason: "open_question_overdue",
|
|
21
|
+
age_ms: now - askedAt,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const latestRoute = question.routing_history
|
|
25
|
+
.map((route) => ({ ...route, at_ms: timestamp(route.at, `question ${question.id}.routing_history.at`) }))
|
|
26
|
+
.sort((a, b) => a.at_ms - b.at_ms)
|
|
27
|
+
.at(-1);
|
|
28
|
+
if (!latestRoute || latestRoute.audience_kind !== "user")
|
|
29
|
+
continue;
|
|
30
|
+
const surfacedAt = question.surfaced_at === null
|
|
31
|
+
? null
|
|
32
|
+
: timestamp(question.surfaced_at, `question ${question.id}.surfaced_at`);
|
|
33
|
+
if (now - latestRoute.at_ms >= input.thresholds.owner_question_surface_ms &&
|
|
34
|
+
(surfacedAt === null || surfacedAt < latestRoute.at_ms)) {
|
|
35
|
+
const finding = questions.get(question.id);
|
|
36
|
+
questions.set(question.id, {
|
|
37
|
+
...(finding ?? { id: question.id }),
|
|
38
|
+
reason: finding ? `${finding.reason},owner_not_surfaced` : "owner_not_surfaced",
|
|
39
|
+
routed_to_owner_at: new Date(latestRoute.at_ms).toISOString(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const leadLanes = input.teams.flatMap((team) => {
|
|
44
|
+
const lead = team.lead_lane;
|
|
45
|
+
if (!lead || (lead.state !== "done" && lead.state !== "failed"))
|
|
46
|
+
return [];
|
|
47
|
+
const hasOpenWork = team.open_story_ids.length > 0 ||
|
|
48
|
+
team.open_pr_refs.length > 0 || team.running_worker_ids.length > 0;
|
|
49
|
+
if (!hasOpenWork)
|
|
50
|
+
return [];
|
|
51
|
+
return [{
|
|
52
|
+
id: team.team,
|
|
53
|
+
reason: "terminal_lead_with_open_work",
|
|
54
|
+
lead_lane_id: lead.id,
|
|
55
|
+
lead_state: lead.state,
|
|
56
|
+
open_story_ids: team.open_story_ids,
|
|
57
|
+
open_pr_refs: team.open_pr_refs,
|
|
58
|
+
running_worker_ids: team.running_worker_ids,
|
|
59
|
+
}];
|
|
60
|
+
});
|
|
61
|
+
const freshness = [];
|
|
62
|
+
for (const team of input.teams) {
|
|
63
|
+
if (team.status_updated_at === null) {
|
|
64
|
+
freshness.push({ id: team.team, reason: "status_missing", path: team.status_path });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const age = now - timestamp(team.status_updated_at, `${team.team}.status.updated_at`);
|
|
68
|
+
if (age >= input.thresholds.status_stale_ms) {
|
|
69
|
+
freshness.push({ id: team.team, reason: "status_stale", path: team.status_path, age_ms: age });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (team.task_list_updated_at === null) {
|
|
73
|
+
freshness.push({ id: team.team, reason: "task_list_missing", path: team.task_list_path });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const age = now - timestamp(team.task_list_updated_at, `${team.team}.task_list.mtime`);
|
|
77
|
+
if (age >= input.thresholds.task_list_stale_ms) {
|
|
78
|
+
freshness.push({ id: team.team, reason: "task_list_stale", path: team.task_list_path, age_ms: age });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const blockers = input.blockers.flatMap((blocker) => {
|
|
83
|
+
if (blocker.resolved_at !== null)
|
|
84
|
+
return [];
|
|
85
|
+
const reasons = [];
|
|
86
|
+
const age = now - timestamp(blocker.opened_at, `blocker ${blocker.id}.opened_at`);
|
|
87
|
+
if (typeof blocker.next_step !== "string" || blocker.next_step.trim() === "")
|
|
88
|
+
reasons.push("missing_next_step");
|
|
89
|
+
if (age > input.thresholds.blocker_max_age_ms)
|
|
90
|
+
reasons.push("open_overdue");
|
|
91
|
+
return reasons.length === 0 ? [] : [{ id: blocker.id, reason: reasons.join(","), age_ms: age }];
|
|
92
|
+
});
|
|
93
|
+
const kpis = input.kpis.flatMap((kpi) => {
|
|
94
|
+
const latestValue = kpi.values
|
|
95
|
+
.map((value) => timestamp(value.at, `KPI ${kpi.id}.values.at`))
|
|
96
|
+
.reduce((latest, at) => latest === null || at > latest ? at : latest, null);
|
|
97
|
+
const baseline = latestValue ?? timestamp(kpi.created_at, `KPI ${kpi.id}.created_at`);
|
|
98
|
+
const age = now - baseline;
|
|
99
|
+
return age >= input.thresholds.kpi_value_max_age_ms
|
|
100
|
+
? [{ id: kpi.id, reason: latestValue === null ? "no_value_overdue" : "value_stale", age_ms: age }]
|
|
101
|
+
: [];
|
|
102
|
+
});
|
|
103
|
+
const directiveTeams = input.directive_teams ?? input.teams.map((team) => ({
|
|
104
|
+
team: team.team,
|
|
105
|
+
...(team.lead_lane ? { lead_lane_id: team.lead_lane.id } : {}),
|
|
106
|
+
}));
|
|
107
|
+
const teamByName = new Map(directiveTeams.map((team) => [team.team, team.lead_lane_id]));
|
|
108
|
+
const directives = input.directives.flatMap((directive) => {
|
|
109
|
+
const age = now - timestamp(directive.created_at, `directive ${directive.id}.created_at`);
|
|
110
|
+
if (age < input.thresholds.directive_ack_timeout_ms)
|
|
111
|
+
return [];
|
|
112
|
+
return directive.teams.flatMap((teamName) => {
|
|
113
|
+
const leadId = teamByName.get(teamName);
|
|
114
|
+
const acknowledged = leadId !== undefined && directive.acknowledgements.some((ack) => ack.team === teamName && ack.lane_id === leadId);
|
|
115
|
+
return acknowledged ? [] : [{
|
|
116
|
+
id: `${directive.id}:${teamName}`,
|
|
117
|
+
reason: leadId === undefined ? "target_team_lead_unavailable" : "lead_ack_missing",
|
|
118
|
+
directive_id: directive.id,
|
|
119
|
+
team: teamName,
|
|
120
|
+
lead_lane_id: leadId ?? null,
|
|
121
|
+
age_ms: age,
|
|
122
|
+
}];
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return [
|
|
126
|
+
check("questions", [...questions.values()]),
|
|
127
|
+
check("lead_lanes", leadLanes),
|
|
128
|
+
check("freshness", freshness),
|
|
129
|
+
check("blockers", blockers),
|
|
130
|
+
check("kpis", kpis),
|
|
131
|
+
check("directives", directives),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=tracker-checks.js.map
|
|
@@ -636,6 +636,8 @@ export interface QuestionRecord {
|
|
|
636
636
|
hop: number;
|
|
637
637
|
status: QuestionStatus;
|
|
638
638
|
version: number;
|
|
639
|
+
/** Null until the owner starts a resume carrying this answer; absent on old records. */
|
|
640
|
+
delivered_at?: string | null;
|
|
639
641
|
body: QuestionBodyRef;
|
|
640
642
|
routing_history: RoutingHistoryEntry[];
|
|
641
643
|
answer?: QuestionAnswer;
|
|
@@ -727,11 +729,24 @@ export interface LivenessWindows {
|
|
|
727
729
|
/** No event at all for this window (and no `session_end`) → lost. Default 12 hours. */
|
|
728
730
|
silent_lost_ms: number;
|
|
729
731
|
}
|
|
732
|
+
/** Age limits used by `hq lanes checks`; each value is a positive millisecond count. */
|
|
733
|
+
export interface TrackerChecksConfig {
|
|
734
|
+
owner_question_surface_ms: number;
|
|
735
|
+
open_question_age_ms: number;
|
|
736
|
+
status_stale_ms: number;
|
|
737
|
+
task_list_stale_ms: number;
|
|
738
|
+
blocker_max_age_ms: number;
|
|
739
|
+
kpi_value_max_age_ms: number;
|
|
740
|
+
directive_ack_timeout_ms: number;
|
|
741
|
+
}
|
|
730
742
|
export interface LanesConfig {
|
|
731
743
|
schema_version: typeof CONFIG_SCHEMA_VERSION | string;
|
|
732
744
|
providers: Record<LaneProvider, ProviderConfig>;
|
|
733
745
|
throttle: ThrottleConfig;
|
|
734
746
|
liveness: LivenessWindows;
|
|
747
|
+
tracker: {
|
|
748
|
+
checks: TrackerChecksConfig;
|
|
749
|
+
};
|
|
735
750
|
/** Seconds an unanswered question waits at a session audience before advancing. */
|
|
736
751
|
session_question_timeout_secs: number;
|
|
737
752
|
}
|