@indigoai-us/hq-cli 5.157.0 → 5.159.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.
@@ -27,7 +27,7 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
27
27
  * is owner-approved telemetry for the exact hook script and may include a
28
28
  * company directory; it is never a substitute for hook input or a transcript.
29
29
  */
30
- export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
30
+ export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "spawn_ms", "slow_child", "slow_child_ms", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
31
31
  export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
32
32
  export type HookSequenceEntry = {
33
33
  script: string;
@@ -45,11 +45,26 @@ const HOOK_TIMEOUT_WARNING_METRIC = "hq.hook_timeout_warning";
45
45
  const HOOK_TIMEOUT_EXCEEDED_METRIC = "hq.hook_timeout_exceeded";
46
46
  const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
47
47
  const HOOK_TIMEOUT_WARNING_FINGERPRINT = "hook-timeout";
48
+ const HOOK_TIMEOUT_MACHINE_WIDE_FINGERPRINT = "hq-hook-slow";
48
49
  const MAX_HOOK_SEQUENCE_ENTRIES = 20;
49
50
  const HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
50
51
  const RATE_LIMIT_LOCK_WAIT_MS = 500;
51
52
  const RATE_LIMIT_LOCK_POLL_MS = 5;
52
53
  const RATE_LIMIT_LOCK_STALE_MS = 5_000;
54
+ const KNOWN_HOOK_SCRIPTS = new Set([
55
+ "master-hook.sh",
56
+ "hook-gate.sh",
57
+ "hook-timeout-watchdog.sh",
58
+ "check-hq-update.sh",
59
+ "block-core-writes-bash.sh",
60
+ "block-core-writes.sh",
61
+ "block-hq-worktree-session.sh",
62
+ "inject-policy-on-trigger.sh",
63
+ "inject-local-context.sh",
64
+ "session-title.sh",
65
+ "45-lanes-senior-monitor.sh",
66
+ "lanes-senior-monitor-stop-gate.sh",
67
+ ]);
53
68
  /**
54
69
  * The only hook diagnostics that can be attached as metadata. The list omits
55
70
  * command text, prompts, payloads, company names, and repositories. `hook_path`
@@ -71,6 +86,9 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
71
86
  "hq_version",
72
87
  "platform",
73
88
  "load_average",
89
+ "spawn_ms",
90
+ "slow_child",
91
+ "slow_child_ms",
74
92
  "exit_code",
75
93
  "final_elapsed_ms",
76
94
  "bash_env_set",
@@ -223,6 +241,9 @@ export function parseSentryReportEvent(stdin) {
223
241
  ? boundedHookSequence(rawMetadata[key])
224
242
  : rawMetadata[key];
225
243
  }
244
+ if (typeof metadata.slow_child === "string") {
245
+ metadata.slow_child = safeSlowChild(metadata);
246
+ }
226
247
  return {
227
248
  code: 0,
228
249
  event: {
@@ -311,6 +332,63 @@ function loadBucket(loadAverage) {
311
332
  return "10-20";
312
333
  return ">=20";
313
334
  }
335
+ function parsedLoadAverage(metadata) {
336
+ const value = metadata.load_average;
337
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
338
+ return value;
339
+ if (typeof value !== "string")
340
+ return undefined;
341
+ if (value === "unavailable")
342
+ return "unavailable";
343
+ const match = /^\s*\{?\s*([0-9]+(?:\.[0-9]+)?)/.exec(value);
344
+ if (!match)
345
+ return undefined;
346
+ const parsed = Number(match[1]);
347
+ return Number.isFinite(parsed) ? parsed : undefined;
348
+ }
349
+ function operatingSystemName(metadata) {
350
+ const platform = stringMetadata(metadata, "platform", "").toLowerCase();
351
+ if (/windows|win32|mingw|msys|cygwin/.test(platform))
352
+ return "windows";
353
+ if (/darwin|macos|mac os/.test(platform))
354
+ return "macos";
355
+ if (/linux/.test(platform))
356
+ return "linux";
357
+ return "other";
358
+ }
359
+ function spawnMsBucket(metadata) {
360
+ const value = metadata.spawn_ms;
361
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
362
+ return "unavailable";
363
+ if (value < 100)
364
+ return "<100ms";
365
+ if (value < 500)
366
+ return "100-499ms";
367
+ if (value < 1_000)
368
+ return "500-999ms";
369
+ return ">=1000ms";
370
+ }
371
+ function safeSlowChild(metadata) {
372
+ const value = metadata.slow_child;
373
+ if (typeof value !== "string" || value.length === 0)
374
+ return "other";
375
+ const script = basenameFromPath(value);
376
+ return KNOWN_HOOK_SCRIPTS.has(script) ? script : "other";
377
+ }
378
+ function isMachineWideHookSlowness(metadata) {
379
+ const spawnMs = metadata.spawn_ms;
380
+ if (typeof spawnMs === "number" && Number.isFinite(spawnMs) && spawnMs >= 500)
381
+ return true;
382
+ if (!Array.isArray(metadata.hook_sequence))
383
+ return false;
384
+ const slowScripts = new Set();
385
+ for (const entry of metadata.hook_sequence) {
386
+ if (entry.ms < 20_000 || !SAFE_HOOK_SCRIPT.test(entry.script))
387
+ continue;
388
+ slowScripts.add(entry.script);
389
+ }
390
+ return slowScripts.size >= 3;
391
+ }
314
392
  function bashEnvTag(metadata) {
315
393
  const value = metadata.bash_env_set;
316
394
  if (value === true || value === "set")
@@ -330,7 +408,8 @@ function hookTimeoutTags(event) {
330
408
  : undefined;
331
409
  const shell = stringMetadata(metadata, "shell");
332
410
  const nproc = metadata.nproc;
333
- const loadAverage = metadata.load_average;
411
+ const loadAverage = parsedLoadAverage(metadata);
412
+ const slowChildMs = metadata.slow_child_ms;
334
413
  return {
335
414
  hook_event: hookEvent(metadata),
336
415
  hook_script: hookScript(metadata),
@@ -346,7 +425,14 @@ function hookTimeoutTags(event) {
346
425
  shell: safeTagValue(shell),
347
426
  bash_env: bashEnvTag(metadata),
348
427
  cwd_kind: safeMetadataTag(metadata, "cwd_kind"),
349
- load_bucket: typeof loadAverage === "number" ? loadBucket(loadAverage) : "unknown",
428
+ os_name: operatingSystemName(metadata),
429
+ load_bucket: loadAverage === "unavailable" ? "unavailable" : loadBucket(loadAverage),
430
+ spawn_ms_bucket: spawnMsBucket(metadata),
431
+ slow_child: safeSlowChild(metadata),
432
+ slow_child_ms_bucket: typeof slowChildMs === "number" && Number.isFinite(slowChildMs)
433
+ ? elapsedBucket(slowChildMs)
434
+ : "unknown",
435
+ machine_wide: isMachineWideHookSlowness(metadata) ? "true" : "false",
350
436
  nproc: typeof nproc === "number" || typeof nproc === "string"
351
437
  ? safeTagValue(String(nproc), SAFE_NPROC)
352
438
  : "unknown",
@@ -393,7 +479,20 @@ function hookTimeoutExceededOverrides(event) {
393
479
  const eventName = hookEvent(event.metadata);
394
480
  if (!isHookTimeoutEvent(event))
395
481
  return undefined;
396
- if (hookTimeoutKind(event) === "warning") {
482
+ const kind = hookTimeoutKind(event);
483
+ if (isMachineWideHookSlowness(event.metadata)) {
484
+ return {
485
+ level: "warning",
486
+ fingerprint: [
487
+ HOOK_TIMEOUT_MACHINE_WIDE_FINGERPRINT,
488
+ "machine-wide",
489
+ operatingSystemName(event.metadata),
490
+ eventName,
491
+ kind,
492
+ ],
493
+ };
494
+ }
495
+ if (kind === "warning") {
397
496
  return {
398
497
  level: "warning",
399
498
  fingerprint: [HOOK_TIMEOUT_WARNING_FINGERPRINT, eventName, hookScript(event.metadata)],
@@ -536,7 +635,11 @@ function pruneExpiredRateLimitState(directory, now, protectedStatePath) {
536
635
  }
537
636
  function timeoutRateLimitLease(event, kind, options, dependencies) {
538
637
  const sessionId = stringMetadata(event.metadata, "session_id");
539
- const key = `${sessionId}\0${hookEvent(event.metadata)}\0${hookScript(event.metadata)}\0${kind}`;
638
+ const machineWide = isMachineWideHookSlowness(event.metadata);
639
+ const classification = machineWide
640
+ ? `machine-wide\0${operatingSystemName(event.metadata)}`
641
+ : `per-script\0${hookScript(event.metadata)}`;
642
+ const key = `${sessionId}\0${hookEvent(event.metadata)}\0${kind}\0${classification}`;
540
643
  const directory = rateLimitDirectory(options, dependencies);
541
644
  const statePath = rateLimitStatePath(directory, key);
542
645
  const releaseLock = acquireRateLimitLock(directory);
@@ -13,6 +13,8 @@ export declare function laneRunsOnHost(lane: LaneRecord, host: AdmissionHost): b
13
13
  export declare function compareLaneQueueOrder(a: LaneRecord, b: LaneRecord): number;
14
14
  export declare function hasActiveAdmissionReservation(lane: LaneRecord, nowMs: number): boolean;
15
15
  export declare function hasActivePendingResume(lane: LaneRecord, nowMs: number): boolean;
16
+ /** Whether a lane currently owns a running slot for its host/provider caps. */
17
+ export declare function laneOccupiesRunningSlot(lane: LaneRecord, nowMs?: number): boolean;
16
18
  /** Return the first capacity limit that currently prevents this lane from starting. */
17
19
  export declare function laneAdmissionCapReason(lanes: LaneRecord[], candidate: LaneRecord, config: LanesConfig, host: AdmissionHost, profileMaxConcurrent?: number, nowMs?: number): AdmissionCapReason | undefined;
18
20
  /**
@@ -139,12 +139,16 @@ export function hasActivePendingResume(lane, nowMs) {
139
139
  const ageMs = nowMs - reservedAt;
140
140
  return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
141
141
  }
142
+ /** Whether a lane currently owns a running slot for its host/provider caps. */
143
+ export function laneOccupiesRunningSlot(lane, nowMs = Date.now()) {
144
+ return lane.state === "running" ||
145
+ hasActiveAdmissionReservation(lane, nowMs) ||
146
+ hasActivePendingResume(lane, nowMs);
147
+ }
142
148
  /** Return the first capacity limit that currently prevents this lane from starting. */
143
149
  export function laneAdmissionCapReason(lanes, candidate, config, host, profileMaxConcurrent, nowMs = Date.now()) {
144
150
  const running = lanes.filter((lane) => lane.lane_id !== candidate.lane_id &&
145
- (lane.state === "running" ||
146
- hasActiveAdmissionReservation(lane, nowMs) ||
147
- hasActivePendingResume(lane, nowMs)) &&
151
+ laneOccupiesRunningSlot(lane, nowMs) &&
148
152
  laneRunsOnHost(lane, host));
149
153
  const providerCfg = config.providers[candidate.provider];
150
154
  const providerRunning = running.filter((lane) => lane.provider === candidate.provider);
@@ -0,0 +1,46 @@
1
+ import type { LaneHost, LaneProvider, LaneRecord, LanesConfig } from "./types.js";
2
+ export interface HostCapacityAccount {
3
+ enabled: boolean;
4
+ logged_in: boolean | null;
5
+ max_running: number;
6
+ running: number;
7
+ queued: number;
8
+ }
9
+ export interface HostCapacityProvider {
10
+ max_running: number;
11
+ max_queued: number;
12
+ running: number;
13
+ queued: number;
14
+ accounts: Record<string, HostCapacityAccount>;
15
+ }
16
+ export interface HostCapacitySnapshot {
17
+ schema_version: "hq-lanes-host-capacity.v1";
18
+ host: LaneHost;
19
+ sampled_at: string;
20
+ providers: Record<LaneProvider, HostCapacityProvider>;
21
+ }
22
+ export interface HostPlacementChoice {
23
+ host: LaneHost;
24
+ account: string;
25
+ queued: boolean;
26
+ }
27
+ /** Snapshot the same per-host running-slot predicate used by lane admission. */
28
+ export declare function buildHostCapacitySnapshot(input: {
29
+ host: LaneHost;
30
+ lanes: readonly LaneRecord[];
31
+ config: LanesConfig;
32
+ existsSync: (path: string) => boolean;
33
+ now: Date;
34
+ }): HostCapacitySnapshot;
35
+ /** Reject malformed remote snapshots before they can influence host selection. */
36
+ export declare function parseHostCapacitySnapshot(raw: unknown): HostCapacitySnapshot;
37
+ /**
38
+ * Choose the least-loaded eligible host/account from advisory snapshots. The
39
+ * selected host still runs the shared admission check under its own lock.
40
+ */
41
+ export declare function chooseHostForLane(snapshots: readonly HostCapacitySnapshot[], request: {
42
+ provider: LaneProvider;
43
+ account?: string;
44
+ allowedAccounts?: readonly string[];
45
+ }): HostPlacementChoice;
46
+ //# sourceMappingURL=host-capacity.d.ts.map
@@ -0,0 +1,173 @@
1
+ import { accountStatus } from "./accounts.js";
2
+ import { accountMaxRunning } from "./config.js";
3
+ import { laneOccupiesRunningSlot, laneRunsOnHost, } from "./admission.js";
4
+ import { LanesError } from "./errors.js";
5
+ function admissionHost(host) {
6
+ return host.kind === "outpost"
7
+ ? { hostname: "", outpostId: host.id }
8
+ : { hostname: host.id, outpostId: null };
9
+ }
10
+ /** Snapshot the same per-host running-slot predicate used by lane admission. */
11
+ export function buildHostCapacitySnapshot(input) {
12
+ const at = input.now.getTime();
13
+ const onHost = input.lanes.filter((lane) => laneRunsOnHost(lane, admissionHost(input.host)));
14
+ const providers = {};
15
+ for (const provider of ["claude", "codex", "grok"]) {
16
+ const providerConfig = input.config.providers[provider];
17
+ const providerLanes = onHost.filter((lane) => lane.provider === provider);
18
+ const running = providerLanes.filter((lane) => laneOccupiesRunningSlot(lane, at));
19
+ const queued = providerLanes.filter((lane) => lane.state === "queued");
20
+ const accounts = {};
21
+ for (const [id, account] of Object.entries(providerConfig.accounts)) {
22
+ const status = accountStatus(provider, id, input.config, {
23
+ existsSync: input.existsSync,
24
+ });
25
+ accounts[id] = {
26
+ enabled: account.enabled,
27
+ logged_in: status.logged_in,
28
+ max_running: accountMaxRunning(providerConfig, id),
29
+ running: running.filter((lane) => lane.account === id).length,
30
+ queued: queued.filter((lane) => lane.account === id).length,
31
+ };
32
+ }
33
+ providers[provider] = {
34
+ max_running: providerConfig.max_running,
35
+ max_queued: providerConfig.max_queued,
36
+ running: running.length,
37
+ queued: queued.length,
38
+ accounts,
39
+ };
40
+ }
41
+ return {
42
+ schema_version: "hq-lanes-host-capacity.v1",
43
+ host: input.host,
44
+ sampled_at: input.now.toISOString(),
45
+ providers,
46
+ };
47
+ }
48
+ function objectValue(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value)
50
+ ? value
51
+ : undefined;
52
+ }
53
+ function requireCount(value, field, positive = false) {
54
+ if (typeof value !== "number" ||
55
+ !Number.isInteger(value) ||
56
+ value < (positive ? 1 : 0)) {
57
+ throw new LanesError("host_capacity_invalid", `Invalid host capacity field ${field}`, {
58
+ field,
59
+ value: value ?? null,
60
+ });
61
+ }
62
+ return value;
63
+ }
64
+ /** Reject malformed remote snapshots before they can influence host selection. */
65
+ export function parseHostCapacitySnapshot(raw) {
66
+ const root = objectValue(raw);
67
+ const hostRaw = objectValue(root?.host);
68
+ const providersRaw = objectValue(root?.providers);
69
+ if (root?.schema_version !== "hq-lanes-host-capacity.v1" ||
70
+ typeof root.sampled_at !== "string" ||
71
+ !Number.isFinite(Date.parse(root.sampled_at)) ||
72
+ (hostRaw?.kind !== "local" && hostRaw?.kind !== "outpost") ||
73
+ typeof hostRaw.id !== "string" ||
74
+ hostRaw.id.trim() === "" ||
75
+ providersRaw === undefined) {
76
+ throw new LanesError("host_capacity_invalid", "Malformed host capacity snapshot", {});
77
+ }
78
+ const providers = {};
79
+ for (const provider of ["claude", "codex", "grok"]) {
80
+ const row = objectValue(providersRaw[provider]);
81
+ const accountsRaw = objectValue(row?.accounts);
82
+ if (row === undefined || accountsRaw === undefined) {
83
+ throw new LanesError("host_capacity_invalid", `Missing capacity for ${provider}`, {
84
+ provider,
85
+ });
86
+ }
87
+ const accounts = {};
88
+ for (const [id, accountValue] of Object.entries(accountsRaw)) {
89
+ const account = objectValue(accountValue);
90
+ if (id.trim() === "" || account === undefined ||
91
+ typeof account.enabled !== "boolean" ||
92
+ !(account.logged_in === null || typeof account.logged_in === "boolean")) {
93
+ throw new LanesError("host_capacity_invalid", `Malformed ${provider} account capacity`, {
94
+ provider,
95
+ account: id,
96
+ });
97
+ }
98
+ accounts[id] = {
99
+ enabled: account.enabled,
100
+ logged_in: account.logged_in,
101
+ max_running: requireCount(account.max_running, `${provider}.${id}.max_running`, true),
102
+ running: requireCount(account.running, `${provider}.${id}.running`),
103
+ queued: requireCount(account.queued, `${provider}.${id}.queued`),
104
+ };
105
+ }
106
+ providers[provider] = {
107
+ max_running: requireCount(row.max_running, `${provider}.max_running`, true),
108
+ max_queued: requireCount(row.max_queued, `${provider}.max_queued`, true),
109
+ running: requireCount(row.running, `${provider}.running`),
110
+ queued: requireCount(row.queued, `${provider}.queued`),
111
+ accounts,
112
+ };
113
+ }
114
+ return {
115
+ schema_version: "hq-lanes-host-capacity.v1",
116
+ host: { kind: hostRaw.kind, id: hostRaw.id },
117
+ sampled_at: root.sampled_at,
118
+ providers,
119
+ };
120
+ }
121
+ /**
122
+ * Choose the least-loaded eligible host/account from advisory snapshots. The
123
+ * selected host still runs the shared admission check under its own lock.
124
+ */
125
+ export function chooseHostForLane(snapshots, request) {
126
+ const candidates = [];
127
+ const allowed = request.account === undefined
128
+ ? request.allowedAccounts
129
+ : request.allowedAccounts === undefined
130
+ ? [request.account]
131
+ : request.allowedAccounts.filter((account) => account === request.account);
132
+ for (const snapshot of snapshots) {
133
+ const provider = snapshot.providers[request.provider];
134
+ if (!provider)
135
+ continue;
136
+ const accounts = Object.entries(provider.accounts)
137
+ .filter(([id, account]) => (allowed === undefined || allowed.includes(id)) &&
138
+ account.enabled &&
139
+ account.logged_in === true);
140
+ for (const [accountId, account] of accounts) {
141
+ const hasRunningSlot = provider.running < provider.max_running &&
142
+ account.running < account.max_running;
143
+ const hasQueueSlot = provider.queued < provider.max_queued;
144
+ if (!hasRunningSlot && !hasQueueSlot)
145
+ continue;
146
+ candidates.push({
147
+ host: snapshot.host,
148
+ account: accountId,
149
+ queued: !hasRunningSlot,
150
+ score: provider.running / provider.max_running +
151
+ provider.queued / provider.max_queued +
152
+ account.running / account.max_running,
153
+ });
154
+ }
155
+ }
156
+ candidates.sort((a, b) => Number(a.queued) - Number(b.queued) ||
157
+ a.score - b.score ||
158
+ `${a.host.kind}:${a.host.id}`.localeCompare(`${b.host.kind}:${b.host.id}`) ||
159
+ a.account.localeCompare(b.account));
160
+ const selected = candidates[0];
161
+ if (!selected) {
162
+ throw new LanesError("lane_placement_no_capacity", `No host has logged-in account capacity for ${request.provider}${request.account ? ` account ${request.account}` : ""}`, {
163
+ provider: request.provider,
164
+ ...(request.account !== undefined ? { account: request.account } : {}),
165
+ hosts: snapshots.map((snapshot) => ({
166
+ kind: snapshot.host.kind,
167
+ id: snapshot.host.id,
168
+ })),
169
+ });
170
+ }
171
+ return { host: selected.host, account: selected.account, queued: selected.queued };
172
+ }
173
+ //# sourceMappingURL=host-capacity.js.map
@@ -3,10 +3,12 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
6
+ export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
7
7
  export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
8
+ export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
9
+ export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
8
10
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
9
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
11
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
10
12
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
11
13
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
12
14
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
@@ -3,7 +3,8 @@
3
3
  */
4
4
  export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
5
5
  export { MAX_LANE_DEPTH } from "./constants.js";
6
- export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
6
+ export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
7
+ export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
7
8
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
8
9
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
9
10
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
@@ -7,7 +7,7 @@
7
7
  * `outpost_dispatch_failed`, never a queued lane nobody will pick up.
8
8
  */
9
9
  import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
10
- import type { LaneRecord } from "./types.js";
10
+ import type { LaneHost, LaneRecord } from "./types.js";
11
11
  export declare const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
12
12
  export declare const PICKUP_WAIT_MS: number;
13
13
  export interface OutpostPickupPayload {
@@ -57,6 +57,16 @@ export interface OutpostExecPort {
57
57
  export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPayload;
58
58
  /** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
59
59
  export declare function buildOutpostPickupCommand(payloadUrl: string): string;
60
+ /** Bounded, read-only capacity query executed by the target Outpost CLI. */
61
+ export declare function buildOutpostCapacityCommand(): string;
62
+ /** Commit a queued lane move on the host that currently owns its state. */
63
+ export declare function buildOutpostMoveTargetCommand(input: {
64
+ laneId: string;
65
+ host: LaneHost;
66
+ account: string;
67
+ }): string;
68
+ /** Idempotently backfill the marker on the selected Outpost. */
69
+ export declare function buildOutpostIdentityBackfillCommand(expectedId: string): string;
60
70
  /** Build the same `hq outposts exec` remote command used by lane pickup. */
61
71
  export declare function buildOutpostLaneCommand(input: OutpostLaneCommand): string;
62
72
  export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
@@ -42,6 +42,35 @@ export function parseOutpostPickupPayload(raw) {
42
42
  export function buildOutpostPickupCommand(payloadUrl) {
43
43
  return withRemoteHqDir(joinCommandParts(["hq", "lanes", "pickup", "--payload-url", payloadUrl]));
44
44
  }
45
+ /** Bounded, read-only capacity query executed by the target Outpost CLI. */
46
+ export function buildOutpostCapacityCommand() {
47
+ return withRemoteHqDir(joinCommandParts(["hq", "lanes", "capacity", "--json"]));
48
+ }
49
+ /** Commit a queued lane move on the host that currently owns its state. */
50
+ export function buildOutpostMoveTargetCommand(input) {
51
+ return withRemoteHqDir(joinCommandParts([
52
+ "hq",
53
+ "lanes",
54
+ "move-target",
55
+ input.laneId,
56
+ "--host-kind",
57
+ input.host.kind,
58
+ "--host-id",
59
+ input.host.id,
60
+ "--account",
61
+ input.account,
62
+ ]));
63
+ }
64
+ /** Idempotently backfill the marker on the selected Outpost. */
65
+ export function buildOutpostIdentityBackfillCommand(expectedId) {
66
+ return withRemoteHqDir(joinCommandParts([
67
+ "hq",
68
+ "lanes",
69
+ "outpost-identity-backfill-target",
70
+ "--expected-id",
71
+ expectedId,
72
+ ]));
73
+ }
45
74
  /** Build the same `hq outposts exec` remote command used by lane pickup. */
46
75
  export function buildOutpostLaneCommand(input) {
47
76
  const args = ["hq", "lanes", input.action, input.laneId];
@@ -88,10 +117,17 @@ export async function dispatchOutpostPickup(port, input) {
88
117
  };
89
118
  }
90
119
  catch (err) {
120
+ const outcomeDetails = {
121
+ outpost_id: input.outpostId,
122
+ command_id: submitted.commandId,
123
+ done: false,
124
+ outcome_unknown: true,
125
+ };
91
126
  if (ac.signal.aborted) {
92
- throw new LanesError("outpost_dispatch_failed", `Timed out waiting for Outpost pickup (${waitMs}ms)`, { outpost_id: input.outpostId, command_id: submitted.commandId });
127
+ throw new LanesError("outpost_dispatch_failed", `Timed out waiting for Outpost pickup (${waitMs}ms)`, outcomeDetails);
93
128
  }
94
- throw err;
129
+ const message = dispatchErrorMessage(err);
130
+ throw new LanesError("outpost_dispatch_failed", `Failed waiting for Outpost pickup: ${message}`, { ...outcomeDetails, message });
95
131
  }
96
132
  finally {
97
133
  clearTimeout(timer);
@@ -0,0 +1,33 @@
1
+ export declare const OUTPOST_ID_MARKER_PATH = "/etc/outpost/outpost-id";
2
+ export declare const OUTPOST_ID_MARKER_DIR = "/etc/outpost";
3
+ export type OutpostIdentityValue = {
4
+ kind: "id";
5
+ id: string;
6
+ } | {
7
+ kind: "unknown";
8
+ } | {
9
+ kind: "not_outpost";
10
+ };
11
+ export interface OutpostIdentityMarkerStore {
12
+ read(): OutpostIdentityValue;
13
+ /** Return false if another writer already created the marker. */
14
+ createIfMissing(id: string): boolean;
15
+ }
16
+ export interface OutpostIdentityMarkerWriterOptions {
17
+ markerDir?: string;
18
+ markerPath?: string;
19
+ run?: (input: {
20
+ id: string;
21
+ markerDir: string;
22
+ markerPath: string;
23
+ script: string;
24
+ }) => {
25
+ status: number | null;
26
+ error?: Error;
27
+ stderr?: string;
28
+ };
29
+ }
30
+ export declare function validateOutpostIdentityId(id: string): void;
31
+ export declare function backfillOutpostIdentityMarker(expectedId: string, store: OutpostIdentityMarkerStore): "created" | "unchanged";
32
+ export declare function createOutpostIdentityMarkerIfMissing(id: string, options?: OutpostIdentityMarkerWriterOptions): boolean;
33
+ //# sourceMappingURL=outpost-identity.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { LanesError } from "./errors.js";
3
+ export const OUTPOST_ID_MARKER_PATH = "/etc/outpost/outpost-id";
4
+ export const OUTPOST_ID_MARKER_DIR = "/etc/outpost";
5
+ const SAFE_OUTPOST_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ export function validateOutpostIdentityId(id) {
7
+ if (id !== id.trim() || !SAFE_OUTPOST_ID.test(id)) {
8
+ throw new LanesError("outpost_identity_id_invalid", "Outpost identity must be a single-line id containing only letters, numbers, hyphens, or underscores", { value: id });
9
+ }
10
+ }
11
+ export function backfillOutpostIdentityMarker(expectedId, store) {
12
+ validateOutpostIdentityId(expectedId);
13
+ const current = store.read();
14
+ if (current.kind === "id") {
15
+ if (current.id !== expectedId) {
16
+ throw outpostIdentityMismatch(expectedId, current.id);
17
+ }
18
+ return "unchanged";
19
+ }
20
+ if (current.kind === "not_outpost") {
21
+ throw new LanesError("outpost_identity_not_host", `This host has no ${OUTPOST_ID_MARKER_DIR} directory; identity backfill only runs on an Outpost`, { expected_id: expectedId, marker_path: OUTPOST_ID_MARKER_PATH });
22
+ }
23
+ let created;
24
+ try {
25
+ created = store.createIfMissing(expectedId);
26
+ }
27
+ catch (err) {
28
+ const after = store.read();
29
+ if (after.kind === "id" && after.id === expectedId)
30
+ return "unchanged";
31
+ if (after.kind === "id")
32
+ throw outpostIdentityMismatch(expectedId, after.id);
33
+ throw new LanesError("outpost_identity_backfill_failed", `Could not write ${OUTPOST_ID_MARKER_PATH}: ${err instanceof Error ? err.message : String(err)}`, { expected_id: expectedId, marker_path: OUTPOST_ID_MARKER_PATH });
34
+ }
35
+ const after = store.read();
36
+ if (after.kind === "id") {
37
+ if (after.id !== expectedId)
38
+ throw outpostIdentityMismatch(expectedId, after.id);
39
+ return created ? "created" : "unchanged";
40
+ }
41
+ throw new LanesError("outpost_identity_backfill_failed", `Backfill did not create ${OUTPOST_ID_MARKER_PATH} with expected value ${expectedId}`, { expected_id: expectedId, marker_path: OUTPOST_ID_MARKER_PATH });
42
+ }
43
+ export function createOutpostIdentityMarkerIfMissing(id, options = {}) {
44
+ validateOutpostIdentityId(id);
45
+ const markerDir = options.markerDir ?? OUTPOST_ID_MARKER_DIR;
46
+ const markerPath = options.markerPath ?? OUTPOST_ID_MARKER_PATH;
47
+ const script = `umask 022; install -d -m 0755 "$2" && set -C && printf '%s\\n' "$1" > "$3"`;
48
+ const result = options.run?.({ id, markerDir, markerPath, script }) ?? spawnSync("sudo", ["-n", "sh", "-c", script, "hq-outpost-identity", id, markerDir, markerPath], { encoding: "utf8" });
49
+ if (result.error) {
50
+ throw new LanesError("outpost_identity_backfill_failed", `Could not run sudo to write ${OUTPOST_ID_MARKER_PATH}: ${result.error.message}`, { expected_id: id, marker_path: OUTPOST_ID_MARKER_PATH });
51
+ }
52
+ if (result.status === 0)
53
+ return true;
54
+ return false;
55
+ }
56
+ function outpostIdentityMismatch(expectedId, actualId) {
57
+ return new LanesError("outpost_identity_mismatch", `Refusing to replace ${OUTPOST_ID_MARKER_PATH}: it contains ${actualId}, expected ${expectedId}`, { expected_id: expectedId, actual_id: actualId, marker_path: OUTPOST_ID_MARKER_PATH });
58
+ }
59
+ //# sourceMappingURL=outpost-identity.js.map
@@ -1496,6 +1496,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1496
1496
  const profile = existing.worker_profile
1497
1497
  ? loadWorkerProfile(hqRoot, existing.worker_profile)
1498
1498
  : undefined;
1499
+ if (existing.host.kind === "outpost" &&
1500
+ profile !== undefined &&
1501
+ existing.worker_profile_hash !== profile.contentHash) {
1502
+ throw new LanesError("worker_profile_hash_mismatch", `Worker profile ${JSON.stringify(existing.worker_profile)} on Outpost ${existing.host.id} changed after pickup validation`, {
1503
+ lane_id: existing.lane_id,
1504
+ worker: existing.worker_profile,
1505
+ outpost: existing.host.id,
1506
+ expected_hash: existing.worker_profile_hash ?? null,
1507
+ actual_hash: profile.contentHash,
1508
+ });
1509
+ }
1499
1510
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === existing.provider;
1500
1511
  const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
1501
1512
  const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;