@indigoai-us/hq-cli 5.176.0 → 5.178.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.
@@ -16,6 +16,7 @@ import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
16
16
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
17
17
  import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
18
18
  export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
19
+ export { LANE_HIRE_GRANTS_FLAG };
19
20
  import { buildOutpostCapacityCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
20
21
  import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
21
22
  import { parseUserYaml } from "../utils/user-yaml-error.js";
@@ -24,7 +25,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
24
25
  import { openMeshTransport } from "../lib/mesh/client.js";
25
26
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
26
27
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
27
- 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, 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, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
28
+ 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, 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";
28
29
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
29
30
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
30
31
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
@@ -51,6 +52,9 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
51
52
  function defaultLanePlacementEnabled() {
52
53
  return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
53
54
  }
55
+ function defaultLaneHireGrantsEnabled() {
56
+ return resolveProcessFlagGate(LANE_HIRE_GRANTS_FLAG, { fallback: false }, () => false);
57
+ }
54
58
  function withoutProviderHome(value) {
55
59
  if (Array.isArray(value))
56
60
  return value.map(withoutProviderHome);
@@ -242,6 +246,7 @@ function resolveDeps(deps = {}) {
242
246
  })),
243
247
  fetchText: deps.fetchText ?? defaultFetchText,
244
248
  lanePlacementEnabled: deps.lanePlacementEnabled ?? defaultLanePlacementEnabled,
249
+ laneHireGrantsEnabled: deps.laneHireGrantsEnabled ?? defaultLaneHireGrantsEnabled,
245
250
  };
246
251
  }
247
252
  function livenessDepsFromCtx(ctx) {
@@ -286,6 +291,16 @@ export function parseSeniorRef(raw) {
286
291
  }
287
292
  return { kind, id };
288
293
  }
294
+ function parseHireGrant(raw) {
295
+ if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
296
+ throw new LanesError("hire_grant_invalid", `Hire grant must be a non-negative safe integer; received ${JSON.stringify(raw)}`, { value: raw });
297
+ }
298
+ const value = Number(raw);
299
+ if (!Number.isSafeInteger(value)) {
300
+ throw new LanesError("hire_grant_invalid", `Hire grant must be a non-negative safe integer; received ${JSON.stringify(raw)}`, { value: raw });
301
+ }
302
+ return value;
303
+ }
289
304
  function seniorKey(ref) {
290
305
  return ref.kind === "user" ? "user" : `${ref.kind}:${ref.id ?? ""}`;
291
306
  }
@@ -416,6 +431,174 @@ function requireQuestionBody(hqRoot, question) {
416
431
  function loadAllLanes(hqRoot) {
417
432
  return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
418
433
  }
434
+ function laneHireFields(lane, lanes) {
435
+ const aliveChildren = aliveDirectChildren(lanes, lane.lane_id);
436
+ const hireGrant = lane.hire_grant ?? 0;
437
+ const hireSummary = `hire ${aliveChildren.length}/${hireGrant}`;
438
+ return {
439
+ hire_grant: hireGrant,
440
+ hire_alive_children: aliveChildren.length,
441
+ hire: `${aliveChildren.length}/${hireGrant}`,
442
+ hire_summary: hireSummary,
443
+ };
444
+ }
445
+ async function ownerLaneHireFields(ctx, lane) {
446
+ if (lane.host.kind !== "outpost") {
447
+ throw new LanesError("outpost_lane_hire_summary_invalid", "Owner summary requires an Outpost-hosted lane", {
448
+ lane_id: lane.lane_id,
449
+ });
450
+ }
451
+ const outpostId = lane.host.id;
452
+ const outpostCliEnvKey = `HQ_LANES_OUTPOST_CLI_${outpostId.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}`;
453
+ const configuredHqBinary = ctx.env[outpostCliEnvKey];
454
+ const hqBinary = configuredHqBinary === undefined ? undefined : configuredHqBinary.trim();
455
+ if (configuredHqBinary !== undefined &&
456
+ (!hqBinary ||
457
+ !path.posix.isAbsolute(hqBinary) ||
458
+ hqBinary.includes("\0") ||
459
+ hqBinary.includes("\r") ||
460
+ hqBinary.includes("\n"))) {
461
+ throw new LanesError("outpost_lane_cli_invalid", `${outpostCliEnvKey} must be an absolute remote executable path`, { outpost_id: outpostId });
462
+ }
463
+ let dispatched;
464
+ try {
465
+ dispatched = await ctx.dispatchOutpostExec({
466
+ outpostId,
467
+ command: buildOutpostLaneCommand({
468
+ action: "show",
469
+ laneId: lane.lane_id,
470
+ hqBinary,
471
+ }),
472
+ timeoutSeconds: OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS,
473
+ waitMs: OUTPOST_HIRE_SUMMARY_WAIT_MS,
474
+ });
475
+ }
476
+ catch (err) {
477
+ throw new LanesError("outpost_lane_hire_summary_unavailable", `Could not read hire summary for lane ${lane.lane_id} from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
478
+ }
479
+ if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
480
+ throw new LanesError("outpost_lane_hire_summary_unavailable", `Outpost ${outpostId} did not confirm the hire summary for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, command_id: dispatched.commandId });
481
+ }
482
+ const remote = parseRemoteJson(dispatched.stdout, "Outpost lane hire summary");
483
+ if (!remote || remote.ok !== true || remote.action !== "show") {
484
+ throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid hire summary for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, command_id: dispatched.commandId });
485
+ }
486
+ let ownerLane;
487
+ try {
488
+ ownerLane = parseLaneRecord(remote.lane);
489
+ }
490
+ catch (err) {
491
+ throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid lane snapshot for ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, cause: dispatchErrorMessage(err) });
492
+ }
493
+ const grant = remote.hire_grant;
494
+ const alive = remote.hire_alive_children;
495
+ if (ownerLane.lane_id !== lane.lane_id ||
496
+ ownerLane.host.kind !== "outpost" ||
497
+ ownerLane.host.id !== outpostId ||
498
+ !Number.isSafeInteger(grant) ||
499
+ grant < 0 ||
500
+ !Number.isSafeInteger(alive) ||
501
+ alive < 0 ||
502
+ remote.hire !== `${String(alive)}/${String(grant)}` ||
503
+ remote.hire_summary !== `hire ${String(alive)}/${String(grant)}`) {
504
+ throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an inconsistent hire summary for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, command_id: dispatched.commandId });
505
+ }
506
+ return {
507
+ hire_grant: grant,
508
+ hire_alive_children: alive,
509
+ hire: `${alive}/${grant}`,
510
+ hire_summary: `hire ${alive}/${grant}`,
511
+ };
512
+ }
513
+ const OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS = 20;
514
+ const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
515
+ const OUTPOST_HIRE_SUMMARY_CONCURRENCY = 4;
516
+ async function laneHirePresentation(ctx, lane, lanes) {
517
+ const local = laneHireFields(lane, lanes);
518
+ if (!ctx.laneHireGrantsEnabled() ||
519
+ lane.host.kind !== "outpost" ||
520
+ isTerminalLaneState(lane.state) ||
521
+ laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
522
+ return { ...local, hire_source: "local" };
523
+ }
524
+ try {
525
+ return { ...(await ownerLaneHireFields(ctx, lane)), hire_source: "owner" };
526
+ }
527
+ catch (err) {
528
+ const detail = isLanesError(err)
529
+ ? `${err.error}: ${err.message}`
530
+ : `outpost_lane_hire_summary_unavailable: ${dispatchErrorMessage(err)}`;
531
+ return {
532
+ ...local,
533
+ hire_source: "local",
534
+ hire_warning: detail,
535
+ };
536
+ }
537
+ }
538
+ async function mapWithConcurrency(values, concurrency, map) {
539
+ const results = new Array(values.length);
540
+ let nextIndex = 0;
541
+ const workerCount = Math.min(concurrency, values.length);
542
+ const workers = Array.from({ length: workerCount }, async () => {
543
+ while (nextIndex < values.length) {
544
+ const index = nextIndex++;
545
+ results[index] = await map(values[index], index);
546
+ }
547
+ });
548
+ await Promise.all(workers);
549
+ return results;
550
+ }
551
+ function withResumeHireAdmission(ctx, lane, fn) {
552
+ const hqRoot = ctx.hqRoot();
553
+ if (!ctx.laneHireGrantsEnabled() ||
554
+ !isTerminalLaneState(lane.state) ||
555
+ lane.senior.kind !== "lane" ||
556
+ !lane.senior.id) {
557
+ return fn();
558
+ }
559
+ return withHireGrantAdmission(hqRoot, lane.senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
560
+ const current = readLane(hqRoot, lane.lane_id);
561
+ // A different resume already reactivated this lane; it occupies the slot
562
+ // in the shared snapshot and is not a second hire.
563
+ if (!isTerminalLaneState(current.state))
564
+ return fn();
565
+ if (isTerminalLaneState(parent.state)) {
566
+ throw new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot resume child lanes`, { senior_lane_id: parent.lane_id, state: parent.state });
567
+ }
568
+ if (children.length >= (parent.hire_grant ?? 0)) {
569
+ throw hireGrantCapacityError(parent, children);
570
+ }
571
+ const requestedGrant = current.hire_grant ?? 0;
572
+ if (requestedGrant > availableChildGrant) {
573
+ throw new LanesError("hire_grant_carve_exceeded", `Senior lane ${parent.lane_id} has ${availableChildGrant} unassigned hire grant remaining, but terminal child ${current.lane_id} needs ${requestedGrant} to resume. Ask its senior for more: write an ask envelope with the desired total grant and why; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`, {
574
+ senior_lane_id: parent.lane_id,
575
+ parent_grant: parentGrant,
576
+ alive_children: children.map((child) => child.lane_id),
577
+ assigned_child_grants: assignedChildHireGrants(children),
578
+ requested_hire_grant: requestedGrant,
579
+ available_hire_grant: availableChildGrant,
580
+ ask_guidance: {
581
+ envelope: "decision=ask; include the desired total grant and the reason",
582
+ senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
583
+ },
584
+ });
585
+ }
586
+ return fn();
587
+ });
588
+ }
589
+ function hireGrantCapacityError(parent, children) {
590
+ const grant = parent.hire_grant ?? 0;
591
+ return new LanesError("hire_grant_exhausted", `Senior lane ${parent.lane_id} has hire grant ${grant} and ${children.length} alive direct children (${children.map((child) => child.lane_id).join(", ") || "none"}). Ask its senior for more: write an ask envelope with the desired total grant and why; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`, {
592
+ senior_lane_id: parent.lane_id,
593
+ parent_grant: grant,
594
+ alive_children: children.map((child) => child.lane_id),
595
+ alive_child_count: children.length,
596
+ ask_guidance: {
597
+ envelope: "decision=ask; include the desired total grant and the reason",
598
+ senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
599
+ },
600
+ });
601
+ }
419
602
  const CAPACITY_PROBE_TIMEOUT_MS = 15_000;
420
603
  const MAX_PLACEMENT_OUTPOSTS = 16;
421
604
  function outpostIdentityUnknownError(message, expectedId, details = {}) {
@@ -1229,6 +1412,13 @@ export function composeWorkerBrief(worker, taskBrief) {
1229
1412
  ]
1230
1413
  .filter(Boolean)
1231
1414
  .join("\n");
1415
+ const laneHiringGuidance = worker.profile?.id === "hq-lanes-manager"
1416
+ ? [
1417
+ "",
1418
+ "## Lane hiring grants",
1419
+ "A child lane may hire only within its assigned grant. If you need more direct child lanes than your current grant allows, stop before creating them and write a decision=ask envelope. Include the total grant you want and why in the question and context. Use your lane id from HQ_LANE_ID so your senior can run `hq lanes grant <lane-id> --hire <n>`.",
1420
+ ]
1421
+ : [];
1232
1422
  return [
1233
1423
  `# ${name}`,
1234
1424
  description,
@@ -1257,6 +1447,7 @@ export function composeWorkerBrief(worker, taskBrief) {
1257
1447
  "## Verification checkpoints",
1258
1448
  checkpoints || "(none)",
1259
1449
  ...(checkpoints ? ["", checkpointInstruction(worker)] : []),
1450
+ ...laneHiringGuidance,
1260
1451
  "",
1261
1452
  "## Task",
1262
1453
  taskBrief.replace(/\s+$/, ""),
@@ -1602,6 +1793,9 @@ function mintMessageId() {
1602
1793
  }
1603
1794
  async function runCreate(ctx, opts) {
1604
1795
  const hqRoot = ctx.hqRoot();
1796
+ const requestedHireGrant = opts.hireGrant === undefined
1797
+ ? undefined
1798
+ : parseHireGrant(opts.hireGrant);
1605
1799
  const requestedPlacement = opts.placement !== undefined
1606
1800
  ? parsePlacementPolicy(opts.placement)
1607
1801
  : undefined;
@@ -1664,6 +1858,17 @@ async function runCreate(ctx, opts) {
1664
1858
  }
1665
1859
  const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
1666
1860
  assertSeniorExists(hqRoot, senior, ctx);
1861
+ const hireGrantsEnabled = ctx.laneHireGrantsEnabled();
1862
+ if (requestedHireGrant !== undefined &&
1863
+ !callerMatchesSenior(ctx, senior)) {
1864
+ throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(senior)} may set a lane's hire grant`, { senior, hire_grant: requestedHireGrant });
1865
+ }
1866
+ if (hireGrantsEnabled &&
1867
+ ctx.env.HQ_LANE_ID?.trim() &&
1868
+ !callerMatchesSenior(ctx, senior)) {
1869
+ throw new LanesError("hire_grant_unauthorized", `Only senior lane ${senior.id} may create a direct child lane`, { senior_lane_id: senior.id });
1870
+ }
1871
+ const hireGrant = requestedHireGrant ?? 0;
1667
1872
  const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
1668
1873
  if (laneDepth > MAX_LANE_DEPTH) {
1669
1874
  throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
@@ -2065,7 +2270,7 @@ async function runCreate(ctx, opts) {
2065
2270
  : { kind: "local", id: ctx.hostname() });
2066
2271
  const queuedReason = admission.action === "queue" ? admission.reason : undefined;
2067
2272
  const admissionReservedAt = admission.action === "start" ? ctx.now().toISOString() : undefined;
2068
- const lane = createLane(hqRoot, {
2273
+ const laneInit = {
2069
2274
  company,
2070
2275
  ...(company === null && meshCompany
2071
2276
  ? { mesh_company: meshCompany }
@@ -2081,6 +2286,7 @@ async function runCreate(ctx, opts) {
2081
2286
  })),
2082
2287
  depth: laneDepth,
2083
2288
  senior,
2289
+ hire_grant: hireGrant,
2084
2290
  senior_history: [],
2085
2291
  state: "queued",
2086
2292
  queued_reason: queuedReason,
@@ -2124,7 +2330,29 @@ async function runCreate(ctx, opts) {
2124
2330
  ? { origin_host_source: originHostSource }
2125
2331
  : {}),
2126
2332
  attribution_history: [],
2127
- });
2333
+ };
2334
+ const lane = senior.kind === "lane"
2335
+ ? withHireGrantAdmission(hqRoot, senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
2336
+ if (hireGrantsEnabled && isTerminalLaneState(parent.state)) {
2337
+ throw new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot hire lanes`, { senior_lane_id: parent.lane_id, state: parent.state });
2338
+ }
2339
+ if (hireGrantsEnabled && children.length >= parentGrant) {
2340
+ throw hireGrantCapacityError(parent, children);
2341
+ }
2342
+ if (hireGrantsEnabled && hireGrant > availableChildGrant) {
2343
+ throw new LanesError("hire_grant_carve_exceeded", `Lane ${parent.lane_id} has ${availableChildGrant} unassigned hire grant remaining after its other alive children. Ask its senior to raise its grant before assigning ${hireGrant}.`, {
2344
+ senior_lane_id: parent.lane_id,
2345
+ parent_grant: parentGrant,
2346
+ alive_children: children.map((child) => child.lane_id),
2347
+ assigned_child_grants: assignedChildHireGrants(children),
2348
+ requested_hire_grant: hireGrant,
2349
+ available_hire_grant: availableChildGrant,
2350
+ ask_guidance: `Write an ask envelope with the desired total grant and reason; the senior runs hq lanes grant ${parent.lane_id} --hire <n>.`,
2351
+ });
2352
+ }
2353
+ return createLane(hqRoot, laneInit);
2354
+ })
2355
+ : createLane(hqRoot, laneInit);
2128
2356
  return { lane, admission, validation };
2129
2357
  };
2130
2358
  let persisted;
@@ -2478,6 +2706,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
2478
2706
  story: options.story,
2479
2707
  account: options.account,
2480
2708
  model: options.model,
2709
+ hire: options.hire,
2481
2710
  compact: options.compact,
2482
2711
  }),
2483
2712
  });
@@ -2514,8 +2743,10 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
2514
2743
  remote.lane_id === lane.lane_id &&
2515
2744
  (action === "message"
2516
2745
  ? remote.accepted === true
2517
- : remote.delivery_status === "worker_spawned" ||
2518
- remote.delivery_status === "queued_on_target_account");
2746
+ : action === "grant"
2747
+ ? remote.hire_grant === Number(options.hire)
2748
+ : remote.delivery_status === "worker_spawned" ||
2749
+ remote.delivery_status === "queued_on_target_account");
2519
2750
  if (!resultConfirmed) {
2520
2751
  throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
2521
2752
  lane_id: lane.lane_id,
@@ -2523,6 +2754,18 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
2523
2754
  command_id: dispatched.commandId,
2524
2755
  });
2525
2756
  }
2757
+ if (action === "grant") {
2758
+ const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
2759
+ printJson({
2760
+ ...remote,
2761
+ lane_id: ownerLane.lane_id,
2762
+ lane: ownerLane,
2763
+ outpost_id: outpostId,
2764
+ outpost_command_id: dispatched.commandId,
2765
+ outpost_transport: "outposts_exec",
2766
+ });
2767
+ return;
2768
+ }
2526
2769
  printJson({
2527
2770
  ...remote,
2528
2771
  outpost_id: outpostId,
@@ -2763,7 +3006,8 @@ async function runList(ctx, opts) {
2763
3006
  }
2764
3007
  const now = ctx.now();
2765
3008
  const host = reconcileHostFromCtx(ctx);
2766
- const lanes = loadAllLanes(hqRoot)
3009
+ const allLanes = loadAllLanes(hqRoot);
3010
+ const selectedLanes = allLanes
2767
3011
  .filter((lane) => {
2768
3012
  if (state && lane.state !== state)
2769
3013
  return false;
@@ -2779,13 +3023,18 @@ async function runList(ctx, opts) {
2779
3023
  return false;
2780
3024
  return true;
2781
3025
  })
2782
- .sort(compareLaneQueueOrder)
2783
- .map((lane) => {
3026
+ .sort(compareLaneQueueOrder);
3027
+ const hireByLane = new Map(await mapWithConcurrency(selectedLanes, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (lane) => [
3028
+ lane.lane_id,
3029
+ await laneHirePresentation(ctx, lane, allLanes),
3030
+ ]));
3031
+ const lanes = selectedLanes.map((lane) => {
2784
3032
  const remoteQueued = lane.state === "queued" &&
2785
3033
  lane.host.kind === "outpost" &&
2786
3034
  !laneRunsHere(lane, host);
2787
3035
  return {
2788
3036
  ...lane,
3037
+ ...hireByLane.get(lane.lane_id),
2789
3038
  ...(remoteQueued
2790
3039
  ? { state: "remote", remote_state: lane.state, queued_reason: undefined }
2791
3040
  : {}),
@@ -3220,6 +3469,8 @@ async function runMoveTarget(ctx, laneId, opts) {
3220
3469
  async function runShow(ctx, laneId) {
3221
3470
  const hqRoot = ctx.hqRoot();
3222
3471
  const storedLane = readLane(hqRoot, laneId);
3472
+ const allLanes = loadAllLanes(hqRoot);
3473
+ const hire = await laneHirePresentation(ctx, storedLane, allLanes);
3223
3474
  const remoteQueued = storedLane.state === "queued" &&
3224
3475
  storedLane.host.kind === "outpost" &&
3225
3476
  !laneRunsHere(storedLane, reconcileHostFromCtx(ctx));
@@ -3231,6 +3482,7 @@ async function runShow(ctx, laneId) {
3231
3482
  ok: true,
3232
3483
  action: "show",
3233
3484
  lane,
3485
+ ...hire,
3234
3486
  overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, ctx.now()),
3235
3487
  stalled: Boolean(lane.stalled),
3236
3488
  stalled_at: lane.stalled_at ?? null,
@@ -3256,17 +3508,17 @@ async function runShow(ctx, laneId) {
3256
3508
  : null,
3257
3509
  });
3258
3510
  }
3259
- function forceAdmissionAuthorized(ctx, lane) {
3511
+ function callerMatchesSenior(ctx, seniorRef) {
3260
3512
  const sessionId = sessionIdFromEnv(ctx.env);
3261
3513
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim() || undefined;
3262
- if (lane.senior.kind === "lane") {
3263
- return callerLaneId !== undefined && callerLaneId === lane.senior.id;
3514
+ if (seniorRef.kind === "lane") {
3515
+ return callerLaneId !== undefined && callerLaneId === seniorRef.id;
3264
3516
  }
3265
3517
  if (!sessionId || callerLaneId !== undefined)
3266
3518
  return false;
3267
- if (lane.senior.kind === "session")
3268
- return sessionId === lane.senior.id;
3269
- if (lane.senior.kind !== "user")
3519
+ if (seniorRef.kind === "session")
3520
+ return sessionId === seniorRef.id;
3521
+ if (seniorRef.kind !== "user")
3270
3522
  return false;
3271
3523
  const loaded = loadSessionMeta(ctx.hqRoot(), sessionId, ctx);
3272
3524
  if (loaded.status !== "ok")
@@ -3276,6 +3528,173 @@ function forceAdmissionAuthorized(ctx, lane) {
3276
3528
  (senior !== null && typeof senior === "object" && !Array.isArray(senior) &&
3277
3529
  senior.kind === "user");
3278
3530
  }
3531
+ function forceAdmissionAuthorized(ctx, lane) {
3532
+ return callerMatchesSenior(ctx, lane.senior);
3533
+ }
3534
+ async function runGrant(ctx, laneId, opts) {
3535
+ if (opts.hire === undefined) {
3536
+ throw new LanesError("missing_hire_grant", "--hire is required", {});
3537
+ }
3538
+ const hireGrant = parseHireGrant(opts.hire);
3539
+ const hqRoot = ctx.hqRoot();
3540
+ const initial = readLane(hqRoot, laneId);
3541
+ if (!callerMatchesSenior(ctx, initial.senior)) {
3542
+ throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(initial.senior)} may change lane ${laneId}'s hire grant`, { lane_id: laneId, senior: initial.senior });
3543
+ }
3544
+ if (initial.host.kind === "outpost" && !laneRunsHere(initial, reconcileHostFromCtx(ctx))) {
3545
+ await dispatchOutpostLaneCommand(ctx, initial, "grant", { hire: String(hireGrant) });
3546
+ return;
3547
+ }
3548
+ const lockedLaneIds = [
3549
+ laneId,
3550
+ ...(initial.senior.kind === "lane" && initial.senior.id
3551
+ ? [initial.senior.id]
3552
+ : []),
3553
+ ];
3554
+ const updated = withLaneHireLocks(hqRoot, lockedLaneIds, () => {
3555
+ const current = readLane(hqRoot, laneId);
3556
+ if (!callerMatchesSenior(ctx, current.senior)) {
3557
+ throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(current.senior)} may change lane ${laneId}'s hire grant`, { lane_id: laneId, senior: current.senior });
3558
+ }
3559
+ const hierarchy = loadAllLanes(hqRoot);
3560
+ const enforce = ctx.laneHireGrantsEnabled();
3561
+ if (enforce && current.senior.kind === "lane") {
3562
+ if (!current.senior.id) {
3563
+ throw new LanesError("hire_grant_senior_invalid", `Lane ${laneId} has a lane senior without an id`, {
3564
+ lane_id: laneId,
3565
+ });
3566
+ }
3567
+ const parent = readLane(hqRoot, current.senior.id);
3568
+ if (isTerminalLaneState(parent.state)) {
3569
+ throw new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot change grants`, { senior_lane_id: parent.lane_id, state: parent.state });
3570
+ }
3571
+ const siblings = aliveDirectChildren(hierarchy, parent.lane_id);
3572
+ const parentGrant = parent.hire_grant ?? 0;
3573
+ const assigned = assignedChildHireGrants(siblings, current.lane_id);
3574
+ const available = Math.max(0, parentGrant - assigned);
3575
+ if (hireGrant > available) {
3576
+ throw new LanesError("hire_grant_carve_exceeded", `Senior lane ${parent.lane_id} has ${available} unassigned hire grant remaining for lane ${laneId}`, {
3577
+ senior_lane_id: parent.lane_id,
3578
+ parent_grant: parentGrant,
3579
+ alive_children: siblings.map((child) => child.lane_id),
3580
+ assigned_other_child_grants: assigned,
3581
+ requested_hire_grant: hireGrant,
3582
+ available_hire_grant: available,
3583
+ });
3584
+ }
3585
+ }
3586
+ if (enforce) {
3587
+ const children = aliveDirectChildren(hierarchy, current.lane_id);
3588
+ const allocated = assignedChildHireGrants(children);
3589
+ const minimumGrant = Math.max(allocated, children.length);
3590
+ if (hireGrant < minimumGrant) {
3591
+ throw new LanesError(allocated > children.length ? "hire_grant_below_allocated" : "hire_grant_below_occupancy", `Lane ${laneId} cannot lower its grant to ${hireGrant}; it has ${children.length} alive children and ${allocated} grant allocated to them`, {
3592
+ lane_id: laneId,
3593
+ requested_hire_grant: hireGrant,
3594
+ alive_children: children.map((child) => child.lane_id),
3595
+ alive_child_count: children.length,
3596
+ assigned_child_grants: allocated,
3597
+ });
3598
+ }
3599
+ }
3600
+ return retryUpdateLane(hqRoot, laneId, "command", (record) => ({
3601
+ ...record,
3602
+ hire_grant: hireGrant,
3603
+ }));
3604
+ });
3605
+ const fields = laneHireFields(updated, loadAllLanes(hqRoot));
3606
+ printJson({ ok: true, action: "grant", lane_id: updated.lane_id, lane: updated, ...fields });
3607
+ }
3608
+ async function runBackfillGrants(ctx, opts) {
3609
+ if (opts.apply && opts.dryRun) {
3610
+ throw new LanesError("hire_grant_backfill_mode_conflict", "Pass either --dry-run or --apply, not both", {});
3611
+ }
3612
+ const apply = Boolean(opts.apply);
3613
+ const hqRoot = ctx.hqRoot();
3614
+ if (apply && ctx.laneHireGrantsEnabled()) {
3615
+ throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
3616
+ }
3617
+ if (apply && ctx.env.HQ_LANE_ID?.trim()) {
3618
+ throw new LanesError("hire_grant_backfill_unauthorized", "A lane cannot apply the hire-grant backfill; run it from an operator session", {});
3619
+ }
3620
+ const allLanes = loadAllLanes(hqRoot);
3621
+ const profileIds = new Set(allLanes
3622
+ .filter((lane) => !isTerminalLaneState(lane.state) &&
3623
+ (aliveDirectChildren(allLanes, lane.lane_id).length > 0 ||
3624
+ lane.worker_profile === "hq-lanes-manager"))
3625
+ .map((lane) => lane.worker_profile)
3626
+ .filter((id) => Boolean(id)));
3627
+ const profileMaxConcurrent = new Map();
3628
+ for (const profileId of profileIds) {
3629
+ let profile;
3630
+ try {
3631
+ profile = loadWorkerProfile(hqRoot, profileId);
3632
+ }
3633
+ catch (err) {
3634
+ throw new LanesError("hire_grant_backfill_profile_unavailable", `Cannot calculate a hire-grant backfill for worker profile ${profileId}: ${err instanceof Error ? err.message : String(err)}`, { worker_profile: profileId });
3635
+ }
3636
+ const maxConcurrent = profile.meta.max_concurrent;
3637
+ if (maxConcurrent !== undefined && !Number.isSafeInteger(maxConcurrent)) {
3638
+ throw new LanesError("hire_grant_backfill_profile_invalid", `Worker profile ${profileId} has max_concurrent outside the safe integer range`, { worker_profile: profileId, max_concurrent: maxConcurrent });
3639
+ }
3640
+ if (maxConcurrent !== undefined)
3641
+ profileMaxConcurrent.set(profileId, maxConcurrent);
3642
+ }
3643
+ let rows = planHireGrantBackfill(allLanes, profileMaxConcurrent);
3644
+ if (apply) {
3645
+ const shortfalls = rows.filter((row) => row.grant_shortfall > 0);
3646
+ if (shortfalls.length > 0) {
3647
+ throw new LanesError("hire_grant_backfill_allocation_shortfall", "Existing parent grants do not cover the grants allocated to alive children; resolve these rows before applying the backfill", { lanes: shortfalls });
3648
+ }
3649
+ rows = rows.map((row) => {
3650
+ if (row.action === "unchanged")
3651
+ return row;
3652
+ const lane = readLane(hqRoot, row.lane_id);
3653
+ const lockIds = [
3654
+ lane.lane_id,
3655
+ ...(lane.senior.kind === "lane" && lane.senior.id ? [lane.senior.id] : []),
3656
+ ];
3657
+ return withLaneHireLocks(hqRoot, lockIds, () => {
3658
+ const current = readLane(hqRoot, row.lane_id);
3659
+ if (current.hire_grant !== undefined) {
3660
+ return {
3661
+ ...row,
3662
+ existing_grant: current.hire_grant,
3663
+ proposed_grant: current.hire_grant,
3664
+ action: "unchanged",
3665
+ };
3666
+ }
3667
+ const latestLanes = loadAllLanes(hqRoot);
3668
+ const latestRow = planHireGrantBackfill(latestLanes, profileMaxConcurrent)
3669
+ .find((candidate) => candidate.lane_id === current.lane_id);
3670
+ if (!latestRow) {
3671
+ throw new LanesError("hire_grant_backfill_target_changed", `Lane ${current.lane_id} no longer qualifies for the hire-grant backfill`, { lane_id: current.lane_id });
3672
+ }
3673
+ if (latestRow.grant_shortfall > 0) {
3674
+ throw new LanesError("hire_grant_backfill_allocation_shortfall", `Lane ${current.lane_id}'s existing grant does not cover grants allocated to its alive children`, { lane: latestRow });
3675
+ }
3676
+ const proposed = latestRow.proposed_grant;
3677
+ const written = retryUpdateLane(hqRoot, current.lane_id, "command", (record) => ({
3678
+ ...record,
3679
+ ...(record.hire_grant === undefined ? { hire_grant: proposed } : {}),
3680
+ }));
3681
+ const value = written.hire_grant ?? 0;
3682
+ return {
3683
+ ...row,
3684
+ existing_grant: value,
3685
+ proposed_grant: value,
3686
+ action: value === proposed ? "set" : "unchanged",
3687
+ };
3688
+ });
3689
+ });
3690
+ }
3691
+ printJson({
3692
+ ok: true,
3693
+ action: "backfill-grants",
3694
+ mode: apply ? "apply" : "dry-run",
3695
+ lanes: rows,
3696
+ });
3697
+ }
3279
3698
  async function runAdmit(ctx, laneId, opts) {
3280
3699
  const hqRoot = ctx.hqRoot();
3281
3700
  let lane = readLane(hqRoot, laneId);
@@ -3590,7 +4009,7 @@ async function prepareAccountMove(ctx, lane, input) {
3590
4009
  if (admission.action === "refuse" && admission.error)
3591
4010
  throw admission.error;
3592
4011
  const queued = admission.action === "queue";
3593
- const moved = casUpdateLane(hqRoot, laneId, (current) => {
4012
+ const moved = withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
3594
4013
  // The copy and every check above were made from `lane`; a record
3595
4014
  // that moved on since (another move, a spawn, a cancel) invalidates
3596
4015
  // them, so the write is refused rather than applied to a state it
@@ -3624,7 +4043,7 @@ async function prepareAccountMove(ctx, lane, input) {
3624
4043
  next.queued_at = undefined;
3625
4044
  }
3626
4045
  return next;
3627
- });
4046
+ }));
3628
4047
  return { lane: moved, from, to: target, copy, queued };
3629
4048
  }, ctx.sleep);
3630
4049
  }
@@ -3640,6 +4059,9 @@ async function runMessage(ctx, laneId, opts) {
3640
4059
  return;
3641
4060
  }
3642
4061
  assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
4062
+ // Check before transcript recovery, account probing, or message writes.
4063
+ // The final state transition repeats this check under the senior lock.
4064
+ withResumeHireAdmission(ctx, lane, () => undefined);
3643
4065
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
3644
4066
  // An account move is refused on its own terms first: a running lane and a
3645
4067
  // provider without session resume get the move's named errors, not the
@@ -3725,62 +4147,64 @@ async function runMessage(ctx, laneId, opts) {
3725
4147
  const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
3726
4148
  if (admission.action === "refuse" && admission.error)
3727
4149
  throw admission.error;
3728
- if (admission.action === "queue") {
3729
- const sessionId = lane.provider_session_id;
3730
- if (!sessionId) {
3731
- throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this message resume`, {
3732
- lane_id: laneId,
3733
- provider: lane.provider,
3734
- });
3735
- }
3736
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
3737
- queuedOnAdmission = true;
3738
- return casUpdateLane(hqRoot, laneId, (current) => {
3739
- if (current.version !== lane.version || current.state !== lane.state) {
3740
- throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
4150
+ return withResumeHireAdmission(ctx, lane, () => {
4151
+ if (admission.action === "queue") {
4152
+ const sessionId = lane.provider_session_id;
4153
+ if (!sessionId) {
4154
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this message resume`, {
3741
4155
  lane_id: laneId,
3742
- prepared_version: lane.version,
3743
- version: current.version,
3744
- state: current.state,
4156
+ provider: lane.provider,
3745
4157
  });
3746
4158
  }
3747
- return {
3748
- ...current,
3749
- state: "queued",
3750
- queued_reason: admission.reason ?? "throttle",
3751
- queued_at: at,
3752
- pending_resume: {
3753
- session_id: sessionId,
3754
- trigger: "message",
3755
- text,
3756
- message_id: messageId,
3757
- compact: opts.compact !== false,
3758
- at,
3759
- },
3760
- };
3761
- });
3762
- }
3763
- const started = spawnLaneWorker(hqRoot, lane, {
3764
- env: ctx.env,
3765
- modelDeps: {
3766
- readFileSync: ctx.readFileSync,
3767
- probe: ctx.probeProvider,
4159
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
4160
+ queuedOnAdmission = true;
4161
+ return casUpdateLane(hqRoot, laneId, (current) => {
4162
+ if (current.version !== lane.version || current.state !== lane.state) {
4163
+ throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
4164
+ lane_id: laneId,
4165
+ prepared_version: lane.version,
4166
+ version: current.version,
4167
+ state: current.state,
4168
+ });
4169
+ }
4170
+ return {
4171
+ ...current,
4172
+ state: "queued",
4173
+ queued_reason: admission.reason ?? "throttle",
4174
+ queued_at: at,
4175
+ pending_resume: {
4176
+ session_id: sessionId,
4177
+ trigger: "message",
4178
+ text,
4179
+ message_id: messageId,
4180
+ compact: opts.compact !== false,
4181
+ at,
4182
+ },
4183
+ };
4184
+ });
4185
+ }
4186
+ const started = spawnLaneWorker(hqRoot, lane, {
3768
4187
  env: ctx.env,
3769
- },
3770
- existsSync: ctx.existsSync,
3771
- spawnProcess: ctx.spawnProcess,
3772
- proveDetached: ctx.proveDetached,
3773
- killProcess: ctx.killProcess,
3774
- now: ctx.now,
3775
- resolveMachineIdentity: ctx.resolveMachineIdentity,
3776
- runCompaction: ctx.runCompaction,
3777
- locateTranscript: ctx.locateTranscript,
3778
- }, {
3779
- trigger: { kind: "message", text, message_id: messageId },
3780
- compact: opts.compact !== false,
4188
+ modelDeps: {
4189
+ readFileSync: ctx.readFileSync,
4190
+ probe: ctx.probeProvider,
4191
+ env: ctx.env,
4192
+ },
4193
+ existsSync: ctx.existsSync,
4194
+ spawnProcess: ctx.spawnProcess,
4195
+ proveDetached: ctx.proveDetached,
4196
+ killProcess: ctx.killProcess,
4197
+ now: ctx.now,
4198
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
4199
+ runCompaction: ctx.runCompaction,
4200
+ locateTranscript: ctx.locateTranscript,
4201
+ }, {
4202
+ trigger: { kind: "message", text, message_id: messageId },
4203
+ compact: opts.compact !== false,
4204
+ });
4205
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
4206
+ return started;
3781
4207
  });
3782
- archiveResumeMessage(hqRoot, laneId, dropboxMessage);
3783
- return started;
3784
4208
  }, ctx.sleep);
3785
4209
  }
3786
4210
  // Every accepted message moves the compaction reference forward.
@@ -3915,6 +4339,9 @@ async function runResume(ctx, laneId, opts) {
3915
4339
  await dispatchOutpostLaneCommand(ctx, lane, "resume", opts);
3916
4340
  return;
3917
4341
  }
4342
+ // Fail before provider/session work if a terminal child would consume a
4343
+ // slot that its senior has already assigned elsewhere.
4344
+ withResumeHireAdmission(ctx, lane, () => undefined);
3918
4345
  assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
3919
4346
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
3920
4347
  if (opts.model !== undefined && opts.account === undefined) {
@@ -4001,64 +4428,66 @@ async function runResume(ctx, laneId, opts) {
4001
4428
  const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
4002
4429
  if (admission.action === "refuse" && admission.error)
4003
4430
  throw admission.error;
4004
- if (admission.action === "queue") {
4005
- const sessionId = lane.provider_session_id;
4006
- if (!sessionId) {
4007
- throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
4008
- lane_id: laneId,
4009
- provider: lane.provider,
4010
- });
4011
- }
4012
- queuedOnAdmission = true;
4013
- return casUpdateLane(hqRoot, laneId, (current) => {
4014
- if (current.version !== lane.version || current.state !== lane.state) {
4015
- throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
4431
+ return withResumeHireAdmission(ctx, lane, () => {
4432
+ if (admission.action === "queue") {
4433
+ const sessionId = lane.provider_session_id;
4434
+ if (!sessionId) {
4435
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
4016
4436
  lane_id: laneId,
4017
- prepared_version: lane.version,
4018
- version: current.version,
4019
- state: current.state,
4437
+ provider: lane.provider,
4020
4438
  });
4021
4439
  }
4022
- return {
4023
- ...current,
4024
- state: "queued",
4025
- queued_reason: admission.reason ?? "throttle",
4026
- queued_at: at,
4027
- pending_resume: {
4028
- session_id: sessionId,
4029
- trigger: "resume",
4030
- resumed_from: resumedFrom,
4031
- ...(text !== undefined ? { text } : {}),
4032
- ...(messageId !== undefined ? { message_id: messageId } : {}),
4033
- compact: opts.compact !== false,
4034
- at,
4035
- },
4036
- };
4037
- });
4038
- }
4039
- return spawnLaneWorker(hqRoot, lane, {
4040
- env: ctx.env,
4041
- modelDeps: {
4042
- readFileSync: ctx.readFileSync,
4043
- probe: ctx.probeProvider,
4440
+ queuedOnAdmission = true;
4441
+ return casUpdateLane(hqRoot, laneId, (current) => {
4442
+ if (current.version !== lane.version || current.state !== lane.state) {
4443
+ throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
4444
+ lane_id: laneId,
4445
+ prepared_version: lane.version,
4446
+ version: current.version,
4447
+ state: current.state,
4448
+ });
4449
+ }
4450
+ return {
4451
+ ...current,
4452
+ state: "queued",
4453
+ queued_reason: admission.reason ?? "throttle",
4454
+ queued_at: at,
4455
+ pending_resume: {
4456
+ session_id: sessionId,
4457
+ trigger: "resume",
4458
+ resumed_from: resumedFrom,
4459
+ ...(text !== undefined ? { text } : {}),
4460
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
4461
+ compact: opts.compact !== false,
4462
+ at,
4463
+ },
4464
+ };
4465
+ });
4466
+ }
4467
+ return spawnLaneWorker(hqRoot, lane, {
4044
4468
  env: ctx.env,
4045
- },
4046
- existsSync: ctx.existsSync,
4047
- spawnProcess: ctx.spawnProcess,
4048
- proveDetached: ctx.proveDetached,
4049
- killProcess: ctx.killProcess,
4050
- now: ctx.now,
4051
- resolveMachineIdentity: ctx.resolveMachineIdentity,
4052
- runCompaction: ctx.runCompaction,
4053
- locateTranscript: ctx.locateTranscript,
4054
- }, {
4055
- trigger: {
4056
- kind: "resume",
4057
- from: resumedFrom,
4058
- ...(text !== undefined ? { text } : {}),
4059
- ...(messageId !== undefined ? { message_id: messageId } : {}),
4060
- },
4061
- compact: opts.compact !== false,
4469
+ modelDeps: {
4470
+ readFileSync: ctx.readFileSync,
4471
+ probe: ctx.probeProvider,
4472
+ env: ctx.env,
4473
+ },
4474
+ existsSync: ctx.existsSync,
4475
+ spawnProcess: ctx.spawnProcess,
4476
+ proveDetached: ctx.proveDetached,
4477
+ killProcess: ctx.killProcess,
4478
+ now: ctx.now,
4479
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
4480
+ runCompaction: ctx.runCompaction,
4481
+ locateTranscript: ctx.locateTranscript,
4482
+ }, {
4483
+ trigger: {
4484
+ kind: "resume",
4485
+ from: resumedFrom,
4486
+ ...(text !== undefined ? { text } : {}),
4487
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
4488
+ },
4489
+ compact: opts.compact !== false,
4490
+ });
4062
4491
  });
4063
4492
  }, ctx.sleep);
4064
4493
  }
@@ -4477,6 +4906,21 @@ function archiveResumeMessage(hqRoot, laneId, message) {
4477
4906
  fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
4478
4907
  }
4479
4908
  async function runReparent(ctx, laneId, opts) {
4909
+ const hqRoot = ctx.hqRoot();
4910
+ if (!opts.senior) {
4911
+ throw new LanesError("missing_senior", "--senior is required", {});
4912
+ }
4913
+ const nextSenior = parseSeniorRef(opts.senior);
4914
+ assertSeniorExists(hqRoot, nextSenior, ctx);
4915
+ const initial = readLane(hqRoot, laneId);
4916
+ const lockIds = [
4917
+ laneId,
4918
+ ...(initial.senior.kind === "lane" && initial.senior.id ? [initial.senior.id] : []),
4919
+ ...(nextSenior.kind === "lane" && nextSenior.id ? [nextSenior.id] : []),
4920
+ ];
4921
+ withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts));
4922
+ }
4923
+ function runReparentLocked(ctx, laneId, opts) {
4480
4924
  const hqRoot = ctx.hqRoot();
4481
4925
  if (!opts.senior) {
4482
4926
  throw new LanesError("missing_senior", "--senior is required", {});
@@ -4490,6 +4934,29 @@ async function runReparent(ctx, laneId, opts) {
4490
4934
  }
4491
4935
  const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
4492
4936
  assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
4937
+ if (ctx.laneHireGrantsEnabled() &&
4938
+ nextSenior.kind === "lane" &&
4939
+ seniorKey(lane.senior) !== seniorKey(nextSenior)) {
4940
+ const parent = readLane(hqRoot, nextSenior.id);
4941
+ if (isTerminalLaneState(parent.state)) {
4942
+ throw new LanesError("hire_grant_senior_inactive", `Senior lane ${parent.lane_id} is ${parent.state} and cannot accept lane children`, { senior_lane_id: parent.lane_id, state: parent.state });
4943
+ }
4944
+ const children = aliveDirectChildren(loadAllLanes(hqRoot), parent.lane_id);
4945
+ if (children.length >= (parent.hire_grant ?? 0)) {
4946
+ throw hireGrantCapacityError(parent, children);
4947
+ }
4948
+ const available = Math.max(0, (parent.hire_grant ?? 0) - assignedChildHireGrants(children));
4949
+ const requested = lane.hire_grant ?? 0;
4950
+ if (requested > available) {
4951
+ throw new LanesError("hire_grant_carve_exceeded", `Senior lane ${parent.lane_id} has ${available} unassigned hire grant remaining for lane ${laneId}`, {
4952
+ senior_lane_id: parent.lane_id,
4953
+ parent_grant: parent.hire_grant ?? 0,
4954
+ assigned_child_grants: assignedChildHireGrants(children),
4955
+ requested_hire_grant: requested,
4956
+ available_hire_grant: available,
4957
+ });
4958
+ }
4959
+ }
4493
4960
  if (walkHitsLane(hqRoot, nextSenior, laneId, ctx)) {
4494
4961
  throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
4495
4962
  }
@@ -5515,6 +5982,7 @@ export function registerLanesCommand(parent, deps = {}) {
5515
5982
  .option("--model <id>", "Model id for the provider CLI")
5516
5983
  .option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
5517
5984
  .option("--senior <ref>", "session:<id> | lane:<id> | user")
5985
+ .option("--hire-grant <n>", "Maximum alive direct child lanes assigned to this lane; set by its senior")
5518
5986
  .option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
5519
5987
  .option("--priority <n>", "Queue priority; higher runs first", "1")
5520
5988
  .option("--outpost <id>", "Run on this Outpost instead of the local host")
@@ -5523,6 +5991,22 @@ export function registerLanesCommand(parent, deps = {}) {
5523
5991
  .action(wrap(async (opts) => {
5524
5992
  await runCreate(ctx, opts);
5525
5993
  }));
5994
+ lanes
5995
+ .command("grant")
5996
+ .description("Set a lane's alive child-lane grant as its senior")
5997
+ .argument("<lane-id>", "Lane id")
5998
+ .requiredOption("--hire <n>", "Non-negative safe integer hire grant")
5999
+ .action(wrap(async (laneId, opts) => {
6000
+ await runGrant(ctx, laneId, opts);
6001
+ }));
6002
+ lanes
6003
+ .command("backfill-grants")
6004
+ .description("Preview or apply missing hire grants for alive manager lanes")
6005
+ .option("--dry-run", "Print proposed values without writing (default)")
6006
+ .option("--apply", "Write missing hire grants; only run before enforcement is enabled")
6007
+ .action(wrap(async (opts) => {
6008
+ await runBackfillGrants(ctx, opts);
6009
+ }));
5526
6010
  lanes
5527
6011
  .command("list")
5528
6012
  .description("List lanes")