@indigoai-us/hq-cli 5.281.0 → 5.282.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.282.0] — 2026-09-29
6
+
7
+ ### Changed
8
+
9
+ - Pin the third-party package setup action in the fast-route benchmark workflow to its resolved commit.
10
+
11
+ ### Fixed
12
+
13
+ - `hq lanes reconcile` admits every queued lane that fits its caps in one pass
14
+ once CPU throttle clears or the starvation bound is reached.
15
+ - Reconcile compares cumulative CPU counters between passes and records the
16
+ measurement source; a queued Outpost lane with a changed worker profile now
17
+ reports the expected and actual hashes plus the senior re-pin command without
18
+ blocking later eligible lanes.
19
+
5
20
  ## [5.281.0] — 2026-09-29
6
21
 
7
22
  ### Added
@@ -507,6 +507,7 @@ export function createLaneMessageHandlers(deps) {
507
507
  ...current,
508
508
  state: "queued",
509
509
  queued_reason: admission.reason ?? "throttle",
510
+ queued_details: undefined,
510
511
  queued_at: at,
511
512
  pending_resume: {
512
513
  session_id: sessionId,
@@ -156,6 +156,7 @@ export function createLaneMoveHandlers(deps) {
156
156
  host: target,
157
157
  account,
158
158
  queued_reason: undefined,
159
+ queued_details: undefined,
159
160
  admission_reserved_at: undefined,
160
161
  }));
161
162
  }, ctx.sleep);
@@ -218,6 +219,7 @@ export function createLaneMoveHandlers(deps) {
218
219
  host: before.host,
219
220
  account: before.account,
220
221
  queued_reason: before.queued_reason,
222
+ queued_details: before.queued_details,
221
223
  admission_reserved_at: before.admission_reserved_at,
222
224
  }));
223
225
  return true;
@@ -317,6 +317,7 @@ export function createLaneResumeHandlers(deps) {
317
317
  ...current,
318
318
  state: "queued",
319
319
  queued_reason: admission.reason ?? "throttle",
320
+ queued_details: undefined,
320
321
  queued_at: at,
321
322
  pending_resume: {
322
323
  session_id: sessionId,
@@ -1,6 +1,7 @@
1
1
  import type { LaneCompanyRef, LanePlacementPolicy, LaneRecord, LanesConfig, QuestionAnswer, QuestionBody, QuestionRecord, SeniorRef, AccountHistoryEntry, LaneAccountSelection, PendingResume, QueuedReason, ResolvedLaneWorker, LivenessDeps, MachineIdentity, MonitorEvidenceReader, ReconcileHost, RunCommand, ThrottleDeps, withLaneHireLocks } from "../lib/lanes/index.js";
2
2
  import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
3
3
  import type { OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
4
+ import type { CpuBusySource } from "../lib/lanes/throttle.js";
4
5
  import type { ProbeCommand } from "../lib/lanes/models.js";
5
6
  import type { KillProcessFn, ProveDetachedFn, SpawnProcessFn } from "../lib/lanes/spawn.js";
6
7
  import type { WatcherLoopDeps } from "../lib/lanes/watcher.js";
@@ -81,6 +82,11 @@ export interface AdmissionResult {
81
82
  action: "start" | "queue" | "refuse";
82
83
  reason?: QueuedReason;
83
84
  error?: LanesError;
85
+ throttle?: {
86
+ last_busy: number | null;
87
+ busy_source: CpuBusySource | undefined;
88
+ sampled_at: string | undefined;
89
+ };
84
90
  }
85
91
  export interface PrepareAccountMoveInput {
86
92
  account: string;
@@ -1957,11 +1957,16 @@ async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccou
1957
1957
  if (current.action !== "start" &&
1958
1958
  !(current.action === "queue" && current.reason === "queue_position"))
1959
1959
  return current;
1960
- const { decision } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
1960
+ const { decision, state } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
1961
1961
  if (decision.admit)
1962
1962
  return current;
1963
+ const throttle = {
1964
+ last_busy: state.last_busy ?? null,
1965
+ busy_source: state.busy_source,
1966
+ sampled_at: state.sampled_at,
1967
+ };
1963
1968
  if (current.action === "queue")
1964
- return { action: "queue", reason: "throttle" };
1969
+ return { action: "queue", reason: "throttle", throttle };
1965
1970
  const providerCfg = config.providers[provider];
1966
1971
  const host = options.host ?? reconcileHostFromCtx(ctx);
1967
1972
  const nowMs = options.nowMs ?? ctx.now().getTime();
@@ -1993,7 +1998,7 @@ async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccou
1993
1998
  }),
1994
1999
  };
1995
2000
  }
1996
- return { action: "queue", reason: "throttle" };
2001
+ return { action: "queue", reason: "throttle", throttle };
1997
2002
  }
1998
2003
  async function assessLaneAdmission(ctx, lane, options) {
1999
2004
  const hqRoot = ctx.hqRoot();
@@ -2351,6 +2356,7 @@ function markDispatchFailure(hqRoot, laneId, details) {
2351
2356
  ...current,
2352
2357
  state: "failed",
2353
2358
  queued_reason: undefined,
2359
+ queued_details: undefined,
2354
2360
  terminal_reason: DISPATCH_TERMINAL_REASON,
2355
2361
  terminal_details: details,
2356
2362
  };
@@ -2374,6 +2380,7 @@ export function pickupFailureUpdater(reasonRaw, details) {
2374
2380
  ...current,
2375
2381
  state: "failed",
2376
2382
  queued_reason: undefined,
2383
+ queued_details: undefined,
2377
2384
  pickup_validation_pending: undefined,
2378
2385
  pickup_validation_pending_since: undefined,
2379
2386
  terminal_reason: reason,
@@ -3241,6 +3248,7 @@ async function runCreate(ctx, opts) {
3241
3248
  ...current,
3242
3249
  state: "failed",
3243
3250
  queued_reason: undefined,
3251
+ queued_details: undefined,
3244
3252
  terminal_reason: "mesh_thread_registration_failed",
3245
3253
  }));
3246
3254
  }
@@ -3385,6 +3393,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
3385
3393
  ...current,
3386
3394
  state: "cancelled",
3387
3395
  queued_reason: undefined,
3396
+ queued_details: undefined,
3388
3397
  terminal_reason: "outpost_self_target",
3389
3398
  outpost_command_id: dispatched.commandId,
3390
3399
  }));
@@ -4297,6 +4306,7 @@ async function runPickup(ctx, opts) {
4297
4306
  ...live,
4298
4307
  state: "queued",
4299
4308
  queued_reason: admission.reason ?? "throttle",
4309
+ queued_details: undefined,
4300
4310
  queued_at: live.queued_at ?? ctx.now().toISOString(),
4301
4311
  };
4302
4312
  });
@@ -4494,7 +4504,7 @@ async function runList(ctx, opts) {
4494
4504
  story_ids: laneStories(lane).map((story) => story.story_id),
4495
4505
  no_story_reason: lane.no_story_reason ?? null,
4496
4506
  ...(remoteQueued
4497
- ? { state: "remote", remote_state: lane.state, queued_reason: undefined }
4507
+ ? { state: "remote", remote_state: lane.state, queued_reason: undefined, queued_details: undefined }
4498
4508
  : {}),
4499
4509
  overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, now),
4500
4510
  stalled: Boolean(lane.stalled),
@@ -4732,7 +4742,7 @@ async function runShow(ctx, laneId) {
4732
4742
  storedLane.host.kind === "outpost" &&
4733
4743
  !laneRunsHere(storedLane, reconcileHostFromCtx(ctx));
4734
4744
  const lane = remoteQueued
4735
- ? { ...storedLane, state: "remote", remote_state: storedLane.state, queued_reason: undefined }
4745
+ ? { ...storedLane, state: "remote", remote_state: storedLane.state, queued_reason: undefined, queued_details: undefined }
4736
4746
  : storedLane;
4737
4747
  const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
4738
4748
  const keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
@@ -5449,6 +5459,7 @@ async function runAdmit(ctx, laneId, opts) {
5449
5459
  });
5450
5460
  }
5451
5461
  let blockedBy;
5462
+ let throttleTelemetry;
5452
5463
  if (opts.force) {
5453
5464
  const callerAuthorization = forceAdmissionAuthorization(ctx, lane);
5454
5465
  if (!callerAuthorization.authorized) {
@@ -5456,9 +5467,14 @@ async function runAdmit(ctx, laneId, opts) {
5456
5467
  }
5457
5468
  const config = loadLanesConfig(hqRoot);
5458
5469
  const lanes = loadAllLanes(hqRoot).filter((row) => row.lane_id !== laneId && laneRunsHere(row, host));
5459
- const { decision } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
5470
+ const { decision, state } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
5460
5471
  if (!decision.admit) {
5461
5472
  blockedBy = "throttle";
5473
+ throttleTelemetry = {
5474
+ last_busy: state.last_busy ?? null,
5475
+ busy_source: state.busy_source,
5476
+ sampled_at: state.sampled_at,
5477
+ };
5462
5478
  }
5463
5479
  else if (hasEarlierEligibleQueuedLane(lanes, lane, config, host, (row) => row.worker_profile === undefined
5464
5480
  ? undefined
@@ -5471,6 +5487,7 @@ async function runAdmit(ctx, laneId, opts) {
5471
5487
  queueAt: lane.queued_at ?? lane.created_at,
5472
5488
  alreadyQueued: true,
5473
5489
  });
5490
+ throttleTelemetry = admission.throttle;
5474
5491
  if (admission.action === "refuse" && admission.error) {
5475
5492
  throw admission.error;
5476
5493
  }
@@ -5487,7 +5504,7 @@ async function runAdmit(ctx, laneId, opts) {
5487
5504
  state: current.state,
5488
5505
  });
5489
5506
  }
5490
- return { ...current, queued_reason: blockedBy };
5507
+ return { ...current, queued_reason: blockedBy, queued_details: undefined };
5491
5508
  });
5492
5509
  printJson({
5493
5510
  ok: true,
@@ -5495,6 +5512,9 @@ async function runAdmit(ctx, laneId, opts) {
5495
5512
  lane_id: laneId,
5496
5513
  admitted: false,
5497
5514
  blocked_by: blockedBy,
5515
+ ...(blockedBy === "throttle" && throttleTelemetry !== undefined
5516
+ ? throttleTelemetry
5517
+ : {}),
5498
5518
  lane: updated,
5499
5519
  });
5500
5520
  return;
@@ -5862,6 +5882,7 @@ async function prepareAccountMove(ctx, lane, input) {
5862
5882
  else if (queued) {
5863
5883
  next.state = "queued";
5864
5884
  next.queued_reason = admission?.reason ?? "throttle";
5885
+ next.queued_details = undefined;
5865
5886
  next.queued_at = at;
5866
5887
  }
5867
5888
  else {
@@ -6944,6 +6965,7 @@ async function runStop(ctx, laneId, reason, quiet = false) {
6944
6965
  ...current,
6945
6966
  state: stopCommittedState(current.state),
6946
6967
  queued_reason: undefined,
6968
+ queued_details: undefined,
6947
6969
  ...(operatorReason === undefined ? {} : {
6948
6970
  stop_history: [
6949
6971
  ...(current.stop_history ?? []),
@@ -7018,6 +7040,7 @@ async function runInterrupt(ctx, laneId, reason) {
7018
7040
  ...current,
7019
7041
  state: "interrupted",
7020
7042
  queued_reason: undefined,
7043
+ queued_details: undefined,
7021
7044
  interrupted_at: at,
7022
7045
  terminal_reason: "operator_interrupt",
7023
7046
  ...(operatorReason === undefined ? {} : {
@@ -45,6 +45,7 @@ export function queueLaneTerminalResume(current, at, resumeText, options = {}) {
45
45
  ...current,
46
46
  state: "queued",
47
47
  queued_reason: undefined,
48
+ queued_details: undefined,
48
49
  queued_at: at,
49
50
  admission_reserved_at: undefined,
50
51
  terminal_reason: undefined,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { type LivenessDeps } from "./liveness.js";
9
9
  import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
10
- import { type ThrottleDeps } from "./throttle.js";
10
+ import { type ThrottleDeps, type CpuBusyObservation } from "./throttle.js";
11
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
12
  import { type RunawayProcessInfo } from "./runaway-tool-guard.js";
13
13
  import { type KeepAliveBlockedReason } from "./keep-alive.js";
@@ -78,6 +78,12 @@ export interface ReconcileDeps {
78
78
  readSessionSenior?: (sessionId: string) => SeniorRef;
79
79
  presenceConnected?: boolean;
80
80
  env?: NodeJS.ProcessEnv;
81
+ /** CPU sample taken before acquiring provider admission locks. */
82
+ cpuBusyFraction?: number | null;
83
+ /** CPU observation and cumulative counters taken before provider locks. */
84
+ cpuObservation?: CpuBusyObservation;
85
+ /** Skip admission this pass if candidates appeared after the pre-sample snapshot. */
86
+ skipAdmissionWithoutCpuSample?: boolean;
81
87
  /** Flag reader seam used to assert rollout reads happen before admission locks. */
82
88
  readRegistryFlag?: typeof readLanesRegistryFlag;
83
89
  /** Pre-read manager gate decisions, scoped to each lane for this pass. */
@@ -20,7 +20,7 @@ import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./gr
20
20
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
21
21
  import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readProcStarttime, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
22
22
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
23
- import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
23
+ import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyObservation, recordCpuBusyObservation, throttleStatePath, writeThrottleState, } from "./throttle.js";
24
24
  import { readLanesRegistryFlag } from "./registry-flag.js";
25
25
  import { resolveLaneCompany } from "./lane-company.js";
26
26
  import { appendDropboxMessage } from "./dropbox.js";
@@ -358,7 +358,7 @@ function casLaneIf(hqRoot, laneId, shouldWrite, updater, dryRun) {
358
358
  function writeQueueReason(hqRoot, lane, reason, dryRun) {
359
359
  if (lane.queued_reason === reason)
360
360
  return;
361
- casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" && current.queued_reason !== reason, (current) => ({ ...current, queued_reason: reason, admission_reserved_at: undefined }), dryRun);
361
+ casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" && current.queued_reason !== reason, (current) => ({ ...current, queued_reason: reason, queued_details: undefined, admission_reserved_at: undefined }), dryRun);
362
362
  }
363
363
  function availableBalancedAccount(hqRoot, lane, occupying, config, host, profileMaxConcurrent, nowMs) {
364
364
  if (!lane.account || !lane.admission_accounts?.includes(lane.account))
@@ -482,6 +482,7 @@ function expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now) {
482
482
  ...current,
483
483
  state: "failed",
484
484
  queued_reason: undefined,
485
+ queued_details: undefined,
485
486
  pickup_validation_pending: undefined,
486
487
  pickup_validation_pending_since: undefined,
487
488
  terminal_reason: PICKUP_VALIDATION_TIMEOUT_REASON,
@@ -998,14 +999,40 @@ export async function runPassBody(hqRoot, deps) {
998
999
  keepAliveFlagByCompany.set(key, keepAliveEnabled);
999
1000
  supersessionLineageAwareByCompany.set(key, lineageAware);
1000
1001
  }));
1002
+ const admissionThrottleDeps = startDeps.throttleDeps ?? defaultThrottleDeps(startDeps.env ?? process.env);
1003
+ const hasAdmissionCandidates = lanes.some((lane) => laneRunsHere(lane, startDeps.host) &&
1004
+ (lane.state === "queued" || lane.state === "awaiting_input")) || [...keepAliveFlagByCompany.values()].some(Boolean);
1005
+ const priorThrottleState = readThrottleState(throttleStatePath(hqRoot), admissionThrottleDeps);
1006
+ const priorThrottleSnapshot = priorThrottleState === "missing" || priorThrottleState === "unreadable"
1007
+ ? undefined
1008
+ : priorThrottleState.cpu_snapshot;
1009
+ const cpuObservation = await sampleCpuBusyObservation(admissionThrottleDeps, priorThrottleSnapshot, { measureBusy: hasAdmissionCandidates });
1010
+ const admissionDeps = {
1011
+ ...startDeps,
1012
+ cpuBusyFraction: cpuObservation.busy,
1013
+ cpuObservation,
1014
+ skipAdmissionWithoutCpuSample: !hasAdmissionCandidates,
1015
+ };
1001
1016
  await withAllLaneAdmissionLocks(hqRoot, async () => {
1002
1017
  const freshLanes = loadLaneRecords(hqRoot, summary);
1003
1018
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
1004
- await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, startDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
1019
+ await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
1005
1020
  const drainLanes = loadLaneRecords(hqRoot, summary);
1006
- await admitQueued(hqRoot, drainLanes, summary, startDeps, now);
1007
- await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, startDeps);
1021
+ await admitQueued(hqRoot, drainLanes, summary, admissionDeps, now);
1022
+ await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, admissionDeps);
1008
1023
  });
1024
+ if (!deps.dryRun && cpuObservation.snapshot !== undefined) {
1025
+ const latestThrottleState = readThrottleState(throttleStatePath(hqRoot), admissionThrottleDeps);
1026
+ if (latestThrottleState === "unreadable") {
1027
+ markError(summary, new LanesError("throttle_state_unreadable", "Throttle state became unreadable while reconciling"));
1028
+ }
1029
+ else {
1030
+ const currentThrottleState = latestThrottleState === "missing"
1031
+ ? emptyThrottleState()
1032
+ : latestThrottleState;
1033
+ writeThrottleState(throttleStatePath(hqRoot), recordCpuBusyObservation(currentThrottleState, cpuObservation), admissionThrottleDeps);
1034
+ }
1035
+ }
1009
1036
  if (!deps.dryRun && summary.errors.length === 0) {
1010
1037
  try {
1011
1038
  writeHeartbeatFile(hqRoot, summary, nowIso);
@@ -1246,6 +1273,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1246
1273
  ...current,
1247
1274
  state: terminalClassification.state,
1248
1275
  queued_reason: undefined,
1276
+ queued_details: undefined,
1249
1277
  terminal_reason: terminalClassification.reason ?? "host_lost",
1250
1278
  interrupted_at: terminalClassification.state === "interrupted" ? nowIso : undefined,
1251
1279
  ...(providerRejection
@@ -1390,6 +1418,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1390
1418
  ...current,
1391
1419
  state: "interrupted",
1392
1420
  queued_reason: undefined,
1421
+ queued_details: undefined,
1393
1422
  terminal_reason: "host_lost",
1394
1423
  interrupted_at: nowIso,
1395
1424
  }), deps.dryRun);
@@ -1448,7 +1477,7 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1448
1477
  laneRunsHere(lane, deps.host))
1449
1478
  .filter((lane) => !hasFreshAdmissionReservation(lane, now.getTime()))
1450
1479
  .sort(compareLaneQueueOrder);
1451
- if (localQueued.length === 0)
1480
+ if (localQueued.length === 0 || deps.skipAdmissionWithoutCpuSample === true)
1452
1481
  return;
1453
1482
  const config = loadLanesConfig(hqRoot);
1454
1483
  const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
@@ -1459,7 +1488,9 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1459
1488
  : loaded === "missing"
1460
1489
  ? emptyThrottleState()
1461
1490
  : loaded;
1462
- const busy = await resolveCpuBusyFraction(throttleDeps);
1491
+ const busy = deps.cpuBusyFraction !== undefined
1492
+ ? deps.cpuBusyFraction
1493
+ : await resolveCpuBusyFraction(throttleDeps);
1463
1494
  const availableMemMb = readAvailableMemMb(throttleDeps);
1464
1495
  const nowMs = now.getTime();
1465
1496
  const decision = evaluateThrottle({
@@ -1476,7 +1507,6 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1476
1507
  hasFreshAdmissionReservation(lane, now.getTime()) ||
1477
1508
  hasActivePendingResume(lane, now.getTime())) &&
1478
1509
  laneRunsHere(lane, deps.host));
1479
- const starvation = decision.admit && decision.reason === "starvation_bound";
1480
1510
  const spawned = [];
1481
1511
  if (!decision.admit) {
1482
1512
  for (const lane of localQueued) {
@@ -1543,8 +1573,6 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1543
1573
  reason: decision.reason,
1544
1574
  });
1545
1575
  spawned.push({ ...admittedLane, state: "running" });
1546
- if (starvation)
1547
- break;
1548
1576
  continue;
1549
1577
  }
1550
1578
  const started = spawnFn(hqRoot, admittedLane);
@@ -1564,10 +1592,38 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1564
1592
  : {}),
1565
1593
  });
1566
1594
  spawned.push(started);
1567
- if (starvation)
1568
- break;
1569
1595
  }
1570
1596
  catch (err) {
1597
+ if (err instanceof LanesError && err.error === "worker_profile_hash_mismatch") {
1598
+ const expectedHash = typeof err.details.expected_hash === "string"
1599
+ ? err.details.expected_hash
1600
+ : null;
1601
+ const actualHash = typeof err.details.actual_hash === "string"
1602
+ ? err.details.actual_hash
1603
+ : undefined;
1604
+ const workerProfile = typeof err.details.worker === "string"
1605
+ ? err.details.worker
1606
+ : lane.worker_profile;
1607
+ if (actualHash !== undefined && workerProfile !== undefined) {
1608
+ casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued", (current) => ({
1609
+ ...current,
1610
+ queued_reason: "worker_profile_changed",
1611
+ queued_details: {
1612
+ reason: "worker_profile_changed",
1613
+ expected_hash: expectedHash,
1614
+ actual_hash: actualHash,
1615
+ recovery_command: `hq lanes resume ${lane.lane_id} --worker ${workerProfile}`,
1616
+ },
1617
+ admission_reserved_at: undefined,
1618
+ }), false);
1619
+ summary.skipped.push({
1620
+ kind: "lane",
1621
+ id: lane.lane_id,
1622
+ why: "worker_profile_changed",
1623
+ });
1624
+ continue;
1625
+ }
1626
+ }
1571
1627
  terminalizeModelUnsupported(hqRoot, lane, err);
1572
1628
  markDirty(summary, {
1573
1629
  kind: "lane",
@@ -2273,6 +2329,7 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
2273
2329
  ...latestLane,
2274
2330
  state: "queued",
2275
2331
  queued_reason: undefined,
2332
+ queued_details: undefined,
2276
2333
  queued_at: nowIso,
2277
2334
  admission_reserved_at: undefined,
2278
2335
  terminal_reason: undefined,
@@ -2348,7 +2405,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2348
2405
  const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
2349
2406
  laneRunsHere(lane, deps.host) &&
2350
2407
  (onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)));
2351
- if (awaiting.length === 0)
2408
+ if (awaiting.length === 0 || deps.skipAdmissionWithoutCpuSample === true)
2352
2409
  return;
2353
2410
  const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
2354
2411
  let throttleDecision;
@@ -2356,7 +2413,9 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2356
2413
  const loaded = readThrottleState(throttleStatePath(hqRoot), throttleDeps);
2357
2414
  const state = loaded === "missing" ? emptyThrottleState() : loaded;
2358
2415
  throttleDecision = evaluateThrottle({
2359
- busy: await resolveCpuBusyFraction(throttleDeps),
2416
+ busy: deps.cpuBusyFraction !== undefined
2417
+ ? deps.cpuBusyFraction
2418
+ : deps.cpuObservation?.busy ?? await resolveCpuBusyFraction(throttleDeps),
2360
2419
  availableMemMb: readAvailableMemMb(throttleDeps),
2361
2420
  cpuCheckEnabled: isCpuCheckEnabled(throttleDeps.env),
2362
2421
  state,
@@ -2365,7 +2424,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2365
2424
  });
2366
2425
  }
2367
2426
  else {
2368
- const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps);
2427
+ const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps, deps.cpuBusyFraction, deps.cpuObservation);
2369
2428
  throttleDecision = throttle.decision;
2370
2429
  }
2371
2430
  const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps, lane)));
@@ -2444,6 +2503,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
2444
2503
  ...latest,
2445
2504
  state: "queued",
2446
2505
  queued_reason: queueReason,
2506
+ queued_details: undefined,
2447
2507
  queued_at: answeredAt,
2448
2508
  pending_resume: {
2449
2509
  ...(latest.provider_session_id
@@ -2503,14 +2563,26 @@ export async function runReconcilePass(hqRoot, deps) {
2503
2563
  /** Run the reconcile answer-resume path for one lane before questions answer returns. */
2504
2564
  export async function wakeAnsweredLaneNow(hqRoot, laneId, deps) {
2505
2565
  const summary = emptySummary(deps.dryRun);
2566
+ const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
2506
2567
  await withReconcileLock(hqRoot, async () => {
2568
+ const previousState = readThrottleState(throttleStatePath(hqRoot), throttleDeps);
2569
+ const previousSnapshot = previousState === "missing" || previousState === "unreadable"
2570
+ ? undefined
2571
+ : previousState.cpu_snapshot;
2572
+ const cpuObservation = await sampleCpuBusyObservation(throttleDeps, previousSnapshot);
2573
+ const admissionDeps = {
2574
+ ...deps,
2575
+ cpuBusyFraction: cpuObservation.busy,
2576
+ cpuObservation,
2577
+ skipAdmissionWithoutCpuSample: false,
2578
+ };
2507
2579
  await withAllLaneAdmissionLocks(hqRoot, async () => {
2508
2580
  const lanes = loadLaneRecords(hqRoot, summary);
2509
2581
  const questions = loadQuestionRecords(hqRoot, summary);
2510
2582
  if (!lanes.some((lane) => lane.lane_id === laneId)) {
2511
2583
  throw new LanesError("lane_not_found", `Lane ${laneId} was not found`, { lane_id: laneId });
2512
2584
  }
2513
- await resumeAwaiting(hqRoot, lanes, questions, summary, deps, new Set([laneId]));
2585
+ await resumeAwaiting(hqRoot, lanes, questions, summary, admissionDeps, new Set([laneId]));
2514
2586
  });
2515
2587
  });
2516
2588
  const latest = readLane(hqRoot, laneId);
@@ -476,6 +476,7 @@ export async function retryLaneOnAlternateAccount(hqRoot, lane, reason, writer =
476
476
  ...row,
477
477
  state: "queued",
478
478
  queued_reason: "account_retry",
479
+ queued_details: undefined,
479
480
  queued_at: at,
480
481
  admission_reserved_at: undefined,
481
482
  terminal_reason: undefined,
@@ -1360,6 +1361,7 @@ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details) {
1360
1361
  ...current,
1361
1362
  state: "failed",
1362
1363
  queued_reason: undefined,
1364
+ queued_details: undefined,
1363
1365
  queued_at: undefined,
1364
1366
  admission_reserved_at: undefined,
1365
1367
  terminal_reason: reason,
@@ -1425,6 +1427,7 @@ function markOutpostSelfTarget(hqRoot, laneId, details) {
1425
1427
  ...current,
1426
1428
  state: "cancelled",
1427
1429
  queued_reason: undefined,
1430
+ queued_details: undefined,
1428
1431
  terminal_reason: "outpost_self_target",
1429
1432
  terminal_details: details,
1430
1433
  };
@@ -1948,6 +1951,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1948
1951
  ...current,
1949
1952
  state: "queued",
1950
1953
  queued_reason: "provider_binary_missing",
1954
+ queued_details: undefined,
1951
1955
  terminal_reason: undefined,
1952
1956
  terminal_details: {
1953
1957
  provider: lane.provider,
@@ -2024,6 +2028,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2024
2028
  ...current,
2025
2029
  state: "running",
2026
2030
  queued_reason: undefined,
2031
+ queued_details: undefined,
2027
2032
  queued_at: undefined,
2028
2033
  admission_reserved_at: undefined,
2029
2034
  terminal_reason: undefined,
@@ -528,6 +528,33 @@ export function parseLaneRecord(raw, filePath) {
528
528
  value: raw.queued_reason,
529
529
  });
530
530
  }
531
+ if (raw.queued_details !== undefined) {
532
+ const details = raw.queued_details;
533
+ if (details === null || typeof details !== "object" || Array.isArray(details) ||
534
+ typeof details.reason !== "string") {
535
+ throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
536
+ path: filePath,
537
+ field: "queued_details",
538
+ });
539
+ }
540
+ const detailsRecord = details;
541
+ if (detailsRecord.reason !== "worker_profile_changed" ||
542
+ (detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
543
+ typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
544
+ typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
545
+ throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
546
+ path: filePath,
547
+ field: "queued_details",
548
+ });
549
+ }
550
+ if (raw.queued_reason !== "worker_profile_changed") {
551
+ throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
552
+ path: filePath,
553
+ field: "queued_details",
554
+ queued_reason: raw.queued_reason,
555
+ });
556
+ }
557
+ }
531
558
  if (raw.queued_at !== undefined)
532
559
  requireTimestamp(raw.queued_at, "queued_at", filePath);
533
560
  if (raw.admission_reserved_at !== undefined) {
@@ -12,7 +12,8 @@
12
12
  import * as fs from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import type { ThrottleConfig } from "./types.js";
15
- export declare const CPU_SAMPLE_MS = 200;
15
+ export declare const CPU_SAMPLE_MS = 5000;
16
+ export declare const CPU_CUMULATIVE_MAX_AGE_MS: number;
16
17
  export declare const STARVATION_MAX_DEFERRALS = 10;
17
18
  export declare const STARVATION_MAX_MS: number;
18
19
  export declare const PROC_MEMINFO = "/proc/meminfo";
@@ -39,12 +40,34 @@ export interface ThrottleDeps {
39
40
  renameSync: typeof fs.renameSync;
40
41
  unlinkSync: typeof fs.unlinkSync;
41
42
  }
43
+ export type CpuBusySource = "interval" | "window" | "override";
44
+ export interface CpuCumulativeSnapshot {
45
+ idle: number;
46
+ total: number;
47
+ sampled_at: string;
48
+ }
49
+ export interface CpuBusyObservation {
50
+ busy: number | null;
51
+ source: CpuBusySource | undefined;
52
+ snapshot: CpuCumulativeSnapshot | undefined;
53
+ }
42
54
  export declare function defaultThrottleDeps(env?: NodeJS.ProcessEnv): ThrottleDeps;
43
55
  export declare function cpuTimesSnapshot(cpus: os.CpuInfo[]): CpuTimes;
44
56
  export declare function busyFractionFromDeltas(before: CpuTimes, after: CpuTimes): number | null;
45
57
  export declare function sampleCpuBusyFraction(deps?: ThrottleDeps, sampleMs?: number): Promise<number | null>;
46
58
  export declare function isCpuCheckEnabled(env?: NodeJS.ProcessEnv): boolean;
47
59
  export declare function resolveCpuBusyFraction(deps?: ThrottleDeps): Promise<number | null>;
60
+ /**
61
+ * Measure CPU busy against the previous reconcile snapshot when it is fresh.
62
+ * A short stable window is used only to establish a baseline or recover from
63
+ * stale/backwards counters (for example, after a reboot).
64
+ */
65
+ export declare function sampleCpuBusyObservation(deps: ThrottleDeps, previous: CpuCumulativeSnapshot | undefined, options?: {
66
+ measureBusy?: boolean;
67
+ }): Promise<CpuBusyObservation>;
68
+ export declare function recordCpuBusyObservation(state: ThrottleState, observation: CpuBusyObservation, options?: {
69
+ advanceSnapshot?: boolean;
70
+ }): ThrottleState;
48
71
  /** Parse MemAvailable kB from /proc/meminfo. */
49
72
  export declare function parseMemAvailableKb(contents: string): number | null;
50
73
  /**
@@ -56,6 +79,10 @@ export interface ThrottleState {
56
79
  consecutiveDeferrals: number;
57
80
  lastAdmissionAt: string | null;
58
81
  cpuPaused: boolean;
82
+ cpu_snapshot?: CpuCumulativeSnapshot;
83
+ last_busy?: number | null;
84
+ busy_source?: CpuBusySource;
85
+ sampled_at?: string;
59
86
  }
60
87
  export type ThrottleAdmitReason = "ok" | "starvation_bound" | "unreadable" | "cpu_check_disabled";
61
88
  export type ThrottleDeferReason = "cpu" | "memory";
@@ -82,7 +109,7 @@ export declare function applyThrottleDecision(state: ThrottleState, decision: Th
82
109
  export declare function throttleStatePath(hqRoot: string): string;
83
110
  export declare function readThrottleState(filePath: string, deps?: ThrottleDeps): ThrottleState | "unreadable" | "missing";
84
111
  export declare function writeThrottleState(filePath: string, state: ThrottleState, deps?: ThrottleDeps): void;
85
- export declare function decideAdmission(hqRoot: string, throttle?: ThrottleConfig, deps?: ThrottleDeps): Promise<{
112
+ export declare function decideAdmission(hqRoot: string, throttle?: ThrottleConfig, deps?: ThrottleDeps, cpuBusyFraction?: number | null, cpuObservation?: CpuBusyObservation): Promise<{
86
113
  decision: ThrottleDecision;
87
114
  state: ThrottleState;
88
115
  }>;
@@ -13,7 +13,10 @@ import * as fs from "node:fs";
13
13
  import * as os from "node:os";
14
14
  import * as path from "node:path";
15
15
  import { DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_MIN_AVAILABLE_MEM_MB, } from "./config.js";
16
- export const CPU_SAMPLE_MS = 200;
16
+ // Reconcile normally measures the interval between cumulative CPU snapshots.
17
+ // A five-second window establishes a baseline or recovers from stale counters.
18
+ export const CPU_SAMPLE_MS = 5_000;
19
+ export const CPU_CUMULATIVE_MAX_AGE_MS = 5 * 60 * 1000;
17
20
  export const STARVATION_MAX_DEFERRALS = 10;
18
21
  export const STARVATION_MAX_MS = 30 * 60 * 1000;
19
22
  export const PROC_MEMINFO = "/proc/meminfo";
@@ -83,6 +86,93 @@ export async function resolveCpuBusyFraction(deps = defaultThrottleDeps()) {
83
86
  }
84
87
  return sampleCpuBusyFraction(deps);
85
88
  }
89
+ function snapshotFromDeps(deps) {
90
+ return {
91
+ ...cpuTimesSnapshot(deps.cpus()),
92
+ sampled_at: new Date(deps.now()).toISOString(),
93
+ };
94
+ }
95
+ /**
96
+ * Measure CPU busy against the previous reconcile snapshot when it is fresh.
97
+ * A short stable window is used only to establish a baseline or recover from
98
+ * stale/backwards counters (for example, after a reboot).
99
+ */
100
+ export async function sampleCpuBusyObservation(deps, previous, options = {}) {
101
+ let start;
102
+ try {
103
+ start = snapshotFromDeps(deps);
104
+ }
105
+ catch (err) {
106
+ process.stderr.write(`lanes throttle: CPU counters failed: ${err instanceof Error ? err.message : String(err)}\n`);
107
+ return { busy: null, source: undefined, snapshot: previous };
108
+ }
109
+ if (options.measureBusy === false || !isCpuCheckEnabled(deps.env)) {
110
+ return { busy: null, source: undefined, snapshot: start };
111
+ }
112
+ const rawOverride = deps.env[LANES_CPU_BUSY_OVERRIDE];
113
+ if (rawOverride !== undefined && rawOverride !== "") {
114
+ const override = Number(rawOverride);
115
+ if (Number.isFinite(override)) {
116
+ return {
117
+ busy: Math.max(0, Math.min(1, override)),
118
+ source: "override",
119
+ snapshot: start,
120
+ };
121
+ }
122
+ process.stderr.write(`lanes throttle: ${LANES_CPU_BUSY_OVERRIDE} is not a finite number (${JSON.stringify(rawOverride)}); sampling instead\n`);
123
+ }
124
+ const nowMs = Date.parse(start.sampled_at);
125
+ const previousAtMs = previous ? Date.parse(previous.sampled_at) : Number.NaN;
126
+ if (previous !== undefined &&
127
+ Number.isFinite(previousAtMs) &&
128
+ nowMs >= previousAtMs &&
129
+ nowMs - previousAtMs <= CPU_CUMULATIVE_MAX_AGE_MS &&
130
+ start.idle >= previous.idle &&
131
+ start.total >= previous.total) {
132
+ return {
133
+ busy: busyFractionFromDeltas(previous, start),
134
+ source: "interval",
135
+ snapshot: start,
136
+ };
137
+ }
138
+ try {
139
+ await deps.sleep(CPU_SAMPLE_MS);
140
+ const end = snapshotFromDeps(deps);
141
+ return {
142
+ busy: busyFractionFromDeltas(start, end),
143
+ source: "window",
144
+ snapshot: end,
145
+ };
146
+ }
147
+ catch (err) {
148
+ process.stderr.write(`lanes throttle: CPU window sample failed: ${err instanceof Error ? err.message : String(err)}\n`);
149
+ return { busy: null, source: "window", snapshot: start };
150
+ }
151
+ }
152
+ export function recordCpuBusyObservation(state, observation, options = {}) {
153
+ const observationAt = observation.snapshot === undefined
154
+ ? Number.NaN
155
+ : Date.parse(observation.snapshot.sampled_at);
156
+ const currentSnapshotAt = state.cpu_snapshot === undefined
157
+ ? Number.NaN
158
+ : Date.parse(state.cpu_snapshot.sampled_at);
159
+ const snapshotIsCurrent = options.advanceSnapshot !== false && observation.snapshot !== undefined && (!Number.isFinite(currentSnapshotAt) ||
160
+ observationAt > currentSnapshotAt ||
161
+ (observationAt === currentSnapshotAt && observation.snapshot.total >= (state.cpu_snapshot?.total ?? 0)));
162
+ const currentMetricAt = state.sampled_at === undefined ? Number.NaN : Date.parse(state.sampled_at);
163
+ const metricIsCurrent = observation.source !== undefined && Number.isFinite(observationAt) && (!Number.isFinite(currentMetricAt) || observationAt >= currentMetricAt);
164
+ return {
165
+ ...state,
166
+ ...(snapshotIsCurrent ? { cpu_snapshot: observation.snapshot } : {}),
167
+ ...(metricIsCurrent
168
+ ? {
169
+ last_busy: observation.busy,
170
+ busy_source: observation.source,
171
+ sampled_at: observation.snapshot.sampled_at,
172
+ }
173
+ : {}),
174
+ };
175
+ }
86
176
  /** Parse MemAvailable kB from /proc/meminfo. */
87
177
  export function parseMemAvailableKb(contents) {
88
178
  for (const line of contents.split("\n")) {
@@ -184,12 +274,14 @@ export function evaluateThrottle(input) {
184
274
  export function applyThrottleDecision(state, decision, nowMs) {
185
275
  if (decision.admit) {
186
276
  return {
277
+ ...state,
187
278
  consecutiveDeferrals: 0,
188
279
  lastAdmissionAt: new Date(nowMs).toISOString(),
189
280
  cpuPaused: false,
190
281
  };
191
282
  }
192
283
  return {
284
+ ...state,
193
285
  consecutiveDeferrals: state.consecutiveDeferrals + 1,
194
286
  lastAdmissionAt: state.lastAdmissionAt,
195
287
  cpuPaused: decision.reason === "cpu" ? true : state.cpuPaused,
@@ -231,10 +323,47 @@ export function readThrottleState(filePath, deps = defaultThrottleDeps()) {
231
323
  process.stderr.write(`lanes throttle: state ${filePath} has invalid cpuPaused\n`);
232
324
  return "unreadable";
233
325
  }
326
+ const optionalTimestamp = (value) => typeof value === "string" && value.trim() !== "" && Number.isFinite(Date.parse(value));
327
+ let cpuSnapshot;
328
+ if (obj.cpu_snapshot !== undefined) {
329
+ const rawSnapshot = obj.cpu_snapshot;
330
+ if (rawSnapshot === null || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) {
331
+ process.stderr.write(`lanes throttle: state ${filePath} has invalid cpu_snapshot\n`);
332
+ return "unreadable";
333
+ }
334
+ const snapshot = rawSnapshot;
335
+ if (typeof snapshot.idle !== "number" || !Number.isFinite(snapshot.idle) || snapshot.idle < 0 ||
336
+ typeof snapshot.total !== "number" || !Number.isFinite(snapshot.total) || snapshot.total < 0 ||
337
+ !optionalTimestamp(snapshot.sampled_at)) {
338
+ process.stderr.write(`lanes throttle: state ${filePath} has invalid cpu_snapshot fields\n`);
339
+ return "unreadable";
340
+ }
341
+ cpuSnapshot = {
342
+ idle: snapshot.idle,
343
+ total: snapshot.total,
344
+ sampled_at: snapshot.sampled_at,
345
+ };
346
+ }
347
+ if (obj.last_busy !== undefined && obj.last_busy !== null && (typeof obj.last_busy !== "number" || !Number.isFinite(obj.last_busy) || obj.last_busy < 0 || obj.last_busy > 1)) {
348
+ process.stderr.write(`lanes throttle: state ${filePath} has invalid last_busy\n`);
349
+ return "unreadable";
350
+ }
351
+ if (obj.busy_source !== undefined && !["interval", "window", "override"].includes(String(obj.busy_source))) {
352
+ process.stderr.write(`lanes throttle: state ${filePath} has invalid busy_source\n`);
353
+ return "unreadable";
354
+ }
355
+ if (obj.sampled_at !== undefined && !optionalTimestamp(obj.sampled_at)) {
356
+ process.stderr.write(`lanes throttle: state ${filePath} has invalid sampled_at\n`);
357
+ return "unreadable";
358
+ }
234
359
  return {
235
360
  consecutiveDeferrals: obj.consecutiveDeferrals,
236
361
  lastAdmissionAt: obj.lastAdmissionAt,
237
362
  cpuPaused: obj.cpuPaused,
363
+ ...(cpuSnapshot !== undefined ? { cpu_snapshot: cpuSnapshot } : {}),
364
+ ...(obj.last_busy !== undefined ? { last_busy: obj.last_busy } : {}),
365
+ ...(obj.busy_source !== undefined ? { busy_source: obj.busy_source } : {}),
366
+ ...(obj.sampled_at !== undefined ? { sampled_at: obj.sampled_at } : {}),
238
367
  };
239
368
  }
240
369
  catch (err) {
@@ -269,7 +398,7 @@ export async function decideAdmission(hqRoot, throttle = {
269
398
  cpu_busy_pause: DEFAULT_CPU_BUSY_PAUSE,
270
399
  cpu_busy_resume: DEFAULT_CPU_BUSY_RESUME,
271
400
  min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
272
- }, deps = defaultThrottleDeps()) {
401
+ }, deps = defaultThrottleDeps(), cpuBusyFraction, cpuObservation) {
273
402
  const filePath = throttleStatePath(hqRoot);
274
403
  const loaded = readThrottleState(filePath, deps);
275
404
  if (loaded === "unreadable") {
@@ -279,18 +408,29 @@ export async function decideAdmission(hqRoot, throttle = {
279
408
  };
280
409
  }
281
410
  const state = loaded === "missing" ? emptyThrottleState() : loaded;
282
- const busy = await resolveCpuBusyFraction(deps);
411
+ const cpuCheckEnabled = isCpuCheckEnabled(deps.env);
412
+ const observation = cpuObservation ?? (cpuBusyFraction === undefined && cpuCheckEnabled
413
+ ? await sampleCpuBusyObservation(deps, state.cpu_snapshot)
414
+ : undefined);
415
+ const busy = cpuBusyFraction === undefined
416
+ ? observation?.busy ?? null
417
+ : cpuBusyFraction;
283
418
  const availableMemMb = readAvailableMemMb(deps);
284
419
  const nowMs = deps.now();
285
420
  const decision = evaluateThrottle({
286
421
  busy,
287
422
  availableMemMb,
288
- cpuCheckEnabled: isCpuCheckEnabled(deps.env),
423
+ cpuCheckEnabled,
289
424
  state,
290
425
  throttle,
291
426
  nowMs,
292
427
  });
293
- const next = applyThrottleDecision(state, decision, nowMs);
428
+ const observedState = observation
429
+ ? recordCpuBusyObservation(state, observation, {
430
+ advanceSnapshot: cpuObservation !== undefined,
431
+ })
432
+ : state;
433
+ const next = applyThrottleDecision(observedState, decision, nowMs);
294
434
  writeThrottleState(filePath, next, deps);
295
435
  return { decision, state: next };
296
436
  }
@@ -173,8 +173,14 @@ export declare const TERMINAL_LANE_STATES: readonly ["done", "failed", "interrup
173
173
  export type TerminalLaneState = (typeof TERMINAL_LANE_STATES)[number];
174
174
  export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
175
175
  export type LaneProvider = (typeof LANE_PROVIDERS)[number];
176
- export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing", "account_retry"];
176
+ export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing", "account_retry", "worker_profile_changed"];
177
177
  export type QueuedReason = (typeof QUEUED_REASONS)[number];
178
+ export interface QueuedDetails {
179
+ reason: "worker_profile_changed";
180
+ expected_hash: string | null;
181
+ actual_hash: string;
182
+ recovery_command: string;
183
+ }
178
184
  export declare function isQueuedReason(value: unknown): value is QueuedReason;
179
185
  export declare const HOST_KINDS: readonly ["local", "outpost"];
180
186
  export type HostKind = (typeof HOST_KINDS)[number];
@@ -442,6 +448,7 @@ export interface LaneRecord {
442
448
  senior_history: SeniorHistoryEntry[];
443
449
  state: LaneState;
444
450
  queued_reason?: QueuedReason;
451
+ queued_details?: QueuedDetails;
445
452
  /** Time the current queue turn began; old records fall back to created_at. */
446
453
  queued_at?: string;
447
454
  /** Short-lived slot reservation between create persistence and worker spawn. */
@@ -107,6 +107,7 @@ export const QUEUED_REASONS = [
107
107
  "throttle",
108
108
  "provider_binary_missing",
109
109
  "account_retry",
110
+ "worker_profile_changed",
110
111
  ];
111
112
  export function isQueuedReason(value) {
112
113
  return typeof value === "string" && QUEUED_REASONS.includes(value);
@@ -220,7 +221,7 @@ export const LANE_FIELD_WRITERS = {
220
221
  project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
221
222
  depth: ["command"], senior: ["command"], senior_history: ["command"],
222
223
  hire_grant: ["creator", "command"],
223
- state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
224
+ state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_details: ["command", "reconcile", "spawn", "watcher"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
224
225
  pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
225
226
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
226
227
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
@@ -380,6 +380,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
380
380
  ...runResultFields(hqRoot, current, at),
381
381
  state: effectiveClassification.state,
382
382
  queued_reason: undefined,
383
+ queued_details: undefined,
383
384
  stalled: undefined,
384
385
  stalled_at: undefined,
385
386
  stalled_reason: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.281.0",
3
+ "version": "5.282.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {