@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
|
@@ -17,7 +17,10 @@ interface WorkerLike {
|
|
|
17
17
|
}
|
|
18
18
|
export interface FlagRegistryDependencies {
|
|
19
19
|
endpoint?: string;
|
|
20
|
-
createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
|
|
20
|
+
createWorker?: (filename: string | URL, options: WorkerOptions) => WorkerLike;
|
|
21
|
+
/** Test seam for exercising the no-source fail-open path. */
|
|
22
|
+
workerSource?: string;
|
|
23
|
+
captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
|
|
21
24
|
}
|
|
22
25
|
export interface ProcessFlagCompanyContext {
|
|
23
26
|
companyUid?: string;
|
|
@@ -12,8 +12,10 @@ import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
|
12
12
|
import { readSessionState } from "./work-context/state.js";
|
|
13
13
|
import { workContextRoot } from "./work-context/paths.js";
|
|
14
14
|
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
15
|
-
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
15
|
+
import { loadCachedTokens, resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
16
16
|
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
17
|
+
import { FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, flagRegistryStateDir, flagRegistryWorkerModeCachePath, readCachedFlagRegistryWorkerMode, writeCachedFlagRegistryWorkerMode, } from "./flag-registry-worker-mode-cache.js";
|
|
18
|
+
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
17
19
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
18
20
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
19
21
|
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -99,6 +101,75 @@ function processSnapshotReader(companyIdentifiers) {
|
|
|
99
101
|
},
|
|
100
102
|
};
|
|
101
103
|
}
|
|
104
|
+
function personSubjectForWorkerMode(idToken) {
|
|
105
|
+
if (typeof idToken !== "string")
|
|
106
|
+
return null;
|
|
107
|
+
const payload = idToken.split(".")[1];
|
|
108
|
+
if (!payload)
|
|
109
|
+
return null;
|
|
110
|
+
try {
|
|
111
|
+
const subject = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).sub;
|
|
112
|
+
return typeof subject === "string" && subject.trim() ? subject : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
Sentry.captureMessage("Could not scope cached flag-registry worker mode because the cached ID token payload is invalid", { level: "warning", tags: { component: "flag-registry-worker-mode-cache" } });
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Scope a persisted rollout value to the same identity that fetched it. */
|
|
120
|
+
function workerModeCacheScopes(company) {
|
|
121
|
+
const tokens = loadCachedTokens();
|
|
122
|
+
const personSubject = tokens ? personSubjectForWorkerMode(tokens.idToken) : null;
|
|
123
|
+
if (tokens && !personSubject)
|
|
124
|
+
return null;
|
|
125
|
+
return [
|
|
126
|
+
...(company.companyUid ? [`company:${company.companyUid}`] : []),
|
|
127
|
+
...(company.companySlug ? [`company-slug:${company.companySlug}`] : []),
|
|
128
|
+
...(personSubject ? [`person:${personSubject}`] : []),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
function selfContainedWorkerEnabled(company) {
|
|
132
|
+
const localOverride = readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env);
|
|
133
|
+
if (localOverride !== null)
|
|
134
|
+
return localOverride;
|
|
135
|
+
const scopes = workerModeCacheScopes(company);
|
|
136
|
+
if (!scopes)
|
|
137
|
+
return false;
|
|
138
|
+
const filePath = flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes);
|
|
139
|
+
try {
|
|
140
|
+
return readCachedFlagRegistryWorkerMode(filePath)?.value ?? false;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
Sentry.captureException(error, {
|
|
144
|
+
tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
|
|
145
|
+
});
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function cacheWorkerModeFromSnapshot(snapshot, company) {
|
|
150
|
+
// A developer's local hq-flags override is process-local and must not become
|
|
151
|
+
// the value selected by a future process.
|
|
152
|
+
if (readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env) !== null)
|
|
153
|
+
return;
|
|
154
|
+
const scopes = workerModeCacheScopes(company);
|
|
155
|
+
if (!scopes)
|
|
156
|
+
return;
|
|
157
|
+
const decision = resolveFlag({
|
|
158
|
+
flagKey: FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG,
|
|
159
|
+
env: {},
|
|
160
|
+
snapshot,
|
|
161
|
+
companyIdentifiers: [company.companyUid, company.companySlug].filter((value) => Boolean(value)),
|
|
162
|
+
lookup: { fallback: false },
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
writeCachedFlagRegistryWorkerMode(flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes), { version: snapshot.version, value: decision.value });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
Sentry.captureException(error, {
|
|
169
|
+
tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
102
173
|
/**
|
|
103
174
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
104
175
|
*
|
|
@@ -117,23 +188,43 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
117
188
|
const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
|
|
118
189
|
processReader = processSnapshotReader(companyIdentifiers);
|
|
119
190
|
try {
|
|
191
|
+
let workerSource = new URL("./flag-registry-worker.js", import.meta.url);
|
|
192
|
+
let workerOptions = { workerData: {
|
|
193
|
+
endpoint,
|
|
194
|
+
...(companyUid ? { companyUid } : {}),
|
|
195
|
+
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
196
|
+
} };
|
|
197
|
+
if (selfContainedWorkerEnabled(company)) {
|
|
198
|
+
// The worker source is generated and embedded in this parent module at
|
|
199
|
+
// build time. It is already resident before company, identity, and cache
|
|
200
|
+
// reads can yield to an updater replacing this package tree.
|
|
201
|
+
const embeddedSource = dependencies.workerSource ?? FLAG_REGISTRY_WORKER_SOURCE;
|
|
202
|
+
if (embeddedSource.length === 0) {
|
|
203
|
+
(dependencies.captureWorkerFailure ?? ((error, tags) => {
|
|
204
|
+
Sentry.captureException(error, { tags });
|
|
205
|
+
}))(new Error("Self-contained flag-registry worker source is unavailable"), {
|
|
206
|
+
component: "flag-registry-worker-startup",
|
|
207
|
+
worker: "flag-registry",
|
|
208
|
+
flag_worker_failure: "embedded_source_unavailable",
|
|
209
|
+
});
|
|
210
|
+
return processReader;
|
|
211
|
+
}
|
|
212
|
+
workerSource = embeddedSource;
|
|
213
|
+
workerOptions = { ...workerOptions, eval: true };
|
|
214
|
+
}
|
|
120
215
|
// The SDK's child-process integration captures every Worker error unless
|
|
121
216
|
// its process-wide captureWorkerErrors option is disabled. Scope only this
|
|
122
217
|
// worker so the reporting boundary can classify its startup errors without
|
|
123
218
|
// changing capture for unrelated workers.
|
|
124
219
|
const worker = Sentry.withScope((scope) => {
|
|
125
220
|
scope.setTag("worker", "flag-registry");
|
|
126
|
-
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(
|
|
127
|
-
workerData: {
|
|
128
|
-
endpoint,
|
|
129
|
-
...(companyUid ? { companyUid } : {}),
|
|
130
|
-
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
131
|
-
},
|
|
132
|
-
});
|
|
221
|
+
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(workerSource, workerOptions);
|
|
133
222
|
});
|
|
134
223
|
worker.on("message", (value) => {
|
|
135
|
-
if (isFlagSnapshot(value))
|
|
224
|
+
if (isFlagSnapshot(value)) {
|
|
136
225
|
heldSnapshot = value;
|
|
226
|
+
cacheWorkerModeFromSnapshot(value, company);
|
|
227
|
+
}
|
|
137
228
|
});
|
|
138
229
|
// A failed background refresh must stay invisible to an offline CLI.
|
|
139
230
|
worker.on("error", () => { });
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* that resolve to the same home are refused (identity-keyed-cap rule).
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig } from "./types.js";
|
|
10
|
+
import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig, type TrackerChecksConfig } from "./types.js";
|
|
11
11
|
export declare const LANES_CONFIG_REL: string;
|
|
12
12
|
export declare const DEFAULT_MAX_QUEUED = 3;
|
|
13
13
|
export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
|
|
@@ -39,6 +39,7 @@ export declare const DEFAULT_TURN_START_LOST_MS: number;
|
|
|
39
39
|
export declare const DEFAULT_SILENT_LOST_MS: number;
|
|
40
40
|
/** Unanswered session questions move to the next audience after 10 minutes. */
|
|
41
41
|
export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
|
|
42
|
+
export declare const DEFAULT_TRACKER_CHECKS: TrackerChecksConfig;
|
|
42
43
|
export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
|
|
43
44
|
export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
|
|
44
45
|
export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -43,6 +43,15 @@ export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
|
|
|
43
43
|
export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
|
|
44
44
|
/** Unanswered session questions move to the next audience after 10 minutes. */
|
|
45
45
|
export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
|
|
46
|
+
export const DEFAULT_TRACKER_CHECKS = {
|
|
47
|
+
owner_question_surface_ms: 10 * 60 * 1000,
|
|
48
|
+
open_question_age_ms: 2 * 60 * 60 * 1000,
|
|
49
|
+
status_stale_ms: 90 * 60 * 1000,
|
|
50
|
+
task_list_stale_ms: 120 * 60 * 1000,
|
|
51
|
+
blocker_max_age_ms: 24 * 60 * 60 * 1000,
|
|
52
|
+
kpi_value_max_age_ms: 24 * 60 * 60 * 1000,
|
|
53
|
+
directive_ack_timeout_ms: 30 * 60 * 1000,
|
|
54
|
+
};
|
|
46
55
|
export const DEFAULT_LIVENESS_WINDOWS = {
|
|
47
56
|
live_within_ms: DEFAULT_LIVE_WITHIN_MS,
|
|
48
57
|
turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
|
|
@@ -362,6 +371,25 @@ function parseLiveness(raw) {
|
|
|
362
371
|
silent_lost_ms: positiveInt(obj.silent_lost_ms, "liveness.silent_lost_ms", DEFAULT_SILENT_LOST_MS),
|
|
363
372
|
};
|
|
364
373
|
}
|
|
374
|
+
function parseTrackerChecks(raw) {
|
|
375
|
+
const tracker = raw === undefined ? {} : raw;
|
|
376
|
+
if (!isPlainObject(tracker)) {
|
|
377
|
+
throw new LanesConfigError("invalid_config", "tracker must be an object");
|
|
378
|
+
}
|
|
379
|
+
const checks = tracker.checks === undefined ? {} : tracker.checks;
|
|
380
|
+
if (!isPlainObject(checks)) {
|
|
381
|
+
throw new LanesConfigError("invalid_config", "tracker.checks must be an object");
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
owner_question_surface_ms: positiveInt(checks.owner_question_surface_ms, "tracker.checks.owner_question_surface_ms", DEFAULT_TRACKER_CHECKS.owner_question_surface_ms),
|
|
385
|
+
open_question_age_ms: positiveInt(checks.open_question_age_ms, "tracker.checks.open_question_age_ms", DEFAULT_TRACKER_CHECKS.open_question_age_ms),
|
|
386
|
+
status_stale_ms: positiveInt(checks.status_stale_ms, "tracker.checks.status_stale_ms", DEFAULT_TRACKER_CHECKS.status_stale_ms),
|
|
387
|
+
task_list_stale_ms: positiveInt(checks.task_list_stale_ms, "tracker.checks.task_list_stale_ms", DEFAULT_TRACKER_CHECKS.task_list_stale_ms),
|
|
388
|
+
blocker_max_age_ms: positiveInt(checks.blocker_max_age_ms, "tracker.checks.blocker_max_age_ms", DEFAULT_TRACKER_CHECKS.blocker_max_age_ms),
|
|
389
|
+
kpi_value_max_age_ms: positiveInt(checks.kpi_value_max_age_ms, "tracker.checks.kpi_value_max_age_ms", DEFAULT_TRACKER_CHECKS.kpi_value_max_age_ms),
|
|
390
|
+
directive_ack_timeout_ms: positiveInt(checks.directive_ack_timeout_ms, "tracker.checks.directive_ack_timeout_ms", DEFAULT_TRACKER_CHECKS.directive_ack_timeout_ms),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
365
393
|
export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
366
394
|
const providers = {};
|
|
367
395
|
for (const provider of LANE_PROVIDERS) {
|
|
@@ -384,6 +412,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
|
384
412
|
min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
|
|
385
413
|
},
|
|
386
414
|
liveness: { ...DEFAULT_LIVENESS_WINDOWS },
|
|
415
|
+
tracker: { checks: { ...DEFAULT_TRACKER_CHECKS } },
|
|
387
416
|
session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
|
|
388
417
|
};
|
|
389
418
|
}
|
|
@@ -425,6 +454,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
|
|
|
425
454
|
providers,
|
|
426
455
|
throttle: parseThrottle(raw.throttle),
|
|
427
456
|
liveness: parseLiveness(raw.liveness),
|
|
457
|
+
tracker: { checks: parseTrackerChecks(raw.tracker) },
|
|
428
458
|
session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
|
|
429
459
|
};
|
|
430
460
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
4
4
|
* drift on hop, origin, raised_via, body store, and routing_history.
|
|
5
5
|
*/
|
|
6
|
-
import { type LaneEnvelope, type LaneRecord, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
|
|
6
|
+
import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
|
|
7
7
|
export interface QuestionBody {
|
|
8
8
|
question: string;
|
|
9
9
|
context: string;
|
|
@@ -11,8 +11,20 @@ export interface QuestionBody {
|
|
|
11
11
|
recommended?: string;
|
|
12
12
|
notes?: string;
|
|
13
13
|
}
|
|
14
|
+
export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
|
|
15
|
+
/** Errors that make an envelope ask unsafe or impossible to persist. */
|
|
16
|
+
export declare function questionRecordRefusalCode(err: unknown): "secret_text_refused" | "question_company_unavailable" | null;
|
|
14
17
|
export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
|
|
15
|
-
export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company"
|
|
18
|
+
export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company" | "mesh_company">, resolvedCompany?: LaneCompanyRef): QuestionBodyStore;
|
|
19
|
+
export declare function resolveQuestionCompany(lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, activeCompany?: LaneCompanyRef | null, hqRoot?: string): LaneCompanyRef;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve project ownership only when exactly one manifest company contains
|
|
22
|
+
* the project's directory. Multiple matches are unresolved so the caller can
|
|
23
|
+
* continue to an explicit active-company choice without picking an owner.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveProjectQuestionCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "project_id">): LaneCompanyRef | null;
|
|
26
|
+
/** Resolve the lane/project company before lazily consulting active HQ scope. */
|
|
27
|
+
export declare function resolveQuestionCompanyWithFallback(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, resolveActiveCompany?: () => Promise<LaneCompanyRef | null>): Promise<LaneCompanyRef>;
|
|
16
28
|
export declare function questionBodyRef(questionId: string): string;
|
|
17
29
|
export declare function sha256Text(text: string): string;
|
|
18
30
|
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
@@ -28,6 +40,7 @@ export interface RaiseLaneQuestionInput {
|
|
|
28
40
|
body: QuestionBody;
|
|
29
41
|
raisedVia: RaisedVia;
|
|
30
42
|
at: string;
|
|
43
|
+
activeCompany?: LaneCompanyRef | null;
|
|
31
44
|
questionId?: string;
|
|
32
45
|
}
|
|
33
46
|
/**
|
|
@@ -43,15 +56,15 @@ export declare function raiseLaneQuestion(input: RaiseLaneQuestionInput): Questi
|
|
|
43
56
|
*/
|
|
44
57
|
export declare function classifyQuestionKind(options: readonly string[]): QuestionKind | undefined;
|
|
45
58
|
/** The store a lane's question bodies live in, derived from the lane and never the reverse. */
|
|
46
|
-
export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company">, questionId: string): string;
|
|
59
|
+
export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, questionId: string): string;
|
|
47
60
|
/**
|
|
48
61
|
* Free-text notes the operator attached with `questions answer --notes`.
|
|
49
62
|
* Read from the lane's own store (spec §6.5): the company store for a company
|
|
50
63
|
* lane, the personal store otherwise. Returns undefined when the answer has
|
|
51
|
-
* no notes_ref or the body carries no notes
|
|
52
|
-
*
|
|
64
|
+
* no notes_ref or the body carries no notes. An unavailable or malformed body
|
|
65
|
+
* fails the resume so the lane cannot continue without its answer notes.
|
|
53
66
|
*/
|
|
54
|
-
export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company">, question: Pick<QuestionRecord, "question_id" | "answer">): string | undefined;
|
|
67
|
+
export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, question: Pick<QuestionRecord, "question_id" | "company" | "body" | "answer">): string | undefined;
|
|
55
68
|
export interface AnsweredQuestionEntry {
|
|
56
69
|
question_id: string;
|
|
57
70
|
choice: string | undefined;
|
|
@@ -66,10 +79,11 @@ export interface AnsweredQuestionEntry {
|
|
|
66
79
|
* directory on the executing host.
|
|
67
80
|
*/
|
|
68
81
|
export declare function answeredQuestionEntries(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company">, questions: QuestionRecord[]): AnsweredQuestionEntry[];
|
|
82
|
+
export declare function markAnsweredQuestionsDelivered(hqRoot: string, laneId: string, questionIds: readonly string[], deliveredAt: string): QuestionRecord[];
|
|
69
83
|
export declare function envelopeQuestionId(laneId: string, envelopeRef: string, index: number): string;
|
|
70
84
|
/**
|
|
71
85
|
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
72
86
|
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
73
87
|
*/
|
|
74
|
-
export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[]): QuestionRecord[];
|
|
88
|
+
export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[], activeCompany?: LaneCompanyRef | null): QuestionRecord[];
|
|
75
89
|
//# sourceMappingURL=questions.d.ts.map
|
|
@@ -7,8 +7,29 @@ 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";
|
|
11
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
10
12
|
import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
|
|
11
13
|
import { QUESTION_BODY_STORES, } from "./types.js";
|
|
14
|
+
export function assertQuestionBodySecretSafe(body) {
|
|
15
|
+
assertNoSecretText("question", body.question);
|
|
16
|
+
assertNoSecretText("context", body.context);
|
|
17
|
+
body.options.forEach((option, index) => assertNoSecretText(`option[${index}]`, option));
|
|
18
|
+
if (body.recommended !== undefined) {
|
|
19
|
+
assertNoSecretText("recommended", body.recommended);
|
|
20
|
+
}
|
|
21
|
+
if (body.notes !== undefined)
|
|
22
|
+
assertNoSecretText("answer notes", body.notes);
|
|
23
|
+
}
|
|
24
|
+
/** Errors that make an envelope ask unsafe or impossible to persist. */
|
|
25
|
+
export function questionRecordRefusalCode(err) {
|
|
26
|
+
if (!(err instanceof LanesError))
|
|
27
|
+
return null;
|
|
28
|
+
if (err.error === "secret_text_refused" || err.error === "question_company_unavailable") {
|
|
29
|
+
return err.error;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
12
33
|
export function parseQuestionBodyStore(value) {
|
|
13
34
|
if (typeof value === "string" &&
|
|
14
35
|
QUESTION_BODY_STORES.includes(value)) {
|
|
@@ -16,8 +37,93 @@ export function parseQuestionBodyStore(value) {
|
|
|
16
37
|
}
|
|
17
38
|
throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
|
|
18
39
|
}
|
|
19
|
-
export function bodyStoreForLane(lane) {
|
|
20
|
-
return lane.company ? "company" : "personal";
|
|
40
|
+
export function bodyStoreForLane(lane, resolvedCompany) {
|
|
41
|
+
return lane.company || lane.mesh_company || resolvedCompany ? "company" : "personal";
|
|
42
|
+
}
|
|
43
|
+
export function resolveQuestionCompany(lane, activeCompany, hqRoot) {
|
|
44
|
+
const laneCompany = lane.company ?? lane.mesh_company;
|
|
45
|
+
if (laneCompany !== null && laneCompany !== undefined) {
|
|
46
|
+
return requireQuestionCompany(laneCompany, lane.lane_id, "lane");
|
|
47
|
+
}
|
|
48
|
+
if (hqRoot) {
|
|
49
|
+
const projectCompany = resolveProjectQuestionCompany(hqRoot, lane);
|
|
50
|
+
if (projectCompany)
|
|
51
|
+
return projectCompany;
|
|
52
|
+
}
|
|
53
|
+
const company = activeCompany;
|
|
54
|
+
if (!company || !company.slug.trim() || !company.uid.trim()) {
|
|
55
|
+
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, resolution: "active_company_required" });
|
|
56
|
+
}
|
|
57
|
+
return company;
|
|
58
|
+
}
|
|
59
|
+
function requireQuestionCompany(company, laneId, source) {
|
|
60
|
+
if (!company.slug.trim() || !company.uid.trim()) {
|
|
61
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the ${source} company for lane ${laneId}: its slug or uid is missing`, { lane_id: laneId, resolution: `${source}_company_invalid` });
|
|
62
|
+
}
|
|
63
|
+
return company;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve project ownership only when exactly one manifest company contains
|
|
67
|
+
* the project's directory. Multiple matches are unresolved so the caller can
|
|
68
|
+
* continue to an explicit active-company choice without picking an owner.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveProjectQuestionCompany(hqRoot, lane) {
|
|
71
|
+
const projectId = lane.project_id?.trim();
|
|
72
|
+
if (!projectId)
|
|
73
|
+
return null;
|
|
74
|
+
if (projectId === "." || projectId === ".." || /[\\/\0]/.test(projectId)) {
|
|
75
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its project id is invalid`, { lane_id: lane.lane_id, resolution: "project_id_invalid" });
|
|
76
|
+
}
|
|
77
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
78
|
+
if (!fs.existsSync(manifestPath))
|
|
79
|
+
return null;
|
|
80
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
81
|
+
if (!companies) {
|
|
82
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: companies/manifest.yaml is unreadable or malformed`, { lane_id: lane.lane_id, resolution: "company_manifest_unavailable" });
|
|
83
|
+
}
|
|
84
|
+
const matches = [];
|
|
85
|
+
for (const [slug, entry] of Object.entries(companies)) {
|
|
86
|
+
if (!entry || typeof entry !== "object" || !slug || /[\\/\0]/.test(slug))
|
|
87
|
+
continue;
|
|
88
|
+
const projectDir = path.join(hqRoot, "companies", slug, "projects", projectId);
|
|
89
|
+
let isDirectory = false;
|
|
90
|
+
try {
|
|
91
|
+
isDirectory = fs.statSync(projectDir).isDirectory();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") {
|
|
95
|
+
throw new LanesError("question_company_unavailable", `Cannot verify project ownership for lane ${lane.lane_id}`, { lane_id: lane.lane_id, resolution: "project_directory_unavailable" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!isDirectory)
|
|
99
|
+
continue;
|
|
100
|
+
const uid = typeof entry.cloud_uid === "string" ? entry.cloud_uid.trim() : "";
|
|
101
|
+
matches.push({ slug, uid });
|
|
102
|
+
}
|
|
103
|
+
if (matches.length > 1)
|
|
104
|
+
return null;
|
|
105
|
+
const match = matches[0];
|
|
106
|
+
if (!match)
|
|
107
|
+
return null;
|
|
108
|
+
if (!match.uid) {
|
|
109
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its manifest company has no cloud uid`, { lane_id: lane.lane_id, resolution: "project_company_uid_missing" });
|
|
110
|
+
}
|
|
111
|
+
return requireQuestionCompany({ slug: match.slug, uid: match.uid }, lane.lane_id, "project");
|
|
112
|
+
}
|
|
113
|
+
/** Resolve the lane/project company before lazily consulting active HQ scope. */
|
|
114
|
+
export async function resolveQuestionCompanyWithFallback(hqRoot, lane, resolveActiveCompany) {
|
|
115
|
+
try {
|
|
116
|
+
return resolveQuestionCompany(lane, null, hqRoot);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (!(err instanceof LanesError) ||
|
|
120
|
+
err.error !== "question_company_unavailable" ||
|
|
121
|
+
err.details.resolution !== "active_company_required" ||
|
|
122
|
+
!resolveActiveCompany) {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
return resolveQuestionCompany(lane, await resolveActiveCompany(), hqRoot);
|
|
126
|
+
}
|
|
21
127
|
}
|
|
22
128
|
export function questionBodyRef(questionId) {
|
|
23
129
|
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
@@ -37,6 +143,7 @@ export function questionBodyAbsPath(hqRoot, question) {
|
|
|
37
143
|
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
38
144
|
}
|
|
39
145
|
export function writeQuestionBody(hqRoot, question, body) {
|
|
146
|
+
assertQuestionBodySecretSafe(body);
|
|
40
147
|
const store = parseQuestionBodyStore(question.body.store);
|
|
41
148
|
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
42
149
|
const serialized = `${JSON.stringify(body, null, 2)}\n`;
|
|
@@ -53,12 +160,14 @@ export function writeQuestionBody(hqRoot, question, body) {
|
|
|
53
160
|
* tenant body, then CAS routing_history with reason `asked`.
|
|
54
161
|
*/
|
|
55
162
|
export function raiseLaneQuestion(input) {
|
|
56
|
-
|
|
163
|
+
assertQuestionBodySecretSafe(input.body);
|
|
164
|
+
const company = resolveQuestionCompany(input.lane, input.activeCompany, input.hqRoot);
|
|
165
|
+
const store = bodyStoreForLane(input.lane, company);
|
|
57
166
|
let questionId = input.questionId;
|
|
58
167
|
if (!questionId) {
|
|
59
168
|
const created = createQuestion(input.hqRoot, {
|
|
60
169
|
lane_id: input.lane.lane_id,
|
|
61
|
-
company
|
|
170
|
+
company,
|
|
62
171
|
project_id: input.lane.project_id,
|
|
63
172
|
story_id: input.lane.story_id,
|
|
64
173
|
thread_id: input.lane.thread_id,
|
|
@@ -77,7 +186,7 @@ export function raiseLaneQuestion(input) {
|
|
|
77
186
|
createQuestion(input.hqRoot, {
|
|
78
187
|
question_id: questionId,
|
|
79
188
|
lane_id: input.lane.lane_id,
|
|
80
|
-
company
|
|
189
|
+
company,
|
|
81
190
|
project_id: input.lane.project_id,
|
|
82
191
|
story_id: input.lane.story_id,
|
|
83
192
|
thread_id: input.lane.thread_id,
|
|
@@ -97,7 +206,7 @@ export function raiseLaneQuestion(input) {
|
|
|
97
206
|
}
|
|
98
207
|
}
|
|
99
208
|
const stored = writeQuestionBody(input.hqRoot, {
|
|
100
|
-
company
|
|
209
|
+
company,
|
|
101
210
|
question_id: questionId,
|
|
102
211
|
body: { store, ref: "", digest: "" },
|
|
103
212
|
}, input.body);
|
|
@@ -149,34 +258,40 @@ export function questionBodyPathForLane(hqRoot, lane, questionId) {
|
|
|
149
258
|
* Free-text notes the operator attached with `questions answer --notes`.
|
|
150
259
|
* Read from the lane's own store (spec §6.5): the company store for a company
|
|
151
260
|
* lane, the personal store otherwise. Returns undefined when the answer has
|
|
152
|
-
* no notes_ref or the body carries no notes
|
|
153
|
-
*
|
|
261
|
+
* no notes_ref or the body carries no notes. An unavailable or malformed body
|
|
262
|
+
* fails the resume so the lane cannot continue without its answer notes.
|
|
154
263
|
*/
|
|
155
264
|
export function readAnswerNotes(hqRoot, lane, question) {
|
|
156
265
|
if (!question.answer?.notes_ref)
|
|
157
266
|
return undefined;
|
|
158
|
-
const
|
|
267
|
+
const bodyStore = parseQuestionBodyStore(question.body.store);
|
|
268
|
+
const company = question.company ?? (bodyStore === "company" ? lane.company ?? lane.mesh_company ?? null : null);
|
|
269
|
+
if (bodyStore === "personal" && company !== question.company) {
|
|
270
|
+
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 });
|
|
271
|
+
}
|
|
272
|
+
const filePath = questionBodyAbsPath(hqRoot, { ...question, company });
|
|
159
273
|
let text;
|
|
160
274
|
try {
|
|
161
275
|
text = fs.readFileSync(filePath, "utf8");
|
|
162
276
|
}
|
|
163
277
|
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;
|
|
278
|
+
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
279
|
}
|
|
168
280
|
let parsed;
|
|
169
281
|
try {
|
|
170
282
|
parsed = JSON.parse(text);
|
|
171
283
|
}
|
|
172
284
|
catch (err) {
|
|
173
|
-
|
|
174
|
-
|
|
285
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are not valid JSON`, { question_id: question.question_id });
|
|
286
|
+
}
|
|
287
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
288
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} have an invalid body`, { question_id: question.question_id });
|
|
175
289
|
}
|
|
176
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
177
|
-
return undefined;
|
|
178
290
|
const notes = parsed.notes;
|
|
179
|
-
|
|
291
|
+
if (typeof notes !== "string") {
|
|
292
|
+
throw new LanesError("question_answer_notes_invalid", `Answer notes for question ${question.question_id} are missing from the body`, { question_id: question.question_id });
|
|
293
|
+
}
|
|
294
|
+
return notes.length > 0 ? notes : undefined;
|
|
180
295
|
}
|
|
181
296
|
/**
|
|
182
297
|
* The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
|
|
@@ -200,6 +315,22 @@ export function answeredQuestionEntries(hqRoot, lane, questions) {
|
|
|
200
315
|
return entry;
|
|
201
316
|
});
|
|
202
317
|
}
|
|
318
|
+
export function markAnsweredQuestionsDelivered(hqRoot, laneId, questionIds, deliveredAt) {
|
|
319
|
+
if (!Number.isFinite(Date.parse(deliveredAt))) {
|
|
320
|
+
throw new LanesError("question_delivery_timestamp_invalid", "Cannot mark an answer delivered with an invalid timestamp", { lane_id: laneId });
|
|
321
|
+
}
|
|
322
|
+
return [...new Set(questionIds)].map((questionId) => retryUpdateQuestion(hqRoot, questionId, (current) => {
|
|
323
|
+
if (current.lane_id !== laneId || current.origin.id !== laneId) {
|
|
324
|
+
throw new LanesError("question_lane_mismatch", `Question ${questionId} does not belong to the resumed lane ${laneId}`, { lane_id: laneId, question_id: questionId });
|
|
325
|
+
}
|
|
326
|
+
if (current.status !== "answered" || !current.answer) {
|
|
327
|
+
throw new LanesError("question_not_answered", `Question ${questionId} is not answered and cannot be marked delivered`, { lane_id: laneId, question_id: questionId });
|
|
328
|
+
}
|
|
329
|
+
if (current.delivered_at)
|
|
330
|
+
return current;
|
|
331
|
+
return { ...current, delivered_at: deliveredAt };
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
203
334
|
export function envelopeQuestionId(laneId, envelopeRef, index) {
|
|
204
335
|
const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
|
|
205
336
|
const digest = createHash("sha256")
|
|
@@ -222,8 +353,15 @@ function envelopeQuestionBody(item) {
|
|
|
222
353
|
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
223
354
|
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
224
355
|
*/
|
|
225
|
-
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
|
|
356
|
+
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes, activeCompany) {
|
|
226
357
|
const items = envelope.questions ?? [];
|
|
358
|
+
const presentItems = items.filter((item) => item !== undefined);
|
|
359
|
+
if (presentItems.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
for (const item of presentItems) {
|
|
362
|
+
assertQuestionBodySecretSafe(envelopeQuestionBody(item));
|
|
363
|
+
}
|
|
364
|
+
resolveQuestionCompany(lane, activeCompany, hqRoot);
|
|
227
365
|
const raised = [];
|
|
228
366
|
for (let i = 0; i < items.length; i += 1) {
|
|
229
367
|
const item = items[i];
|
|
@@ -236,6 +374,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at,
|
|
|
236
374
|
raisedVia: "envelope",
|
|
237
375
|
at,
|
|
238
376
|
questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
|
|
377
|
+
activeCompany,
|
|
239
378
|
}));
|
|
240
379
|
}
|
|
241
380
|
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, resolveQuestionCompanyWithFallback, } 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,21 @@ 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
|
+
try {
|
|
1058
|
+
const activeCompany = await resolveQuestionCompanyWithFallback(hqRoot, questionLane, deps.resolveQuestionActiveCompany);
|
|
1059
|
+
raiseEnvelopeQuestions(hqRoot, questionLane, latest.envelope, latest.ref, nowIso, undefined, activeCompany);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
questionRefusalCode = questionRecordRefusalCode(err);
|
|
1063
|
+
if (questionRefusalCode === null)
|
|
1064
|
+
throw err;
|
|
1065
|
+
repairedClassification = {
|
|
1066
|
+
state: "failed",
|
|
1067
|
+
reason: `question_record_refused:${questionRefusalCode}`,
|
|
1068
|
+
};
|
|
1069
|
+
process.stderr.write(`hq lanes reconcile: terminal ask refused for ${lane.lane_id}: ${questionRefusalCode}\n`);
|
|
1070
|
+
}
|
|
1056
1071
|
}
|
|
1057
1072
|
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "running", (current) => {
|
|
1058
1073
|
const next = {
|
|
@@ -1100,7 +1115,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1100
1115
|
to: repairedClassification.state,
|
|
1101
1116
|
reason: repairedClassification.reason ?? "host_lost",
|
|
1102
1117
|
});
|
|
1103
|
-
if (latest?.envelope) {
|
|
1118
|
+
if (latest?.envelope && questionRefusalCode === null) {
|
|
1104
1119
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
1105
1120
|
if (mesh) {
|
|
1106
1121
|
const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
@@ -2030,10 +2045,12 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
|
|
|
2030
2045
|
}
|
|
2031
2046
|
catch (err) {
|
|
2032
2047
|
terminalizeModelUnsupported(hqRoot, lane, err);
|
|
2048
|
+
const errorName = err instanceof LanesError ? `${err.error}: ` : "";
|
|
2049
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2033
2050
|
markDirty(summary, {
|
|
2034
2051
|
kind: "lane",
|
|
2035
2052
|
id: lane.lane_id,
|
|
2036
|
-
why: `resume failed: ${
|
|
2053
|
+
why: `resume failed: ${errorName}${errorMessage}`,
|
|
2037
2054
|
});
|
|
2038
2055
|
}
|
|
2039
2056
|
}
|