@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 +15 -0
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-move.js +2 -0
- package/dist/commands/lanes-resume.js +1 -0
- package/dist/commands/lanes-shared.d.ts +6 -0
- package/dist/commands/lanes.js +30 -7
- package/dist/lib/lanes/keep-alive.js +1 -0
- package/dist/lib/lanes/reconcile.d.ts +7 -1
- package/dist/lib/lanes/reconcile.js +88 -16
- package/dist/lib/lanes/spawn.js +5 -0
- package/dist/lib/lanes/store.js +27 -0
- package/dist/lib/lanes/throttle.d.ts +29 -2
- package/dist/lib/lanes/throttle.js +145 -5
- package/dist/lib/lanes/types.d.ts +8 -1
- package/dist/lib/lanes/types.js +2 -1
- package/dist/lib/lanes/watcher.js +1 -0
- package/package.json +1 -1
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
|
|
@@ -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;
|
|
@@ -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;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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 ? {} : {
|
|
@@ -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,
|
|
1019
|
+
await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
|
|
1005
1020
|
const drainLanes = loadLaneRecords(hqRoot, summary);
|
|
1006
|
-
await admitQueued(hqRoot, drainLanes, summary,
|
|
1007
|
-
await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary,
|
|
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 =
|
|
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:
|
|
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,
|
|
2585
|
+
await resumeAwaiting(hqRoot, lanes, questions, summary, admissionDeps, new Set([laneId]));
|
|
2514
2586
|
});
|
|
2515
2587
|
});
|
|
2516
2588
|
const latest = readLane(hqRoot, laneId);
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -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,
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
423
|
+
cpuCheckEnabled,
|
|
289
424
|
state,
|
|
290
425
|
throttle,
|
|
291
426
|
nowMs,
|
|
292
427
|
});
|
|
293
|
-
const
|
|
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. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -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,
|