@indigoai-us/hq-cli 5.192.0 → 5.192.2
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 +22 -0
- package/dist/commands/lanes.d.ts +2 -2
- package/dist/commands/lanes.js +230 -10
- package/dist/lib/lanes/admission.js +2 -0
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +7 -0
- package/dist/lib/lanes/outpost-dispatch.js +18 -0
- package/dist/lib/lanes/reconcile.d.ts +10 -1
- package/dist/lib/lanes/reconcile.js +112 -6
- package/dist/lib/lanes/spawn.js +7 -3
- package/dist/lib/lanes/types.d.ts +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.192.2] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Create, resume, automatic repair, and apply now read
|
|
10
|
+
`lanes.claude-account-balance` from hq-flags. `HQ_FLAG_*` remains the local
|
|
11
|
+
override, and a registry read failure keeps the gate off. The
|
|
12
|
+
`accounts balance --json` report includes `balance_gate_enabled`.
|
|
13
|
+
- `hq lanes accounts balance --apply` now honors each lane's company-scoped
|
|
14
|
+
gate, skips disabled company lanes, and rechecks the gate before moving.
|
|
15
|
+
|
|
16
|
+
## [5.192.1] — 2026-09-25
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `hq lanes questions answer` now resumes an asking lane as soon as its final
|
|
21
|
+
pending question is answered. When capacity or throttling prevents a start,
|
|
22
|
+
hq-cli queues the lane with an `answered_questions` resume and the answer
|
|
23
|
+
timestamp. It sends Outpost answers to the lane's owner and reports any
|
|
24
|
+
unanswered sibling questions or a named wake error. After the owner confirms
|
|
25
|
+
the answer, the primary's question copy is marked answered as well.
|
|
26
|
+
|
|
5
27
|
## [5.192.0] — 2026-09-25
|
|
6
28
|
|
|
7
29
|
### Added
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -86,8 +86,8 @@ export interface LanesCommandDeps {
|
|
|
86
86
|
isSsmOwnerDispatch?: () => boolean;
|
|
87
87
|
/** Test seam for changing a lane between pre-lock and locked grant reads. */
|
|
88
88
|
withGrantHireLocks?: typeof withLaneHireLocks;
|
|
89
|
-
/** Test seam for the
|
|
90
|
-
laneClaudeAccountBalanceEnabled?: () => boolean
|
|
89
|
+
/** Test seam for the Claude account-balancing gate. */
|
|
90
|
+
laneClaudeAccountBalanceEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
|
|
91
91
|
}
|
|
92
92
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
93
93
|
export declare function parseSeniorRef(raw: string): SeniorRef;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createOutpostHireGrantContext, parseOutpostHireGrantContext, sameHireGr
|
|
|
21
21
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
22
22
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
23
23
|
export { LANE_HIRE_GRANTS_FLAG };
|
|
24
|
-
import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
24
|
+
import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostQuestionAnswerCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
25
25
|
import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
|
|
26
26
|
import { BALANCE_MOVABLE_STATES, CLAUDE_ACCOUNT_BALANCE_FLAG, accountLoadsForReport, assertBalanceApplyEnabled, planClaudeBalanceMoves, selectClaudeAccount, } from "../lib/lanes/claude-account-balance.js";
|
|
27
27
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
@@ -30,7 +30,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
30
30
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
31
31
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
32
32
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
33
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
33
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
34
34
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
35
35
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
36
36
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
@@ -57,8 +57,10 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
|
57
57
|
function defaultLanePlacementEnabled() {
|
|
58
58
|
return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
|
|
59
59
|
}
|
|
60
|
-
function defaultLaneClaudeAccountBalanceEnabled() {
|
|
61
|
-
|
|
60
|
+
function defaultLaneClaudeAccountBalanceEnabled(company) {
|
|
61
|
+
// Keep the local HQ_FLAG override and the registry reader's fail-closed
|
|
62
|
+
// behavior, matching the lane hire-grants gate.
|
|
63
|
+
return readLanesRegistryFlag(CLAUDE_ACCOUNT_BALANCE_FLAG, { company });
|
|
62
64
|
}
|
|
63
65
|
function defaultLaneHireGrantsEnabled(company) {
|
|
64
66
|
return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
|
|
@@ -2516,7 +2518,7 @@ async function runCreate(ctx, opts) {
|
|
|
2516
2518
|
}
|
|
2517
2519
|
const allowedAccounts = validatedAccounts.size > 0 ? [...validatedAccounts] : profileAccounts;
|
|
2518
2520
|
let accountSelection;
|
|
2519
|
-
if (provider === "claude" && ctx.laneClaudeAccountBalanceEnabled()) {
|
|
2521
|
+
if (provider === "claude" && await ctx.laneClaudeAccountBalanceEnabled(company)) {
|
|
2520
2522
|
const targetHost = placementHostOverride ?? (outpostId !== undefined
|
|
2521
2523
|
? { kind: "outpost", id: outpostId }
|
|
2522
2524
|
: laneHostFromCtx(ctx));
|
|
@@ -3208,6 +3210,139 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3208
3210
|
outpost_transport: "outposts_exec",
|
|
3209
3211
|
});
|
|
3210
3212
|
}
|
|
3213
|
+
async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
3214
|
+
if (lane.host.kind !== "outpost") {
|
|
3215
|
+
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to answer its question`, { lane_id: lane.lane_id, question_id: questionId });
|
|
3216
|
+
}
|
|
3217
|
+
const outpostId = lane.host.id;
|
|
3218
|
+
let dispatched;
|
|
3219
|
+
try {
|
|
3220
|
+
dispatched = await ctx.dispatchOutpostExec({
|
|
3221
|
+
outpostId,
|
|
3222
|
+
command: buildOutpostQuestionAnswerCommand({
|
|
3223
|
+
questionId,
|
|
3224
|
+
choice: options.choice,
|
|
3225
|
+
freeform: options.freeform,
|
|
3226
|
+
notes: options.notes,
|
|
3227
|
+
}),
|
|
3228
|
+
});
|
|
3229
|
+
}
|
|
3230
|
+
catch (err) {
|
|
3231
|
+
throw new LanesError("outpost_dispatch_failed", `Could not dispatch question ${questionId} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
3232
|
+
}
|
|
3233
|
+
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
|
|
3234
|
+
throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} did not confirm the answer for question ${questionId}`, {
|
|
3235
|
+
lane_id: lane.lane_id,
|
|
3236
|
+
question_id: questionId,
|
|
3237
|
+
outpost_id: outpostId,
|
|
3238
|
+
command_id: dispatched.commandId,
|
|
3239
|
+
status: dispatched.status,
|
|
3240
|
+
done: dispatched.done ?? false,
|
|
3241
|
+
exit_code: dispatched.exitCode,
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost question answer");
|
|
3245
|
+
if (remote?.error !== undefined) {
|
|
3246
|
+
throw new LanesError("outpost_remote_command_failed", typeof remote.message === "string"
|
|
3247
|
+
? remote.message
|
|
3248
|
+
: `Outpost ${outpostId} rejected the answer for question ${questionId}`, {
|
|
3249
|
+
lane_id: lane.lane_id,
|
|
3250
|
+
question_id: questionId,
|
|
3251
|
+
outpost_id: outpostId,
|
|
3252
|
+
command_id: dispatched.commandId,
|
|
3253
|
+
remote_error: remote.error,
|
|
3254
|
+
});
|
|
3255
|
+
}
|
|
3256
|
+
const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
|
|
3257
|
+
? remote.question
|
|
3258
|
+
: null;
|
|
3259
|
+
const awaitingWakeConfirmed = lane.state !== "awaiting_input" ||
|
|
3260
|
+
(remote?.wake_status === "resumed" && remote.lane_state === "running") ||
|
|
3261
|
+
(remote?.wake_status === "queued" && remote.lane_state === "queued") ||
|
|
3262
|
+
(remote?.wake_status === "awaiting_answers" && remote.lane_state === "awaiting_input");
|
|
3263
|
+
if (remote?.ok !== true ||
|
|
3264
|
+
remote.action !== "questions.answer" ||
|
|
3265
|
+
remoteQuestion?.question_id !== questionId ||
|
|
3266
|
+
remoteQuestion.lane_id !== lane.lane_id ||
|
|
3267
|
+
remoteQuestion.status !== "answered" ||
|
|
3268
|
+
typeof remote.delivery !== "string" ||
|
|
3269
|
+
!awaitingWakeConfirmed) {
|
|
3270
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without confirming the answer and wake status for question ${questionId}`, {
|
|
3271
|
+
lane_id: lane.lane_id,
|
|
3272
|
+
question_id: questionId,
|
|
3273
|
+
outpost_id: outpostId,
|
|
3274
|
+
command_id: dispatched.commandId,
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
let ownerQuestion;
|
|
3278
|
+
try {
|
|
3279
|
+
ownerQuestion = parseQuestionRecord(remoteQuestion);
|
|
3280
|
+
}
|
|
3281
|
+
catch (err) {
|
|
3282
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a valid answered question record for ${questionId}: ${dispatchErrorMessage(err)}`, {
|
|
3283
|
+
lane_id: lane.lane_id,
|
|
3284
|
+
question_id: questionId,
|
|
3285
|
+
outpost_id: outpostId,
|
|
3286
|
+
command_id: dispatched.commandId,
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
const ownerAnswer = ownerQuestion.answer;
|
|
3290
|
+
if (!isQuestionAnswer(ownerAnswer)) {
|
|
3291
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return the confirmed answer for question ${questionId}`, {
|
|
3292
|
+
lane_id: lane.lane_id,
|
|
3293
|
+
question_id: questionId,
|
|
3294
|
+
outpost_id: outpostId,
|
|
3295
|
+
command_id: dispatched.commandId,
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
const syncedQuestion = casUpdateQuestion(ctx.hqRoot(), questionId, (current) => {
|
|
3299
|
+
if (current.lane_id !== lane.lane_id) {
|
|
3300
|
+
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} belongs to a different lane than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
3301
|
+
}
|
|
3302
|
+
if (current.status === "pending") {
|
|
3303
|
+
return { ...current, status: "answered", answer: ownerAnswer };
|
|
3304
|
+
}
|
|
3305
|
+
if (current.status === "answered" && sameQuestionAnswer(current.answer, ownerAnswer)) {
|
|
3306
|
+
return current;
|
|
3307
|
+
}
|
|
3308
|
+
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} is ${current.status} or has a different answer than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
3309
|
+
});
|
|
3310
|
+
printJson({
|
|
3311
|
+
...remote,
|
|
3312
|
+
lane_id: lane.lane_id,
|
|
3313
|
+
question: syncedQuestion,
|
|
3314
|
+
outpost_id: outpostId,
|
|
3315
|
+
outpost_command_id: dispatched.commandId,
|
|
3316
|
+
outpost_transport: "outposts_exec",
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
function isQuestionAnswer(value) {
|
|
3320
|
+
if (value === null || typeof value !== "object")
|
|
3321
|
+
return false;
|
|
3322
|
+
const answer = value;
|
|
3323
|
+
return (typeof answer.choice === "string" &&
|
|
3324
|
+
answer.choice.trim() !== "" &&
|
|
3325
|
+
(answer.answer_kind === undefined ||
|
|
3326
|
+
answer.answer_kind === "option" ||
|
|
3327
|
+
answer.answer_kind === "freeform") &&
|
|
3328
|
+
(answer.notes_ref === undefined || typeof answer.notes_ref === "string") &&
|
|
3329
|
+
typeof answer.answered_by === "string" &&
|
|
3330
|
+
answer.answered_by.trim() !== "" &&
|
|
3331
|
+
typeof answer.answered_at === "string" &&
|
|
3332
|
+
Number.isFinite(Date.parse(answer.answered_at)) &&
|
|
3333
|
+
typeof answer.answered_level === "number" &&
|
|
3334
|
+
Number.isInteger(answer.answered_level) &&
|
|
3335
|
+
answer.answered_level > 0);
|
|
3336
|
+
}
|
|
3337
|
+
function sameQuestionAnswer(left, right) {
|
|
3338
|
+
return (left !== undefined &&
|
|
3339
|
+
left.choice === right.choice &&
|
|
3340
|
+
left.answer_kind === right.answer_kind &&
|
|
3341
|
+
left.notes_ref === right.notes_ref &&
|
|
3342
|
+
left.answered_by === right.answered_by &&
|
|
3343
|
+
left.answered_at === right.answered_at &&
|
|
3344
|
+
left.answered_level === right.answered_level);
|
|
3345
|
+
}
|
|
3211
3346
|
function ingestPickupPayload(hqRoot, payload, now) {
|
|
3212
3347
|
const laneId = payload.lane.lane_id;
|
|
3213
3348
|
if (!laneId) {
|
|
@@ -4708,7 +4843,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
4708
4843
|
async function selectAutomaticClaudeResumeAccount(ctx, lane) {
|
|
4709
4844
|
if (lane.provider !== "claude")
|
|
4710
4845
|
return undefined;
|
|
4711
|
-
if (!ctx.laneClaudeAccountBalanceEnabled()) {
|
|
4846
|
+
if (!await ctx.laneClaudeAccountBalanceEnabled(lane.company)) {
|
|
4712
4847
|
if (lane.account === undefined)
|
|
4713
4848
|
return undefined;
|
|
4714
4849
|
const config = loadLanesConfig(ctx.hqRoot());
|
|
@@ -5112,6 +5247,7 @@ async function applyClaudeBalanceMove(ctx, suggestion) {
|
|
|
5112
5247
|
return { lane_id: suggestion.lane_id, host: suggestion.host, from: suggestion.from, to: suggestion.to, state: suggestion.state };
|
|
5113
5248
|
}
|
|
5114
5249
|
const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), suggestion.lane_id));
|
|
5250
|
+
assertBalanceApplyEnabled(await ctx.laneClaudeAccountBalanceEnabled(lane.company));
|
|
5115
5251
|
if (lane.state === "running") {
|
|
5116
5252
|
throw new LanesError("lane_running", `Lane ${lane.lane_id} is running; balance never moves a running turn`, {
|
|
5117
5253
|
lane_id: lane.lane_id,
|
|
@@ -5140,8 +5276,8 @@ async function applyClaudeBalanceMove(ctx, suggestion) {
|
|
|
5140
5276
|
return { lane_id: moved.lane.lane_id, host: moved.lane.host, from: moved.from ?? suggestion.from, to: moved.to, state: moved.lane.state };
|
|
5141
5277
|
}
|
|
5142
5278
|
async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
|
|
5143
|
-
assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
|
|
5144
5279
|
const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), laneId));
|
|
5280
|
+
assertBalanceApplyEnabled(await ctx.laneClaudeAccountBalanceEnabled(lane.company));
|
|
5145
5281
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "balance-move-target");
|
|
5146
5282
|
if (lane.provider !== "claude") {
|
|
5147
5283
|
throw new LanesError("claude_account_balance_provider_mismatch", `Lane ${laneId} does not use Claude`, {
|
|
@@ -6464,9 +6600,16 @@ async function runQuestionsShow(ctx, questionId) {
|
|
|
6464
6600
|
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
6465
6601
|
const hqRoot = ctx.hqRoot();
|
|
6466
6602
|
const question = readQuestion(hqRoot, questionId);
|
|
6467
|
-
throwIfQuestionNotPending(question);
|
|
6468
6603
|
await yieldEventLoop();
|
|
6469
6604
|
const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
|
|
6605
|
+
if (askingLane.host.kind === "outpost" && !laneIsHostedHere(ctx, askingLane)) {
|
|
6606
|
+
await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId, opts);
|
|
6607
|
+
return;
|
|
6608
|
+
}
|
|
6609
|
+
throwIfQuestionNotPending(question);
|
|
6610
|
+
if (askingLane.state === "awaiting_input") {
|
|
6611
|
+
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "wake after answering");
|
|
6612
|
+
}
|
|
6470
6613
|
if (laneAcceptsDropboxDelivery(askingLane)) {
|
|
6471
6614
|
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "deliver an answer to");
|
|
6472
6615
|
}
|
|
@@ -6511,6 +6654,53 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
|
6511
6654
|
},
|
|
6512
6655
|
};
|
|
6513
6656
|
});
|
|
6657
|
+
const latestAskingLane = readLane(hqRoot, askingLane.lane_id);
|
|
6658
|
+
if (latestAskingLane.state === "awaiting_input") {
|
|
6659
|
+
let wake;
|
|
6660
|
+
try {
|
|
6661
|
+
wake = await wakeAnsweredLaneNow(hqRoot, askingLane.lane_id, {
|
|
6662
|
+
now: ctx.now,
|
|
6663
|
+
host: reconcileHostFromCtx(ctx),
|
|
6664
|
+
dryRun: false,
|
|
6665
|
+
spawnLaneDeps: {
|
|
6666
|
+
env: ctx.env,
|
|
6667
|
+
existsSync: ctx.existsSync,
|
|
6668
|
+
spawnProcess: ctx.spawnProcess,
|
|
6669
|
+
proveDetached: ctx.proveDetached,
|
|
6670
|
+
killProcess: ctx.killProcess,
|
|
6671
|
+
now: ctx.now,
|
|
6672
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
6673
|
+
runCompaction: ctx.runCompaction,
|
|
6674
|
+
locateTranscript: ctx.locateTranscript,
|
|
6675
|
+
},
|
|
6676
|
+
throttleDeps: ctx.throttleDeps,
|
|
6677
|
+
livenessDeps: livenessDepsFromCtx(ctx),
|
|
6678
|
+
presenceConnected: ctx.presenceConnected,
|
|
6679
|
+
mesh: ctx.mesh,
|
|
6680
|
+
env: ctx.env,
|
|
6681
|
+
});
|
|
6682
|
+
}
|
|
6683
|
+
catch (err) {
|
|
6684
|
+
throw new LanesError("question_wake_failed", `Question ${questionId} was recorded, but lane ${askingLane.lane_id} could not be woken: ${dispatchErrorMessage(err)}`, { lane_id: askingLane.lane_id, question_id: questionId });
|
|
6685
|
+
}
|
|
6686
|
+
if (wake.status === "failed") {
|
|
6687
|
+
throw new LanesError("question_wake_failed", `Question ${questionId} was recorded, but lane ${askingLane.lane_id} could not be woken: ${wake.error}`, { lane_id: askingLane.lane_id, question_id: questionId, wake_error: wake.error });
|
|
6688
|
+
}
|
|
6689
|
+
printJson({
|
|
6690
|
+
ok: true,
|
|
6691
|
+
action: "questions.answer",
|
|
6692
|
+
lane_id: askingLane.lane_id,
|
|
6693
|
+
question: updated,
|
|
6694
|
+
delivery: wake.status === "awaiting_answers" ? "awaiting_other_answers" : wake.status,
|
|
6695
|
+
wake_status: wake.status,
|
|
6696
|
+
lane_state: wake.lane_state,
|
|
6697
|
+
...(wake.queued_reason !== undefined ? { queued_reason: wake.queued_reason } : {}),
|
|
6698
|
+
...(wake.remaining_question_ids !== undefined
|
|
6699
|
+
? { remaining_question_ids: wake.remaining_question_ids }
|
|
6700
|
+
: {}),
|
|
6701
|
+
});
|
|
6702
|
+
return;
|
|
6703
|
+
}
|
|
6514
6704
|
const delivery = deliverAnswerToAskingLane(ctx, updated, choice, at);
|
|
6515
6705
|
printJson({
|
|
6516
6706
|
ok: true,
|
|
@@ -6686,11 +6876,30 @@ async function runAccountsBalance(ctx, opts) {
|
|
|
6686
6876
|
accepted: ["claude"],
|
|
6687
6877
|
});
|
|
6688
6878
|
}
|
|
6689
|
-
if (opts.apply)
|
|
6690
|
-
assertBalanceApplyEnabled(ctx.laneClaudeAccountBalanceEnabled());
|
|
6691
6879
|
const hqRoot = ctx.hqRoot();
|
|
6692
6880
|
const config = loadLanesConfig(hqRoot);
|
|
6693
6881
|
const loaded = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
|
|
6882
|
+
const laneBalanceGateEnabled = new Map();
|
|
6883
|
+
const companyBalanceGateEnabled = new Map();
|
|
6884
|
+
const lanes = loaded.snapshots.flatMap((host) => host.lanes);
|
|
6885
|
+
for (const lane of lanes) {
|
|
6886
|
+
const companyKey = JSON.stringify([lane.company?.uid ?? null, lane.company?.slug ?? null]);
|
|
6887
|
+
let enabled = companyBalanceGateEnabled.get(companyKey);
|
|
6888
|
+
if (enabled === undefined) {
|
|
6889
|
+
enabled = await ctx.laneClaudeAccountBalanceEnabled(lane.company);
|
|
6890
|
+
companyBalanceGateEnabled.set(companyKey, enabled);
|
|
6891
|
+
}
|
|
6892
|
+
laneBalanceGateEnabled.set(lane.lane_id, enabled);
|
|
6893
|
+
}
|
|
6894
|
+
// With no lanes there is no tenant scope to resolve, so report and enforce
|
|
6895
|
+
// the unscoped/default gate. Otherwise the report reflects whether any
|
|
6896
|
+
// represented lane company has the gate enabled.
|
|
6897
|
+
if (lanes.length === 0) {
|
|
6898
|
+
companyBalanceGateEnabled.set(JSON.stringify([null, null]), await ctx.laneClaudeAccountBalanceEnabled());
|
|
6899
|
+
}
|
|
6900
|
+
const balanceGateEnabled = [...companyBalanceGateEnabled.values()].some(Boolean);
|
|
6901
|
+
if (opts.apply)
|
|
6902
|
+
assertBalanceApplyEnabled(balanceGateEnabled);
|
|
6694
6903
|
const loads = accountLoadsForReport({ hosts: loaded.snapshots, config });
|
|
6695
6904
|
const suggestions = planClaudeBalanceMoves({ hosts: loaded.snapshots, config });
|
|
6696
6905
|
const selections = loaded.snapshots.map((host) => {
|
|
@@ -6752,6 +6961,16 @@ async function runAccountsBalance(ctx, opts) {
|
|
|
6752
6961
|
});
|
|
6753
6962
|
continue;
|
|
6754
6963
|
}
|
|
6964
|
+
if (laneBalanceGateEnabled.get(suggestion.lane_id) !== true) {
|
|
6965
|
+
skipped.push({
|
|
6966
|
+
lane_id: suggestion.lane_id,
|
|
6967
|
+
host: suggestion.host,
|
|
6968
|
+
from: suggestion.from,
|
|
6969
|
+
to: suggestion.to,
|
|
6970
|
+
reason: "claude_account_balance_disabled",
|
|
6971
|
+
});
|
|
6972
|
+
continue;
|
|
6973
|
+
}
|
|
6755
6974
|
try {
|
|
6756
6975
|
moved.push(await applyClaudeBalanceMove(ctx, suggestion));
|
|
6757
6976
|
}
|
|
@@ -6770,6 +6989,7 @@ async function runAccountsBalance(ctx, opts) {
|
|
|
6770
6989
|
ok: true,
|
|
6771
6990
|
action: "accounts.balance",
|
|
6772
6991
|
provider: "claude",
|
|
6992
|
+
balance_gate_enabled: balanceGateEnabled,
|
|
6773
6993
|
accounts,
|
|
6774
6994
|
selections,
|
|
6775
6995
|
unavailable_hosts: loaded.unavailableHosts,
|
|
@@ -135,6 +135,8 @@ export function hasActivePendingResume(lane, nowMs) {
|
|
|
135
135
|
const pending = lane.pending_resume;
|
|
136
136
|
if (!pending)
|
|
137
137
|
return false;
|
|
138
|
+
if (lane.state === "queued" && pending.trigger === "answered_questions")
|
|
139
|
+
return false;
|
|
138
140
|
const reservedAt = Date.parse(pending.at);
|
|
139
141
|
const ageMs = nowMs - reservedAt;
|
|
140
142
|
return Number.isFinite(reservedAt) && ageMs >= 0 && ageMs < ADMISSION_RESERVATION_GRACE_MS;
|
|
@@ -42,8 +42,8 @@ export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PRO
|
|
|
42
42
|
export type { AccountResolveContext, AccountStatus, AuthState, ResolvedAccount, } from "./accounts.js";
|
|
43
43
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
44
44
|
export type { LivenessDeps, LivenessEventKind, LivenessIndex, SessionIndexEntry, } from "./liveness.js";
|
|
45
|
-
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
46
|
-
export type { ReconcileChange, ReconcileDeps, ReconcileHost, ReconcileSkip, ReconcileSummary, } from "./reconcile.js";
|
|
45
|
+
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
46
|
+
export type { AnsweredLaneWakeResult, ReconcileChange, ReconcileDeps, ReconcileHost, ReconcileSkip, ReconcileSummary, } from "./reconcile.js";
|
|
47
47
|
export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
|
|
48
48
|
export type { ProviderPathEnv, ReconcileInstallOpts, ReconcileInstallResult, RunCommand, RunCommandResult, } from "./reconcile-install.js";
|
|
49
49
|
export { unitPathEnv } from "./reconcile-install.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export { LANE_MESH_EVENTS, WIRE_EVENT_KINDS, createLaneMesh, emitLaneDecisionEve
|
|
|
23
23
|
export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO, STARVATION_MAX_DEFERRALS, STARVATION_MAX_MS, applyThrottleDecision, busyFractionFromDeltas, cpuTimesSnapshot, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, parseMemAvailableKb, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyFraction, starvationBoundReached, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
24
24
|
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
25
25
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
26
|
-
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
26
|
+
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
27
27
|
export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
|
|
28
28
|
export { unitPathEnv } from "./reconcile-install.js";
|
|
29
29
|
export { PROVIDER_REJECTED_REASON, PROVIDER_REJECTION_MESSAGE_CAP, parseClaudeResultRejection, parseCodexTurnFailed, parseGrokErrorOutput, parseProviderRejection, readProviderRejection, retryableFromStatus, } from "./provider-rejection.js";
|
|
@@ -41,6 +41,13 @@ export interface OutpostLaneCommand {
|
|
|
41
41
|
grantContext?: string;
|
|
42
42
|
compact?: boolean;
|
|
43
43
|
}
|
|
44
|
+
export declare function buildOutpostQuestionAnswerCommand(input: {
|
|
45
|
+
questionId: string;
|
|
46
|
+
choice?: string;
|
|
47
|
+
freeform?: string;
|
|
48
|
+
notes?: string;
|
|
49
|
+
hqBinary?: string;
|
|
50
|
+
}): string;
|
|
44
51
|
export interface OutpostExecPort {
|
|
45
52
|
stageExecInput: (outpostId?: string) => Promise<{
|
|
46
53
|
putUrl: string;
|
|
@@ -10,6 +10,24 @@ import { OutpostsClient, joinCommandParts, withRemoteHqDir } from "@indigoai-us/
|
|
|
10
10
|
import { LanesError } from "./errors.js";
|
|
11
11
|
export const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
|
|
12
12
|
export const PICKUP_WAIT_MS = PICKUP_EXEC_TIMEOUT_SECONDS * 1000;
|
|
13
|
+
export function buildOutpostQuestionAnswerCommand(input) {
|
|
14
|
+
const args = [
|
|
15
|
+
input.hqBinary?.trim() || "hq",
|
|
16
|
+
"lanes",
|
|
17
|
+
"questions",
|
|
18
|
+
"answer",
|
|
19
|
+
input.questionId,
|
|
20
|
+
];
|
|
21
|
+
for (const [flag, value] of [
|
|
22
|
+
["--choice", input.choice],
|
|
23
|
+
["--freeform", input.freeform],
|
|
24
|
+
["--notes", input.notes],
|
|
25
|
+
]) {
|
|
26
|
+
if (value !== undefined)
|
|
27
|
+
args.push(flag, value);
|
|
28
|
+
}
|
|
29
|
+
return withRemoteHqDir(joinCommandParts(args));
|
|
30
|
+
}
|
|
13
31
|
export function parseOutpostPickupPayload(raw) {
|
|
14
32
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
15
33
|
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload must be a JSON object", {});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { type LivenessDeps } from "./liveness.js";
|
|
9
9
|
import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
|
|
10
10
|
import { type ThrottleDeps } from "./throttle.js";
|
|
11
|
-
import type { LaneRecord, LivenessState, QuestionRecord, SeniorRef } from "./types.js";
|
|
11
|
+
import type { LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
|
|
12
12
|
import { type LaneMesh } from "./mesh-events.js";
|
|
13
13
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
14
14
|
export declare const QUESTION_AGE_MS: number;
|
|
@@ -102,4 +102,13 @@ export declare function runPassBody(hqRoot: string, deps: ReconcileDeps): Promis
|
|
|
102
102
|
* `ok: false` rather than being swallowed.
|
|
103
103
|
*/
|
|
104
104
|
export declare function runReconcilePass(hqRoot: string, deps: ReconcileDeps): Promise<ReconcileSummary>;
|
|
105
|
+
export interface AnsweredLaneWakeResult {
|
|
106
|
+
status: "resumed" | "queued" | "awaiting_answers" | "failed";
|
|
107
|
+
lane_state: LaneRecord["state"];
|
|
108
|
+
queued_reason?: QueuedReason;
|
|
109
|
+
remaining_question_ids?: string[];
|
|
110
|
+
error?: string;
|
|
111
|
+
}
|
|
112
|
+
/** Run the reconcile answer-resume path for one lane before questions answer returns. */
|
|
113
|
+
export declare function wakeAnsweredLaneNow(hqRoot: string, laneId: string, deps: ReconcileDeps): Promise<AnsweredLaneWakeResult>;
|
|
105
114
|
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -1284,9 +1284,11 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
|
1284
1284
|
writeThrottleState(filePath, applyThrottleDecision(base, applied, nowMs), throttleDeps);
|
|
1285
1285
|
}
|
|
1286
1286
|
}
|
|
1287
|
-
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
1287
|
+
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneIds) {
|
|
1288
1288
|
const config = loadLanesConfig(hqRoot);
|
|
1289
|
-
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
|
|
1289
|
+
const awaiting = lanes.filter((lane) => lane.state === "awaiting_input" &&
|
|
1290
|
+
laneRunsHere(lane, deps.host) &&
|
|
1291
|
+
(onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)));
|
|
1290
1292
|
if (awaiting.length === 0)
|
|
1291
1293
|
return;
|
|
1292
1294
|
const throttleDeps = deps.throttleDeps ?? defaultThrottleDeps(deps.env ?? process.env);
|
|
@@ -1307,10 +1309,24 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1307
1309
|
const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps);
|
|
1308
1310
|
throttleDecision = throttle.decision;
|
|
1309
1311
|
}
|
|
1310
|
-
if (!throttleDecision.admit)
|
|
1311
|
-
return;
|
|
1312
1312
|
const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, deps.spawnLaneDeps));
|
|
1313
1313
|
const nowMs = deps.now().getTime();
|
|
1314
|
+
const fallbackAnswerAt = new Date(nowMs).toISOString();
|
|
1315
|
+
const answerAtByLane = new Map();
|
|
1316
|
+
for (const question of questions) {
|
|
1317
|
+
if (question.status !== "answered")
|
|
1318
|
+
continue;
|
|
1319
|
+
const at = question.answer?.answered_at ?? question.asked_at;
|
|
1320
|
+
const previous = answerAtByLane.get(question.origin.id);
|
|
1321
|
+
if (previous === undefined || Date.parse(at) > Date.parse(previous)) {
|
|
1322
|
+
answerAtByLane.set(question.origin.id, at);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const latestAnswerAt = (lane) => answerAtByLane.get(lane.lane_id) ?? fallbackAnswerAt;
|
|
1326
|
+
awaiting.sort((a, b) => {
|
|
1327
|
+
const byAnswerTime = Date.parse(latestAnswerAt(a)) - Date.parse(latestAnswerAt(b));
|
|
1328
|
+
return byAnswerTime || compareLaneQueueOrder(a, b);
|
|
1329
|
+
});
|
|
1314
1330
|
const capacityLanes = lanes.map((lane) => {
|
|
1315
1331
|
try {
|
|
1316
1332
|
return readLane(hqRoot, lane.lane_id);
|
|
@@ -1336,7 +1352,55 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1336
1352
|
if (current.worker_profile !== undefined) {
|
|
1337
1353
|
profileMaxConcurrent = loadWorkerProfile(hqRoot, current.worker_profile).meta.max_concurrent;
|
|
1338
1354
|
}
|
|
1339
|
-
|
|
1355
|
+
const capReason = laneAdmissionCapReason(running, current, config, deps.host, profileMaxConcurrent, nowMs);
|
|
1356
|
+
const answeredAt = latestAnswerAt(current);
|
|
1357
|
+
const queueCandidate = { ...current, state: "queued", queued_at: answeredAt };
|
|
1358
|
+
const earlierQueued = capacityLanes.some((queued) => queued.lane_id !== current.lane_id &&
|
|
1359
|
+
queued.state === "queued" &&
|
|
1360
|
+
queued.provider === current.provider &&
|
|
1361
|
+
laneRunsHere(queued, deps.host) &&
|
|
1362
|
+
compareLaneQueueOrder(queued, queueCandidate) < 0);
|
|
1363
|
+
const queueReason = !throttleDecision.admit
|
|
1364
|
+
? "throttle"
|
|
1365
|
+
: capReason ?? (earlierQueued ? "queue_position" : undefined);
|
|
1366
|
+
if (queueReason !== undefined) {
|
|
1367
|
+
const queuedCount = capacityLanes.filter((queued) => queued.state === "queued" &&
|
|
1368
|
+
queued.provider === current.provider &&
|
|
1369
|
+
laneRunsHere(queued, deps.host) &&
|
|
1370
|
+
!hasFreshAdmissionReservation(queued, nowMs)).length;
|
|
1371
|
+
const maxQueued = config.providers[current.provider].max_queued;
|
|
1372
|
+
if (queuedCount >= maxQueued) {
|
|
1373
|
+
summary.skipped.push({
|
|
1374
|
+
kind: "lane",
|
|
1375
|
+
id: current.lane_id,
|
|
1376
|
+
why: `lane_queue_full: ${current.provider} queue has ${queuedCount}/${maxQueued} lanes`,
|
|
1377
|
+
});
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (!deps.dryRun)
|
|
1382
|
+
writeAnsweredQuestions(hqRoot, current, questions);
|
|
1383
|
+
if (queueReason !== undefined) {
|
|
1384
|
+
const queued = casLaneIf(hqRoot, current.lane_id, (latest) => latest.state === "awaiting_input", (latest) => ({
|
|
1385
|
+
...latest,
|
|
1386
|
+
state: "queued",
|
|
1387
|
+
queued_reason: queueReason,
|
|
1388
|
+
queued_at: answeredAt,
|
|
1389
|
+
pending_resume: {
|
|
1390
|
+
...(latest.provider_session_id
|
|
1391
|
+
? { session_id: latest.provider_session_id }
|
|
1392
|
+
: {}),
|
|
1393
|
+
trigger: "answered_questions",
|
|
1394
|
+
at: answeredAt,
|
|
1395
|
+
},
|
|
1396
|
+
}), deps.dryRun);
|
|
1397
|
+
summary.resumed.push({
|
|
1398
|
+
id: queued.record.lane_id,
|
|
1399
|
+
from: "awaiting_input",
|
|
1400
|
+
to: "queued",
|
|
1401
|
+
reason: queueReason,
|
|
1402
|
+
});
|
|
1403
|
+
capacityLanes.push(queued.record);
|
|
1340
1404
|
continue;
|
|
1341
1405
|
}
|
|
1342
1406
|
if (deps.dryRun) {
|
|
@@ -1348,7 +1412,6 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1348
1412
|
running.push({ ...current, state: "running" });
|
|
1349
1413
|
continue;
|
|
1350
1414
|
}
|
|
1351
|
-
writeAnsweredQuestions(hqRoot, lane, questions);
|
|
1352
1415
|
const started = spawnFn(hqRoot, lane);
|
|
1353
1416
|
if (started.state === "running")
|
|
1354
1417
|
running.push(started);
|
|
@@ -1376,4 +1439,47 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps) {
|
|
|
1376
1439
|
export async function runReconcilePass(hqRoot, deps) {
|
|
1377
1440
|
return withReconcileLock(hqRoot, async () => runPassBody(hqRoot, deps));
|
|
1378
1441
|
}
|
|
1442
|
+
/** Run the reconcile answer-resume path for one lane before questions answer returns. */
|
|
1443
|
+
export async function wakeAnsweredLaneNow(hqRoot, laneId, deps) {
|
|
1444
|
+
const summary = emptySummary(deps.dryRun);
|
|
1445
|
+
await withReconcileLock(hqRoot, async () => {
|
|
1446
|
+
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
1447
|
+
const lanes = loadLaneRecords(hqRoot, summary);
|
|
1448
|
+
const questions = loadQuestionRecords(hqRoot, summary);
|
|
1449
|
+
if (!lanes.some((lane) => lane.lane_id === laneId)) {
|
|
1450
|
+
throw new LanesError("lane_not_found", `Lane ${laneId} was not found`, { lane_id: laneId });
|
|
1451
|
+
}
|
|
1452
|
+
await resumeAwaiting(hqRoot, lanes, questions, summary, deps, new Set([laneId]));
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
const latest = readLane(hqRoot, laneId);
|
|
1456
|
+
const laneQuestions = loadQuestionRecords(hqRoot, emptySummary(false)).filter((question) => question.origin.id === laneId);
|
|
1457
|
+
const remaining = laneQuestions
|
|
1458
|
+
.filter((question) => question.status === "pending")
|
|
1459
|
+
.map((question) => question.question_id)
|
|
1460
|
+
.sort();
|
|
1461
|
+
if (latest.state === "running") {
|
|
1462
|
+
return { status: "resumed", lane_state: latest.state };
|
|
1463
|
+
}
|
|
1464
|
+
if (latest.state === "queued" && latest.pending_resume?.trigger === "answered_questions") {
|
|
1465
|
+
return {
|
|
1466
|
+
status: "queued",
|
|
1467
|
+
lane_state: latest.state,
|
|
1468
|
+
...(latest.queued_reason !== undefined ? { queued_reason: latest.queued_reason } : {}),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (latest.state === "awaiting_input" && remaining.length > 0) {
|
|
1472
|
+
return {
|
|
1473
|
+
status: "awaiting_answers",
|
|
1474
|
+
lane_state: latest.state,
|
|
1475
|
+
remaining_question_ids: remaining,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
const failure = summary.skipped.find((skip) => skip.kind === "lane" && skip.id === laneId);
|
|
1479
|
+
return {
|
|
1480
|
+
status: "failed",
|
|
1481
|
+
lane_state: latest.state,
|
|
1482
|
+
error: failure?.why ?? `Lane ${laneId} did not leave awaiting_input after its questions were answered`,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1379
1485
|
//# sourceMappingURL=reconcile.js.map
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1553,9 +1553,13 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1553
1553
|
// state and the caller sees the error. Admission and question resume keep
|
|
1554
1554
|
// today's behaviour of naming the terminal reason on the record.
|
|
1555
1555
|
const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
|
|
1556
|
-
const plan = pendingResume
|
|
1557
|
-
?
|
|
1558
|
-
|
|
1556
|
+
const plan = pendingResume?.trigger === "answered_questions"
|
|
1557
|
+
? pendingResume.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
1558
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
1559
|
+
: planLaneLaunch(existing, { kind: "answered_questions" })
|
|
1560
|
+
: pendingResume?.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
1561
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
1562
|
+
: planLaneLaunch(existing, trigger);
|
|
1559
1563
|
if (existing.host.kind === "outpost") {
|
|
1560
1564
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
1561
1565
|
}
|
|
@@ -508,7 +508,8 @@ export interface AccountHistoryEntry {
|
|
|
508
508
|
reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "operator_move";
|
|
509
509
|
}
|
|
510
510
|
export interface PendingResume {
|
|
511
|
-
|
|
511
|
+
/** Fresh answered-question launches need no previous session id. */
|
|
512
|
+
session_id?: string;
|
|
512
513
|
trigger: "answered_questions" | "message" | "resume";
|
|
513
514
|
resumed_from?: "interrupted" | "done" | "blocked" | "failed";
|
|
514
515
|
text?: string;
|