@indigoai-us/hq-cli 5.191.0 → 5.192.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/command-catalog.generated.d.ts +18 -3
- package/dist/command-catalog.generated.js +24 -3
- package/dist/commands/agents.d.ts +3 -0
- package/dist/commands/agents.js +95 -3
- package/dist/commands/lanes.js +190 -3
- package/dist/commands/report.js +44 -1
- package/dist/lib/agents/model-catalog.d.ts +61 -0
- package/dist/lib/agents/model-catalog.js +275 -0
- package/dist/lib/lanes/admission.js +2 -0
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +7 -0
- package/dist/lib/lanes/outpost-dispatch.js +18 -0
- package/dist/lib/lanes/reconcile.d.ts +10 -1
- package/dist/lib/lanes/reconcile.js +112 -6
- package/dist/lib/lanes/spawn.js +7 -3
- package/dist/lib/lanes/types.d.ts +2 -1
- package/dist/lib/report-publish.d.ts +3 -2
- package/dist/lib/report-publish.js +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const AGENT_MODEL_PROVIDERS: readonly ["codex", "grok", "claude"];
|
|
2
|
+
export type AgentModelProvider = (typeof AGENT_MODEL_PROVIDERS)[number];
|
|
3
|
+
export interface AgentCatalogModel {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
visibility: string;
|
|
7
|
+
isNew: boolean;
|
|
8
|
+
authModes: string[];
|
|
9
|
+
efforts: string[];
|
|
10
|
+
selectable: boolean;
|
|
11
|
+
unavailableReason?: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentCatalogProvider {
|
|
14
|
+
models: AgentCatalogModel[];
|
|
15
|
+
defaultModel: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AgentModelCatalog {
|
|
18
|
+
providers: Record<AgentModelProvider, AgentCatalogProvider>;
|
|
19
|
+
generatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
export type AgentModelCatalogResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
catalog: AgentModelCatalog;
|
|
24
|
+
etag: string;
|
|
25
|
+
fetchedAt: number;
|
|
26
|
+
source: "network" | "cache";
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: "unavailable";
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: "request_failed";
|
|
33
|
+
status: number;
|
|
34
|
+
};
|
|
35
|
+
export interface ReadAgentModelCatalogOptions {
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
cacheFile?: string;
|
|
38
|
+
now?: () => number;
|
|
39
|
+
request?: (headers: Record<string, string>, signal: AbortSignal) => Promise<Response>;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/** Use the same effective state directory as the CLI's person-token cache. */
|
|
43
|
+
export declare function agentModelCatalogCacheFile(baseUrl?: string, home?: string, env?: NodeJS.ProcessEnv): string;
|
|
44
|
+
/** Parse the documented response without constraining forward-compatible string values. */
|
|
45
|
+
export declare function parseAgentModelCatalog(value: unknown): AgentModelCatalog | null;
|
|
46
|
+
/** Read the live endpoint, conditionally refresh it, and fall back to the last good cache. */
|
|
47
|
+
export declare function readAgentModelCatalog(token: string, options?: ReadAgentModelCatalogOptions): Promise<AgentModelCatalogResult>;
|
|
48
|
+
export type ModelSelectionResult = {
|
|
49
|
+
ok: true;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
reason: "unknown" | "not_selectable";
|
|
53
|
+
provider?: AgentModelProvider;
|
|
54
|
+
suggestions: string[];
|
|
55
|
+
};
|
|
56
|
+
/** Check an explicit model against the selected provider or inferred agents-v2 brain. */
|
|
57
|
+
export declare function validateAgentModelSelection(catalog: AgentModelCatalog, modelId: string, chosenProvider?: string): ModelSelectionResult;
|
|
58
|
+
export declare function modelSelectionErrorMessage(modelId: string, result: Extract<ModelSelectionResult, {
|
|
59
|
+
ok: false;
|
|
60
|
+
}>): string;
|
|
61
|
+
//# sourceMappingURL=model-catalog.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { DEFAULT_VAULT_API_URL, personTokenCacheFile } from "../../utils/cognito-session.js";
|
|
6
|
+
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
7
|
+
export const AGENT_MODEL_PROVIDERS = ["codex", "grok", "claude"];
|
|
8
|
+
const MODEL_CATALOG_REQUEST_TIMEOUT_MS = 15_000;
|
|
9
|
+
/** Use the same effective state directory as the CLI's person-token cache. */
|
|
10
|
+
export function agentModelCatalogCacheFile(baseUrl = DEFAULT_VAULT_API_URL, home = os.homedir(), env = process.env) {
|
|
11
|
+
const scope = createHash("sha256").update(baseUrl).digest("hex").slice(0, 16);
|
|
12
|
+
return path.join(path.dirname(personTokenCacheFile(home, env)), `agent-model-catalog-${scope}.json`);
|
|
13
|
+
}
|
|
14
|
+
/** Parse the documented response without constraining forward-compatible string values. */
|
|
15
|
+
export function parseAgentModelCatalog(value) {
|
|
16
|
+
if (!isRecord(value) || !isRecord(value.providers))
|
|
17
|
+
return null;
|
|
18
|
+
if (typeof value.generatedAt !== "string" || !Number.isFinite(Date.parse(value.generatedAt))) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const providers = {};
|
|
22
|
+
for (const provider of AGENT_MODEL_PROVIDERS) {
|
|
23
|
+
const rawProvider = value.providers[provider];
|
|
24
|
+
if (!isRecord(rawProvider) ||
|
|
25
|
+
!Array.isArray(rawProvider.models) ||
|
|
26
|
+
typeof rawProvider.defaultModel !== "string") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const models = [];
|
|
30
|
+
for (const rawModel of rawProvider.models) {
|
|
31
|
+
if (!isRecord(rawModel) ||
|
|
32
|
+
typeof rawModel.id !== "string" ||
|
|
33
|
+
!/^[A-Za-z0-9._-]{1,128}$/.test(rawModel.id) ||
|
|
34
|
+
typeof rawModel.label !== "string" ||
|
|
35
|
+
typeof rawModel.visibility !== "string" ||
|
|
36
|
+
typeof rawModel.isNew !== "boolean" ||
|
|
37
|
+
!isStringArray(rawModel.authModes) ||
|
|
38
|
+
!isStringArray(rawModel.efforts) ||
|
|
39
|
+
typeof rawModel.selectable !== "boolean" ||
|
|
40
|
+
(rawModel.unavailableReason !== undefined &&
|
|
41
|
+
rawModel.unavailableReason !== null &&
|
|
42
|
+
typeof rawModel.unavailableReason !== "string")) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
models.push({
|
|
46
|
+
id: rawModel.id,
|
|
47
|
+
label: rawModel.label,
|
|
48
|
+
visibility: rawModel.visibility,
|
|
49
|
+
isNew: rawModel.isNew,
|
|
50
|
+
authModes: rawModel.authModes,
|
|
51
|
+
efforts: rawModel.efforts,
|
|
52
|
+
selectable: rawModel.selectable,
|
|
53
|
+
...(rawModel.unavailableReason !== undefined
|
|
54
|
+
? { unavailableReason: rawModel.unavailableReason }
|
|
55
|
+
: {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!models.some((model) => model.id === rawProvider.defaultModel && model.selectable)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
providers[provider] = { models, defaultModel: rawProvider.defaultModel };
|
|
62
|
+
}
|
|
63
|
+
return { providers, generatedAt: value.generatedAt };
|
|
64
|
+
}
|
|
65
|
+
/** Read the live endpoint, conditionally refresh it, and fall back to the last good cache. */
|
|
66
|
+
export async function readAgentModelCatalog(token, options = {}) {
|
|
67
|
+
const baseUrl = options.baseUrl ?? DEFAULT_VAULT_API_URL;
|
|
68
|
+
const cacheFile = options.cacheFile ?? agentModelCatalogCacheFile(baseUrl);
|
|
69
|
+
const now = options.now ?? Date.now;
|
|
70
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? MODEL_CATALOG_REQUEST_TIMEOUT_MS);
|
|
71
|
+
const cached = readCache(cacheFile);
|
|
72
|
+
const headers = cached
|
|
73
|
+
? { "If-None-Match": cached.etag }
|
|
74
|
+
: {};
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = options.request
|
|
78
|
+
? await options.request(headers, signal)
|
|
79
|
+
: await vaultApiFetch({ token, baseUrl, path: "/v1/agents/models", headers, signal });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return cached ? fromCache(cached) : { ok: false, reason: "unavailable" };
|
|
83
|
+
}
|
|
84
|
+
if (response.status === 304) {
|
|
85
|
+
if (!cached)
|
|
86
|
+
return { ok: false, reason: "unavailable" };
|
|
87
|
+
const refreshed = {
|
|
88
|
+
...cached,
|
|
89
|
+
etag: response.headers.get("etag") ?? cached.etag,
|
|
90
|
+
fetchedAt: now(),
|
|
91
|
+
};
|
|
92
|
+
if (isStrongEtag(refreshed.etag))
|
|
93
|
+
writeCache(cacheFile, refreshed);
|
|
94
|
+
return fromCache(refreshed, "network");
|
|
95
|
+
}
|
|
96
|
+
if (response.status === 404 || response.status === 429 || response.status >= 500) {
|
|
97
|
+
return cached ? fromCache(cached) : { ok: false, reason: "unavailable" };
|
|
98
|
+
}
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
return { ok: false, reason: "request_failed", status: response.status };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const catalog = parseAgentModelCatalog(await response.json());
|
|
104
|
+
const etag = response.headers.get("etag");
|
|
105
|
+
if (!catalog || !etag || !isStrongEtag(etag)) {
|
|
106
|
+
return cached ? fromCache(cached) : { ok: false, reason: "unavailable" };
|
|
107
|
+
}
|
|
108
|
+
const entry = {
|
|
109
|
+
version: 1,
|
|
110
|
+
catalog,
|
|
111
|
+
etag,
|
|
112
|
+
fetchedAt: now(),
|
|
113
|
+
};
|
|
114
|
+
writeCache(cacheFile, entry);
|
|
115
|
+
return { ok: true, ...entry, source: "network" };
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return cached ? fromCache(cached) : { ok: false, reason: "unavailable" };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Check an explicit model against the selected provider or inferred agents-v2 brain. */
|
|
122
|
+
export function validateAgentModelSelection(catalog, modelId, chosenProvider) {
|
|
123
|
+
const provider = isCatalogProvider(chosenProvider) ? chosenProvider : undefined;
|
|
124
|
+
const inferred = providerFromModelId(modelId);
|
|
125
|
+
const providers = provider
|
|
126
|
+
? [provider]
|
|
127
|
+
: inferred
|
|
128
|
+
? [inferred]
|
|
129
|
+
: [...AGENT_MODEL_PROVIDERS];
|
|
130
|
+
const rows = providers.flatMap((name) => catalog.providers[name].models.map((model) => ({ provider: name, model })));
|
|
131
|
+
const exact = rows.find(({ model }) => model.id.toLowerCase() === modelId.toLowerCase());
|
|
132
|
+
if (exact?.model.selectable)
|
|
133
|
+
return { ok: true };
|
|
134
|
+
const suggestions = closestSelectableModels(modelId, rows.filter(({ model }) => model.selectable).map(({ model }) => model.id));
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: exact ? "not_selectable" : "unknown",
|
|
138
|
+
...(providers.length === 1 ? { provider: providers[0] } : {}),
|
|
139
|
+
suggestions,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function modelSelectionErrorMessage(modelId, result) {
|
|
143
|
+
const scope = result.provider ? `the ${result.provider} catalog` : "the model catalog";
|
|
144
|
+
const problem = result.reason === "not_selectable" ? "not selectable in" : "not listed in";
|
|
145
|
+
const suggestion = result.suggestions.length
|
|
146
|
+
? ` Close matches: ${result.suggestions.join(", ")}.`
|
|
147
|
+
: "";
|
|
148
|
+
const command = result.provider
|
|
149
|
+
? ` Run \`hq agents models --provider ${result.provider}\` to see available models.`
|
|
150
|
+
: " Run `hq agents models` to see available models.";
|
|
151
|
+
return `Model '${modelId}' is ${problem} ${scope}.${suggestion}${command}`;
|
|
152
|
+
}
|
|
153
|
+
function providerFromModelId(modelId) {
|
|
154
|
+
const normalized = modelId.toLowerCase();
|
|
155
|
+
if (normalized.startsWith("gpt-"))
|
|
156
|
+
return "codex";
|
|
157
|
+
if (normalized.startsWith("grok-"))
|
|
158
|
+
return "grok";
|
|
159
|
+
if (normalized.startsWith("claude-"))
|
|
160
|
+
return "claude";
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function closestSelectableModels(modelId, candidates) {
|
|
164
|
+
const needle = modelId.toLowerCase();
|
|
165
|
+
return candidates
|
|
166
|
+
.map((id) => ({
|
|
167
|
+
id,
|
|
168
|
+
distance: editDistance(needle, id.toLowerCase()),
|
|
169
|
+
prefix: commonPrefixLength(needle, id.toLowerCase()),
|
|
170
|
+
}))
|
|
171
|
+
.filter(({ distance, id }) => distance <= Math.max(2, Math.floor(needle.length / 4)) ||
|
|
172
|
+
id.toLowerCase().startsWith(needle))
|
|
173
|
+
.sort((a, b) => a.distance - b.distance || b.prefix - a.prefix || a.id.localeCompare(b.id))
|
|
174
|
+
.slice(0, 3)
|
|
175
|
+
.map(({ id }) => id);
|
|
176
|
+
}
|
|
177
|
+
function editDistance(left, right) {
|
|
178
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
179
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
180
|
+
const current = [i];
|
|
181
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
182
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
|
|
183
|
+
}
|
|
184
|
+
previous = current;
|
|
185
|
+
}
|
|
186
|
+
return previous[right.length];
|
|
187
|
+
}
|
|
188
|
+
function commonPrefixLength(left, right) {
|
|
189
|
+
let index = 0;
|
|
190
|
+
while (index < left.length && index < right.length && left[index] === right[index])
|
|
191
|
+
index += 1;
|
|
192
|
+
return index;
|
|
193
|
+
}
|
|
194
|
+
function readCache(file) {
|
|
195
|
+
let serialized;
|
|
196
|
+
try {
|
|
197
|
+
serialized = fs.readFileSync(file, "utf8");
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (isMissingFile(error))
|
|
201
|
+
return null;
|
|
202
|
+
console.warn("Unable to read the model catalog cache; continuing without cached data.");
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
let value;
|
|
206
|
+
try {
|
|
207
|
+
value = JSON.parse(serialized);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
console.warn("Ignoring an invalid model catalog cache file.");
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (!isRecord(value) || value.version !== 1 || !isStrongEtag(value.etag)) {
|
|
214
|
+
console.warn("Ignoring an invalid model catalog cache file.");
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (typeof value.fetchedAt !== "number" || !Number.isFinite(value.fetchedAt)) {
|
|
218
|
+
console.warn("Ignoring an invalid model catalog cache file.");
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const catalog = parseAgentModelCatalog(value.catalog);
|
|
222
|
+
if (!catalog) {
|
|
223
|
+
console.warn("Ignoring an invalid model catalog cache file.");
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return { version: 1, catalog, etag: value.etag, fetchedAt: value.fetchedAt };
|
|
227
|
+
}
|
|
228
|
+
function writeCache(file, cache) {
|
|
229
|
+
const temporary = `${file}.${process.pid}.${cache.fetchedAt}.tmp`;
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
232
|
+
fs.writeFileSync(temporary, JSON.stringify(cache), { encoding: "utf8", mode: 0o600 });
|
|
233
|
+
fs.renameSync(temporary, file);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
console.warn("Unable to save the model catalog cache; offline use may be unavailable.");
|
|
237
|
+
try {
|
|
238
|
+
fs.rmSync(temporary, { force: true });
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!isMissingFile(error)) {
|
|
242
|
+
console.warn("Unable to remove a temporary model catalog cache file.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function fromCache(cache, source = "cache") {
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
catalog: cache.catalog,
|
|
251
|
+
etag: cache.etag,
|
|
252
|
+
fetchedAt: cache.fetchedAt,
|
|
253
|
+
source,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function isStrongEtag(value) {
|
|
257
|
+
return typeof value === "string" &&
|
|
258
|
+
value.length >= 2 &&
|
|
259
|
+
value.startsWith('"') &&
|
|
260
|
+
value.endsWith('"') &&
|
|
261
|
+
!/[\r\n]/.test(value);
|
|
262
|
+
}
|
|
263
|
+
function isMissingFile(error) {
|
|
264
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
265
|
+
}
|
|
266
|
+
function isCatalogProvider(value) {
|
|
267
|
+
return value !== undefined && AGENT_MODEL_PROVIDERS.includes(value);
|
|
268
|
+
}
|
|
269
|
+
function isStringArray(value) {
|
|
270
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
271
|
+
}
|
|
272
|
+
function isRecord(value) {
|
|
273
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=model-catalog.js.map
|
|
@@ -135,6 +135,8 @@ export function hasActivePendingResume(lane, nowMs) {
|
|
|
135
135
|
const pending = lane.pending_resume;
|
|
136
136
|
if (!pending)
|
|
137
137
|
return false;
|
|
138
|
+
if (lane.state === "queued" && pending.trigger === "answered_questions")
|
|
139
|
+
return false;
|
|
138
140
|
const reservedAt = Date.parse(pending.at);
|
|
139
141
|
const ageMs = nowMs - reservedAt;
|
|
140
142
|
return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
|
|
@@ -42,8 +42,8 @@ export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PRO
|
|
|
42
42
|
export type { AccountResolveContext, AccountStatus, AuthState, ResolvedAccount, } from "./accounts.js";
|
|
43
43
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
44
44
|
export type { LivenessDeps, LivenessEventKind, LivenessIndex, SessionIndexEntry, } from "./liveness.js";
|
|
45
|
-
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
46
|
-
export type { ReconcileChange, ReconcileDeps, ReconcileHost, ReconcileSkip, ReconcileSummary, } from "./reconcile.js";
|
|
45
|
+
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
46
|
+
export type { AnsweredLaneWakeResult, ReconcileChange, ReconcileDeps, ReconcileHost, ReconcileSkip, ReconcileSummary, } from "./reconcile.js";
|
|
47
47
|
export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
|
|
48
48
|
export type { ProviderPathEnv, ReconcileInstallOpts, ReconcileInstallResult, RunCommand, RunCommandResult, } from "./reconcile-install.js";
|
|
49
49
|
export { unitPathEnv } from "./reconcile-install.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export { LANE_MESH_EVENTS, WIRE_EVENT_KINDS, createLaneMesh, emitLaneDecisionEve
|
|
|
23
23
|
export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO, STARVATION_MAX_DEFERRALS, STARVATION_MAX_MS, applyThrottleDecision, busyFractionFromDeltas, cpuTimesSnapshot, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, parseMemAvailableKb, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyFraction, starvationBoundReached, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
24
24
|
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
25
25
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
26
|
-
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
26
|
+
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
27
27
|
export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
|
|
28
28
|
export { unitPathEnv } from "./reconcile-install.js";
|
|
29
29
|
export { PROVIDER_REJECTED_REASON, PROVIDER_REJECTION_MESSAGE_CAP, parseClaudeResultRejection, parseCodexTurnFailed, parseGrokErrorOutput, parseProviderRejection, readProviderRejection, retryableFromStatus, } from "./provider-rejection.js";
|
|
@@ -41,6 +41,13 @@ export interface OutpostLaneCommand {
|
|
|
41
41
|
grantContext?: string;
|
|
42
42
|
compact?: boolean;
|
|
43
43
|
}
|
|
44
|
+
export declare function buildOutpostQuestionAnswerCommand(input: {
|
|
45
|
+
questionId: string;
|
|
46
|
+
choice?: string;
|
|
47
|
+
freeform?: string;
|
|
48
|
+
notes?: string;
|
|
49
|
+
hqBinary?: string;
|
|
50
|
+
}): string;
|
|
44
51
|
export interface OutpostExecPort {
|
|
45
52
|
stageExecInput: (outpostId?: string) => Promise<{
|
|
46
53
|
putUrl: string;
|
|
@@ -10,6 +10,24 @@ import { OutpostsClient, joinCommandParts, withRemoteHqDir } from "@indigoai-us/
|
|
|
10
10
|
import { LanesError } from "./errors.js";
|
|
11
11
|
export const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
|
|
12
12
|
export const PICKUP_WAIT_MS = PICKUP_EXEC_TIMEOUT_SECONDS * 1000;
|
|
13
|
+
export function buildOutpostQuestionAnswerCommand(input) {
|
|
14
|
+
const args = [
|
|
15
|
+
input.hqBinary?.trim() || "hq",
|
|
16
|
+
"lanes",
|
|
17
|
+
"questions",
|
|
18
|
+
"answer",
|
|
19
|
+
input.questionId,
|
|
20
|
+
];
|
|
21
|
+
for (const [flag, value] of [
|
|
22
|
+
["--choice", input.choice],
|
|
23
|
+
["--freeform", input.freeform],
|
|
24
|
+
["--notes", input.notes],
|
|
25
|
+
]) {
|
|
26
|
+
if (value !== undefined)
|
|
27
|
+
args.push(flag, value);
|
|
28
|
+
}
|
|
29
|
+
return withRemoteHqDir(joinCommandParts(args));
|
|
30
|
+
}
|
|
13
31
|
export function parseOutpostPickupPayload(raw) {
|
|
14
32
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
15
33
|
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload must be a JSON object", {});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { type LivenessDeps } from "./liveness.js";
|
|
9
9
|
import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
|
|
10
10
|
import { type ThrottleDeps } from "./throttle.js";
|
|
11
|
-
import type { LaneRecord, LivenessState, QuestionRecord, SeniorRef } from "./types.js";
|
|
11
|
+
import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
|
|
12
12
|
import { type LaneMesh } from "./mesh-events.js";
|
|
13
13
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
14
14
|
export declare const QUESTION_AGE_MS: number;
|
|
@@ -102,4 +102,13 @@ export declare function runPassBody(hqRoot: string, deps: ReconcileDeps): Promis
|
|
|
102
102
|
* `ok: false` rather than being swallowed.
|
|
103
103
|
*/
|
|
104
104
|
export declare function runReconcilePass(hqRoot: string, deps: ReconcileDeps): Promise<ReconcileSummary>;
|
|
105
|
+
export interface AnsweredLaneWakeResult {
|
|
106
|
+
status: "resumed" | "queued" | "awaiting_answers" | "failed";
|
|
107
|
+
lane_state: LaneRecord["state"];
|
|
108
|
+
queued_reason?: QueuedReason;
|
|
109
|
+
remaining_question_ids?: string[];
|
|
110
|
+
error?: string;
|
|
111
|
+
}
|
|
112
|
+
/** Run the reconcile answer-resume path for one lane before questions answer returns. */
|
|
113
|
+
export declare function wakeAnsweredLaneNow(hqRoot: string, laneId: string, deps: ReconcileDeps): Promise<AnsweredLaneWakeResult>;
|
|
105
114
|
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -1284,9 +1284,11 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1284
1284
|
writeThrottleState(filePath, applyThrottleDecision(base, applied, nowMs), throttleDeps);
|
|
1285
1285
|
}
|
|
1286
1286
|
}
|
|
1287
|
-
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
1287
|
+
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneIds) {
|
|
1288
1288
|
const config = loadLanesConfig(hqRoot);
|
|
1289
|
-
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
|
|
1289
|
+
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
|
|
1290
|
+
laneRunsHere(lane, deps.host) &&
|
|
1291
|
+
(onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)));
|
|
1290
1292
|
if (awaiting.length === 0)
|
|
1291
1293
|
return;
|
|
1292
1294
|
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
@@ -1307,10 +1309,24 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1307
1309
|
const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps);
|
|
1308
1310
|
throttleDecision = throttle.decision;
|
|
1309
1311
|
}
|
|
1310
|
-
if (!throttleDecision.admit)
|
|
1311
|
-
return;
|
|
1312
1312
|
const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, deps.spawnLaneDeps));
|
|
1313
1313
|
const nowMs = deps.now().getTime();
|
|
1314
|
+
const fallbackAnswerAt = new Date(nowMs).toISOString();
|
|
1315
|
+
const answerAtByLane = new Map();
|
|
1316
|
+
for (const question of questions) {
|
|
1317
|
+
if (question.status !== "answered")
|
|
1318
|
+
continue;
|
|
1319
|
+
const at = question.answer?.answered_at ?? question.asked_at;
|
|
1320
|
+
const previous = answerAtByLane.get(question.origin.id);
|
|
1321
|
+
if (previous === undefined || Date.parse(at) > Date.parse(previous)) {
|
|
1322
|
+
answerAtByLane.set(question.origin.id, at);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const latestAnswerAt = (lane) => answerAtByLane.get(lane.lane_id) ?? fallbackAnswerAt;
|
|
1326
|
+
awaiting.sort((a, b) => {
|
|
1327
|
+
const byAnswerTime = Date.parse(latestAnswerAt(a)) - Date.parse(latestAnswerAt(b));
|
|
1328
|
+
return byAnswerTime || compareLaneQueueOrder(a, b);
|
|
1329
|
+
});
|
|
1314
1330
|
const capacityLanes = lanes.map((lane) => {
|
|
1315
1331
|
try {
|
|
1316
1332
|
return readLane(hqRoot, lane.lane_id);
|
|
@@ -1336,7 +1352,55 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1336
1352
|
if (current.worker_profile !== undefined) {
|
|
1337
1353
|
profileMaxConcurrent = loadWorkerProfile(hqRoot, current.worker_profile).meta.max_concurrent;
|
|
1338
1354
|
}
|
|
1339
|
-
|
|
1355
|
+
const capReason = laneAdmissionCapReason(running, current, config, deps.host, profileMaxConcurrent, nowMs);
|
|
1356
|
+
const answeredAt = latestAnswerAt(current);
|
|
1357
|
+
const queueCandidate = { ...current, state: "queued", queued_at: answeredAt };
|
|
1358
|
+
const earlierQueued = capacityLanes.some((queued) => queued.lane_id !== current.lane_id &&
|
|
1359
|
+
queued.state === "queued" &&
|
|
1360
|
+
queued.provider === current.provider &&
|
|
1361
|
+
laneRunsHere(queued, deps.host) &&
|
|
1362
|
+
compareLaneQueueOrder(queued, queueCandidate) < 0);
|
|
1363
|
+
const queueReason = !throttleDecision.admit
|
|
1364
|
+
? "throttle"
|
|
1365
|
+
: capReason ?? (earlierQueued ? "queue_position" : undefined);
|
|
1366
|
+
if (queueReason !== undefined) {
|
|
1367
|
+
const queuedCount = capacityLanes.filter((queued) => queued.state === "queued" &&
|
|
1368
|
+
queued.provider === current.provider &&
|
|
1369
|
+
laneRunsHere(queued, deps.host) &&
|
|
1370
|
+
!hasFreshAdmissionReservation(queued, nowMs)).length;
|
|
1371
|
+
const maxQueued = config.providers[current.provider].max_queued;
|
|
1372
|
+
if (queuedCount >= maxQueued) {
|
|
1373
|
+
summary.skipped.push({
|
|
1374
|
+
kind: "lane",
|
|
1375
|
+
id: current.lane_id,
|
|
1376
|
+
why: `lane_queue_full: ${current.provider} queue has ${queuedCount}/${maxQueued} lanes`,
|
|
1377
|
+
});
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (!deps.dryRun)
|
|
1382
|
+
writeAnsweredQuestions(hqRoot, current, questions);
|
|
1383
|
+
if (queueReason !== undefined) {
|
|
1384
|
+
const queued = casLaneIf(hqRoot, current.lane_id, (latest) => latest.state === "awaiting_input", (latest) => ({
|
|
1385
|
+
...latest,
|
|
1386
|
+
state: "queued",
|
|
1387
|
+
queued_reason: queueReason,
|
|
1388
|
+
queued_at: answeredAt,
|
|
1389
|
+
pending_resume: {
|
|
1390
|
+
...(latest.provider_session_id
|
|
1391
|
+
? { session_id: latest.provider_session_id }
|
|
1392
|
+
: {}),
|
|
1393
|
+
trigger: "answered_questions",
|
|
1394
|
+
at: answeredAt,
|
|
1395
|
+
},
|
|
1396
|
+
}), deps.dryRun);
|
|
1397
|
+
summary.resumed.push({
|
|
1398
|
+
id: queued.record.lane_id,
|
|
1399
|
+
from: "awaiting_input",
|
|
1400
|
+
to: "queued",
|
|
1401
|
+
reason: queueReason,
|
|
1402
|
+
});
|
|
1403
|
+
capacityLanes.push(queued.record);
|
|
1340
1404
|
continue;
|
|
1341
1405
|
}
|
|
1342
1406
|
if (deps.dryRun) {
|
|
@@ -1348,7 +1412,6 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1348
1412
|
running.push({ ...current, state: "running" });
|
|
1349
1413
|
continue;
|
|
1350
1414
|
}
|
|
1351
|
-
writeAnsweredQuestions(hqRoot, lane, questions);
|
|
1352
1415
|
const started = spawnFn(hqRoot, lane);
|
|
1353
1416
|
if (started.state === "running")
|
|
1354
1417
|
running.push(started);
|
|
@@ -1376,4 +1439,47 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1376
1439
|
export async function runReconcilePass(hqRoot, deps) {
|
|
1377
1440
|
return withReconcileLock(hqRoot, async () => runPassBody(hqRoot, deps));
|
|
1378
1441
|
}
|
|
1442
|
+
/** Run the reconcile answer-resume path for one lane before questions answer returns. */
|
|
1443
|
+
export async function wakeAnsweredLaneNow(hqRoot, laneId, deps) {
|
|
1444
|
+
const summary = emptySummary(deps.dryRun);
|
|
1445
|
+
await withReconcileLock(hqRoot, async () => {
|
|
1446
|
+
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
1447
|
+
const lanes = loadLaneRecords(hqRoot, summary);
|
|
1448
|
+
const questions = loadQuestionRecords(hqRoot, summary);
|
|
1449
|
+
if (!lanes.some((lane) => lane.lane_id === laneId)) {
|
|
1450
|
+
throw new LanesError("lane_not_found", `Lane ${laneId} was not found`, { lane_id: laneId });
|
|
1451
|
+
}
|
|
1452
|
+
await resumeAwaiting(hqRoot, lanes, questions, summary, deps, new Set([laneId]));
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
const latest = readLane(hqRoot, laneId);
|
|
1456
|
+
const laneQuestions = loadQuestionRecords(hqRoot, emptySummary(false)).filter((question) => question.origin.id === laneId);
|
|
1457
|
+
const remaining = laneQuestions
|
|
1458
|
+
.filter((question) => question.status === "pending")
|
|
1459
|
+
.map((question) => question.question_id)
|
|
1460
|
+
.sort();
|
|
1461
|
+
if (latest.state === "running") {
|
|
1462
|
+
return { status: "resumed", lane_state: latest.state };
|
|
1463
|
+
}
|
|
1464
|
+
if (latest.state === "queued" && latest.pending_resume?.trigger === "answered_questions") {
|
|
1465
|
+
return {
|
|
1466
|
+
status: "queued",
|
|
1467
|
+
lane_state: latest.state,
|
|
1468
|
+
...(latest.queued_reason !== undefined ? { queued_reason: latest.queued_reason } : {}),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (latest.state === "awaiting_input" && remaining.length > 0) {
|
|
1472
|
+
return {
|
|
1473
|
+
status: "awaiting_answers",
|
|
1474
|
+
lane_state: latest.state,
|
|
1475
|
+
remaining_question_ids: remaining,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const failure = summary.skipped.find((skip) => skip.kind === "lane" && skip.id === laneId);
|
|
1479
|
+
return {
|
|
1480
|
+
status: "failed",
|
|
1481
|
+
lane_state: latest.state,
|
|
1482
|
+
error: failure?.why ?? `Lane ${laneId} did not leave awaiting_input after its questions were answered`,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1379
1485
|
//# sourceMappingURL=reconcile.js.map
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1553,9 +1553,13 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1553
1553
|
// state and the caller sees the error. Admission and question resume keep
|
|
1554
1554
|
// today's behaviour of naming the terminal reason on the record.
|
|
1555
1555
|
const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
|
|
1556
|
-
const plan = pendingResume
|
|
1557
|
-
?
|
|
1558
|
-
|
|
1556
|
+
const plan = pendingResume?.trigger === "answered_questions"
|
|
1557
|
+
? pendingResume.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
1558
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
1559
|
+
: planLaneLaunch(existing, { kind: "answered_questions" })
|
|
1560
|
+
: pendingResume?.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
1561
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
1562
|
+
: planLaneLaunch(existing, trigger);
|
|
1559
1563
|
if (existing.host.kind === "outpost") {
|
|
1560
1564
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
1561
1565
|
}
|
|
@@ -508,7 +508,8 @@ export interface AccountHistoryEntry {
|
|
|
508
508
|
reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "operator_move";
|
|
509
509
|
}
|
|
510
510
|
export interface PendingResume {
|
|
511
|
-
|
|
511
|
+
/** Fresh answered-question launches need no previous session id. */
|
|
512
|
+
session_id?: string;
|
|
512
513
|
trigger: "answered_questions" | "message" | "resume";
|
|
513
514
|
resumed_from?: "interrupted" | "done" | "blocked" | "failed";
|
|
514
515
|
text?: string;
|
|
@@ -106,6 +106,7 @@ export declare function ownerFailureNotice(input: {
|
|
|
106
106
|
body: string;
|
|
107
107
|
} | null;
|
|
108
108
|
export interface ReportAdoptDependencies {
|
|
109
|
+
preflightJobCreation(): Promise<void>;
|
|
109
110
|
createJob(input: {
|
|
110
111
|
prompt: string;
|
|
111
112
|
schedule: {
|
|
@@ -124,8 +125,8 @@ export interface ReportAdoptDependencies {
|
|
|
124
125
|
patch(report: ChannelReport, patch: ReportPatch): Promise<ChannelReport>;
|
|
125
126
|
}
|
|
126
127
|
export declare class ReportPublishError extends Error {
|
|
127
|
-
readonly code: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "revision-conflict";
|
|
128
|
-
constructor(message: string, code?: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "revision-conflict");
|
|
128
|
+
readonly code: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "job-execution-disabled" | "revision-conflict";
|
|
129
|
+
constructor(message: string, code?: "already-running" | "source-invalid" | "deploy-failed" | "job-failed" | "job-execution-disabled" | "revision-conflict");
|
|
129
130
|
}
|
|
130
131
|
/** A deliberately boring, token-safe failure reason stored on the report. */
|
|
131
132
|
export declare function plainFailureReason(error: unknown): string;
|
|
@@ -41,7 +41,7 @@ export class ReportPublishError extends Error {
|
|
|
41
41
|
/** A deliberately boring, token-safe failure reason stored on the report. */
|
|
42
42
|
export function plainFailureReason(error) {
|
|
43
43
|
if (error instanceof ReportPublishError) {
|
|
44
|
-
if (error.code === "source-invalid" || error.code === "job-failed" || error.code === "revision-conflict")
|
|
44
|
+
if (error.code === "source-invalid" || error.code === "job-failed" || error.code === "job-execution-disabled" || error.code === "revision-conflict")
|
|
45
45
|
return error.message.slice(0, 240);
|
|
46
46
|
if (error.code === "already-running")
|
|
47
47
|
return "already running";
|
|
@@ -231,9 +231,11 @@ export async function adoptReport(report, deps) {
|
|
|
231
231
|
throw new ReportPublishError("Report schedule is missing.", "source-invalid");
|
|
232
232
|
}
|
|
233
233
|
try {
|
|
234
|
+
const cron = reportJobCron(schedule);
|
|
235
|
+
if (cron)
|
|
236
|
+
await deps.preflightJobCreation();
|
|
234
237
|
if (report.jobId)
|
|
235
238
|
await deps.cancelJob(report.jobId);
|
|
236
|
-
const cron = reportJobCron(schedule);
|
|
237
239
|
if (!cron) {
|
|
238
240
|
return deps.patch(report, { jobId: null, jobState: "adopted", jobStateReason: null });
|
|
239
241
|
}
|