@indigoai-us/hq-cli 5.157.0 → 5.158.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.
@@ -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;
@@ -460,6 +460,18 @@ export function parseLaneRecord(raw, filePath) {
460
460
  path: filePath,
461
461
  });
462
462
  }
463
+ if (raw.placement_default !== undefined) {
464
+ const placement = raw.placement_default;
465
+ if (!isPlainObject(placement) ||
466
+ (placement.kind !== "local" && placement.kind !== "auto" && placement.kind !== "outpost") ||
467
+ (placement.kind === "outpost" && (typeof placement.id !== "string" || placement.id.trim() === "")) ||
468
+ (placement.kind !== "outpost" && "id" in placement)) {
469
+ throw new LaneSchemaError("placement_default must be local, auto, or a pinned Outpost", {
470
+ path: filePath,
471
+ field: "placement_default",
472
+ });
473
+ }
474
+ }
463
475
  if (!Array.isArray(raw.attribution_history)) {
464
476
  throw new LaneSchemaError("attribution_history must be an array", {
465
477
  path: filePath,
@@ -314,6 +314,7 @@ export interface LaneRecord {
314
314
  * recorded session with this turn instead of starting fresh from the brief.
315
315
  */
316
316
  pending_resume?: PendingResume;
317
+ placement_default?: LanePlacementPolicy;
317
318
  host: LaneHost;
318
319
  /**
319
320
  * Creating machine's identity when `host.kind` is `outpost` (spec §5.2).
@@ -448,6 +449,15 @@ export interface PendingResume {
448
449
  compact?: boolean;
449
450
  at: string;
450
451
  }
452
+ /** Where child lanes created by this manager should be placed by default. */
453
+ export type LanePlacementPolicy = {
454
+ kind: "local";
455
+ } | {
456
+ kind: "auto";
457
+ } | {
458
+ kind: "outpost";
459
+ id: string;
460
+ };
451
461
  export interface LaneResumeRecord {
452
462
  session_id: string;
453
463
  at: string;
@@ -220,7 +220,7 @@ export const LANE_FIELD_WRITERS = {
220
220
  pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
221
221
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
222
222
  priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"], worker_profile: ["creator"], worker_profile_hash: ["creator", "spawn"], worker_profile_ignored: ["creator", "spawn"],
223
- provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
223
+ provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
224
224
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
225
225
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"], fast_mode: ["spawn"],
226
226
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.157.0",
3
+ "version": "5.158.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {