@indigoai-us/hq-cli 5.218.0 → 5.220.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 +33 -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 +172 -49
- package/dist/lib/flag-registry-worker-mode-cache.d.ts +13 -0
- package/dist/lib/flag-registry-worker-mode-cache.js +146 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +2 -0
- package/dist/lib/flag-registry-worker-source.generated.js +3 -0
- package/dist/lib/flag-registry.d.ts +4 -1
- package/dist/lib/flag-registry.js +100 -9
- package/dist/lib/lanes/config.d.ts +2 -1
- package/dist/lib/lanes/config.js +30 -0
- package/dist/lib/lanes/questions.d.ts +21 -7
- package/dist/lib/lanes/questions.js +157 -18
- package/dist/lib/lanes/reconcile.d.ts +3 -1
- package/dist/lib/lanes/reconcile.js +22 -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 +36 -7
- package/dist/sentry.js +41 -0
- package/package.json +5 -2
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
|
}
|
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
* Requires HQ_ROOT in the environment.
|
|
7
7
|
*/
|
|
8
8
|
import { runWatcherLoop } from "./watcher.js";
|
|
9
|
+
import { LanesError } from "./errors.js";
|
|
10
|
+
import { openMeshTransport } from "../mesh/client.js";
|
|
11
|
+
import { CompanySelectionError } from "../../utils/company-selection-error.js";
|
|
12
|
+
async function resolveQuestionActiveCompany() {
|
|
13
|
+
try {
|
|
14
|
+
const { company } = await openMeshTransport({});
|
|
15
|
+
return { slug: company.companySlug, uid: company.companyUid };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (!(err instanceof CompanySelectionError))
|
|
19
|
+
throw err;
|
|
20
|
+
throw new LanesError("question_company_unavailable", "Cannot raise a lane question because the active HQ company could not be resolved", {});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
function main() {
|
|
10
24
|
const laneId = process.argv[2]?.trim();
|
|
11
25
|
const hqRoot = process.env.HQ_ROOT?.trim();
|
|
@@ -13,7 +27,9 @@ function main() {
|
|
|
13
27
|
process.stderr.write("hq lanes watcher: requires lane id argv and HQ_ROOT in the environment\n");
|
|
14
28
|
process.exit(1);
|
|
15
29
|
}
|
|
16
|
-
void runWatcherLoop(hqRoot, laneId
|
|
30
|
+
void runWatcherLoop(hqRoot, laneId, {
|
|
31
|
+
resolveQuestionActiveCompany,
|
|
32
|
+
}).catch((err) => {
|
|
17
33
|
process.stderr.write(`hq lanes watcher: ${err instanceof Error ? err.message : String(err)}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
|
|
18
34
|
process.exit(1);
|
|
19
35
|
});
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* Queue admission of the next lane when a slot frees is owned by the
|
|
32
32
|
* periodic reconcile pass and is out of scope here.
|
|
33
33
|
*/
|
|
34
|
-
import { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
|
|
34
|
+
import { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneCompanyRef, type LaneRecord, type LaneState } from "./types.js";
|
|
35
35
|
import { type QuotaSignal } from "./quota.js";
|
|
36
36
|
import { type ProviderRejection } from "./provider-rejection.js";
|
|
37
37
|
import type { AdmissionHost } from "./admission.js";
|
|
@@ -145,6 +145,7 @@ export interface ApplyTerminalOpts {
|
|
|
145
145
|
envelopeRef?: string;
|
|
146
146
|
envelopeValidationErrors?: string[];
|
|
147
147
|
questionIndexes?: readonly number[];
|
|
148
|
+
activeCompany?: LaneCompanyRef | null;
|
|
148
149
|
at?: string;
|
|
149
150
|
quota?: QuotaSignal | null;
|
|
150
151
|
/** Written to `terminal_details` with the model and account the run used. */
|
|
@@ -219,6 +220,8 @@ export interface WatcherLoopDeps {
|
|
|
219
220
|
resolveAdmissionHost?: () => AdmissionHost;
|
|
220
221
|
/** Spawn seams for account retry; host scope is supplied by the resolver above. */
|
|
221
222
|
retrySpawnDeps?: Omit<SpawnLaneDeps, "admissionHost">;
|
|
223
|
+
/** Active-company fallback when an unscoped lane asks from its envelope. */
|
|
224
|
+
resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
|
|
222
225
|
}
|
|
223
226
|
/**
|
|
224
227
|
* Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { LanesError } from "./errors.js";
|
|
37
|
-
import { raiseEnvelopeQuestions } from "./questions.js";
|
|
37
|
+
import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
38
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";
|
|
@@ -309,12 +309,27 @@ export function applyTerminalToLane(hqRoot, laneId, classification, opts = {}) {
|
|
|
309
309
|
export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}) {
|
|
310
310
|
const at = opts.at ?? new Date().toISOString();
|
|
311
311
|
const existing = readLane(hqRoot, laneId);
|
|
312
|
+
let questionRefusalCode = null;
|
|
312
313
|
if (!isTerminalLaneState(existing.state) &&
|
|
313
314
|
classification.state === "awaiting_input" &&
|
|
314
315
|
opts.envelope &&
|
|
315
316
|
(opts.envelope.questions?.length ?? 0) > 0) {
|
|
316
|
-
|
|
317
|
+
try {
|
|
318
|
+
raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes, opts.activeCompany);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
questionRefusalCode = questionRecordRefusalCode(err);
|
|
322
|
+
if (questionRefusalCode === null)
|
|
323
|
+
throw err;
|
|
324
|
+
process.stderr.write(`hq lanes watcher: terminal ask refused for ${laneId}: ${questionRefusalCode}\n`);
|
|
325
|
+
}
|
|
317
326
|
}
|
|
327
|
+
const terminalClassification = questionRefusalCode === null
|
|
328
|
+
? classification
|
|
329
|
+
: {
|
|
330
|
+
state: "failed",
|
|
331
|
+
reason: `question_record_refused:${questionRefusalCode}`,
|
|
332
|
+
};
|
|
318
333
|
let changed = false;
|
|
319
334
|
const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
320
335
|
const terminal = current.state === "done" ||
|
|
@@ -331,7 +346,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
331
346
|
const next = {
|
|
332
347
|
...current,
|
|
333
348
|
...runResultFields(hqRoot, current),
|
|
334
|
-
state:
|
|
349
|
+
state: terminalClassification.state,
|
|
335
350
|
queued_reason: undefined,
|
|
336
351
|
stalled: undefined,
|
|
337
352
|
stalled_at: undefined,
|
|
@@ -340,9 +355,9 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
340
355
|
envelope_validation_errors: opts.envelopeValidationErrors,
|
|
341
356
|
};
|
|
342
357
|
changed = true;
|
|
343
|
-
if (
|
|
358
|
+
if (terminalClassification.reason) {
|
|
344
359
|
next.terminal_reason =
|
|
345
|
-
|
|
360
|
+
terminalClassification.reason;
|
|
346
361
|
}
|
|
347
362
|
if (opts.quota?.quota_reset_at) {
|
|
348
363
|
next.quota_reset_at = opts.quota.quota_reset_at;
|
|
@@ -1054,11 +1069,25 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
1054
1069
|
return;
|
|
1055
1070
|
}
|
|
1056
1071
|
}
|
|
1072
|
+
const terminalLane = readLane(hqRoot, laneId);
|
|
1073
|
+
let activeQuestionCompany;
|
|
1074
|
+
if (envelope?.decision === "ask" && (envelope.questions?.length ?? 0) > 0) {
|
|
1075
|
+
try {
|
|
1076
|
+
activeQuestionCompany = await resolveQuestionCompanyWithFallback(hqRoot, terminalLane, deps.resolveQuestionActiveCompany);
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
if (questionRecordRefusalCode(err) !== "question_company_unavailable")
|
|
1080
|
+
throw err;
|
|
1081
|
+
// applyTerminalToLaneWithResult repeats the synchronous lane and
|
|
1082
|
+
// project checks, then terminalizes this ask with the named refusal.
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1057
1085
|
const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
|
|
1058
1086
|
envelope,
|
|
1059
1087
|
envelopeRef: latest?.ref,
|
|
1060
1088
|
envelopeValidationErrors,
|
|
1061
1089
|
questionIndexes,
|
|
1090
|
+
activeCompany: activeQuestionCompany,
|
|
1062
1091
|
at: tick.emit.at,
|
|
1063
1092
|
quota,
|
|
1064
1093
|
providerRejection,
|
|
@@ -1074,7 +1103,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
1074
1103
|
// lane_blocked; ask → lane_ask with the queue ids. After the state
|
|
1075
1104
|
// write, so a mesh failure can never leave the lane unclassified.
|
|
1076
1105
|
if (mesh && terminalWrite.changed) {
|
|
1077
|
-
if (envelope && latest) {
|
|
1106
|
+
if (envelope && latest && updated.state === classification.state) {
|
|
1078
1107
|
const questionIds = envelope.decision === "ask"
|
|
1079
1108
|
? envelopeQuestionIds(laneId, latest.ref, envelope, questionIndexes)
|
|
1080
1109
|
: undefined;
|
|
@@ -1086,7 +1115,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
1086
1115
|
}
|
|
1087
1116
|
else if (updated.state === "failed" || updated.state === "interrupted" ||
|
|
1088
1117
|
updated.state === "cancelled" || updated.state === "killed") {
|
|
1089
|
-
await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, classification.reason, signal), meshRequestTimeoutMs);
|
|
1118
|
+
await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, updated.terminal_reason ?? classification.reason, signal), meshRequestTimeoutMs);
|
|
1090
1119
|
}
|
|
1091
1120
|
}
|
|
1092
1121
|
}
|
package/dist/sentry.js
CHANGED
|
@@ -26,6 +26,9 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
26
26
|
return null;
|
|
27
27
|
event.level = "warning";
|
|
28
28
|
event.tags = { ...event.tags, worker: "flag-registry" };
|
|
29
|
+
const missingFile = flagRegistryWorkerMissingFileTags(hint?.originalException, event);
|
|
30
|
+
if (missingFile)
|
|
31
|
+
event.tags = { ...event.tags, ...missingFile };
|
|
29
32
|
}
|
|
30
33
|
if (!flagRegistryWorkerEvent && isEpipe(hint?.originalException))
|
|
31
34
|
return null;
|
|
@@ -123,6 +126,44 @@ function isExpectedFlagRegistryWorkerStartupFailure(error) {
|
|
|
123
126
|
return missingModule.replaceAll("\\", "/").endsWith("/flag-registry-worker.js") ||
|
|
124
127
|
missingModule === "flag-registry-worker.js";
|
|
125
128
|
}
|
|
129
|
+
function flagRegistryWorkerMissingFileTags(error, event) {
|
|
130
|
+
if (typeof error !== "object" || error === null)
|
|
131
|
+
return null;
|
|
132
|
+
const details = error;
|
|
133
|
+
if (details.code !== "ENOENT" || !["open", "lstat"].includes(String(details.syscall))) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let missingPath = typeof details.path === "string" ? details.path : "";
|
|
137
|
+
if (!missingPath && typeof details.message === "string") {
|
|
138
|
+
missingPath = /\b(?:open|lstat)\s+['"]([^'"]+)['"]/.exec(details.message)?.[1] ?? "";
|
|
139
|
+
}
|
|
140
|
+
const basename = missingPath.replaceAll("\\", "/").split("/").at(-1) ?? "";
|
|
141
|
+
if (!basename)
|
|
142
|
+
return null;
|
|
143
|
+
const normalizedPath = missingPath.replaceAll("\\", "/");
|
|
144
|
+
const role = basename === "flag-registry-worker.js"
|
|
145
|
+
? "worker_entry"
|
|
146
|
+
: normalizedPath.includes("/node_modules/")
|
|
147
|
+
? "dependency"
|
|
148
|
+
: "other";
|
|
149
|
+
const eventText = [
|
|
150
|
+
event.message,
|
|
151
|
+
...(event.exception?.values?.map((value) => value.value) ?? []),
|
|
152
|
+
].filter((value) => typeof value === "string").join("\n");
|
|
153
|
+
const pathVisibility = eventText.includes(missingPath)
|
|
154
|
+
? "full_path"
|
|
155
|
+
: eventText.includes(basename)
|
|
156
|
+
? "basename_only"
|
|
157
|
+
: "omitted";
|
|
158
|
+
return {
|
|
159
|
+
flag_worker_load_failure: "1",
|
|
160
|
+
flag_worker_missing_basename: basename,
|
|
161
|
+
flag_worker_missing_file_role: role,
|
|
162
|
+
flag_worker_path_in_event: pathVisibility,
|
|
163
|
+
// The HQ beforeSend scrubber redacts credentials but does not strip paths.
|
|
164
|
+
flag_worker_path_scrubbed_by_hq_cli: "no",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
126
167
|
/**
|
|
127
168
|
* Tests substitute only the transport so the real SDK lifecycle can be
|
|
128
169
|
* exercised without an ingest request. Production callers pass nothing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.220.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
"scripts/mesh-daemon-if-stale.mjs"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|
|
25
|
+
"build": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
|
+
"pretypecheck": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
|
|
27
28
|
"typecheck": "tsc --noEmit",
|
|
28
29
|
"gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
|
|
29
30
|
"gen:command-catalog": "tsc && node scripts/generate-command-catalog.mjs",
|
|
30
31
|
"lint": "eslint .",
|
|
32
|
+
"pretest": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
|
|
31
33
|
"test": "vitest run",
|
|
32
34
|
"test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
|
|
33
35
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"@vitest/coverage-v8": "4.1.6",
|
|
67
69
|
"aedes": "1.1.2",
|
|
68
70
|
"aws-sdk-client-mock": "^4.1.0",
|
|
71
|
+
"esbuild": "0.27.0",
|
|
69
72
|
"eslint": "^10.5.0",
|
|
70
73
|
"husky": "^9.1.7",
|
|
71
74
|
"lint-staged": "^15.5.2",
|