| import { resolveGlobalSingleton } from "../shared/global-singleton.js"; |
| |
| |
| |
| |
| import { |
| getQueueState, |
| normalizeLane, |
| peekLaneQueue, |
| type LaneGroupState, |
| } from "./command-queue.state.js"; |
| import type { CommandLaneBlockReason, CommandLaneSnapshot } from "./command-queue.types.js"; |
| import { CommandLane } from "./lanes.js"; |
|
|
| |
| type BoundedDrainLaneFn = (lane: string, maxStarts?: number) => number | void; |
|
|
| |
| export type CommandLaneGroupSpec = { |
| |
| budget: number; |
| members: readonly string[]; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| reservations?: Readonly<Record<string, number>>; |
| }; |
|
|
| |
| const DRAINING_GROUPS = resolveGlobalSingleton( |
| Symbol.for("openclaw.commandQueueDrainingGroups"), |
| () => new WeakSet<LaneGroupState>(), |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const GROUP_INELIGIBLE_LANES: ReadonlySet<string> = new Set<string>([ |
| CommandLane.Cron, |
| CommandLane.Main, |
| CommandLane.SystemAgent, |
| CommandLane.Subagent, |
| CommandLane.Nested, |
| ]); |
|
|
| const GROUP_INELIGIBLE_PREFIXES = ["session:", "nested:", "context-engine-turn-maintenance:"]; |
|
|
| function assertGroupEligibleLane(lane: string): void { |
| if (GROUP_INELIGIBLE_LANES.has(lane)) { |
| throw new Error( |
| `command lane "${lane}" cannot join a capacity group: it can be synchronously awaited by another lane`, |
| ); |
| } |
| for (const prefix of GROUP_INELIGIBLE_PREFIXES) { |
| if (lane.startsWith(prefix)) { |
| throw new Error( |
| `command lane "${lane}" cannot join a capacity group: "${prefix}*" lanes can be synchronously awaited`, |
| ); |
| } |
| } |
| } |
|
|
| export function getLaneGroup(lane: string): LaneGroupState | undefined { |
| const { laneGroups: groups, laneGroupByLane: groupByLane } = getQueueState(); |
| const groupId = groupByLane.get(lane); |
| return groupId ? groups.get(groupId) : undefined; |
| } |
|
|
| |
| |
| |
| |
| function getMemberActiveCount(lane: string): number { |
| return getQueueState().lanes.get(lane)?.activeTaskIds.size ?? 0; |
| } |
|
|
| type GroupCapacity = { active: number; reserved: number }; |
|
|
| |
| |
| function readGroupCapacity(group: LaneGroupState): GroupCapacity { |
| let active = 0; |
| let reserved = 0; |
| for (const member of group.members) { |
| const memberActive = getMemberActiveCount(member); |
| active += memberActive; |
| reserved += Math.max(0, (group.reservations.get(member) ?? 0) - memberActive); |
| } |
| return { active, reserved }; |
| } |
|
|
| function resolveGroupBlockReason( |
| group: LaneGroupState, |
| lane: string, |
| capacity: GroupCapacity, |
| ): CommandLaneBlockReason { |
| if (capacity.active >= group.budget) { |
| return "group-budget"; |
| } |
| if (getMemberActiveCount(lane) < (group.reservations.get(lane) ?? 0)) { |
| return null; |
| } |
| |
| |
| return capacity.active + capacity.reserved < group.budget ? null : "sibling-reservation"; |
| } |
|
|
| |
| export function applyCommandLaneCapacity(snapshot: CommandLaneSnapshot): void { |
| const group = getLaneGroup(snapshot.lane); |
| const capacity = group ? readGroupCapacity(group) : undefined; |
| snapshot.blockedBy = |
| snapshot.activeCount >= snapshot.maxConcurrent |
| ? "lane" |
| : group && capacity |
| ? resolveGroupBlockReason(group, snapshot.lane, capacity) |
| : null; |
| if (group && capacity) { |
| snapshot.group = group.group; |
| snapshot.groupActive = capacity.active; |
| snapshot.groupBudget = group.budget; |
| snapshot.reservedForLane = group.reservations.get(snapshot.lane) ?? 0; |
| } |
| } |
|
|
| export function canAdmitInGroup(lane: string): boolean { |
| const group = getLaneGroup(lane); |
| return !group || resolveGroupBlockReason(group, lane, readGroupCapacity(group)) === null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function validateCommandLaneGroupSpec( |
| group: string, |
| spec: CommandLaneGroupSpec, |
| ): LaneGroupState { |
| const members = spec.members.map((member) => normalizeLane(member)); |
| for (const member of members) { |
| assertGroupEligibleLane(member); |
| } |
| const reservations = new Map<string, number>(); |
| let reservedTotal = 0; |
| for (const [rawLane, count] of Object.entries(spec.reservations ?? {})) { |
| const member = normalizeLane(rawLane); |
| if (!members.includes(member)) { |
| throw new Error(`command lane group "${group}" reserves for non-member lane "${member}"`); |
| } |
| const reserved = Math.max(0, Math.floor(count)); |
| reservations.set(member, reserved); |
| reservedTotal += reserved; |
| } |
| const budget = Math.max(0, Math.floor(spec.budget)); |
| if (reservedTotal > budget) { |
| |
| |
| throw new Error( |
| `command lane group "${group}" reserves ${reservedTotal} slots but its budget is ${budget}`, |
| ); |
| } |
| return { group, budget, members: new Set(members), reservations }; |
| } |
|
|
| |
| export function installCommandLaneGroup(next: LaneGroupState): void { |
| const { laneGroups: groups, laneGroupByLane: groupByLane } = getQueueState(); |
| const previous = groups.get(next.group); |
| if (previous) { |
| for (const member of previous.members) { |
| groupByLane.delete(member); |
| } |
| } |
| for (const member of next.members) { |
| |
| |
| |
| const owner = groupByLane.get(member); |
| if (owner && owner !== next.group) { |
| groups.get(owner)?.members.delete(member); |
| } |
| } |
| groups.set(next.group, next); |
| for (const member of next.members) { |
| groupByLane.set(member, next.group); |
| } |
| } |
|
|
| |
| |
| |
| function resolveNextGroupLane(group: LaneGroupState): string | undefined { |
| let selected: |
| | { |
| lane: string; |
| priority: number; |
| sequence: number; |
| } |
| | undefined; |
| let capacity: GroupCapacity | undefined; |
| for (const lane of group.members) { |
| const state = getQueueState().lanes.get(lane); |
| const head = state ? peekLaneQueue(state.queue) : undefined; |
| if (!state || !head || state.draining || state.activeTaskIds.size >= state.maxConcurrent) { |
| continue; |
| } |
| |
| |
| capacity ??= readGroupCapacity(group); |
| if (resolveGroupBlockReason(group, lane, capacity) !== null) { |
| continue; |
| } |
| if ( |
| !selected || |
| head.priority > selected.priority || |
| (head.priority === selected.priority && |
| (head.sequence < selected.sequence || |
| (head.sequence === selected.sequence && lane < selected.lane))) |
| ) { |
| selected = { lane, priority: head.priority, sequence: head.sequence }; |
| } |
| } |
| return selected?.lane; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function drainCommandLaneGroup(lane: string, drainLane: BoundedDrainLaneFn): void { |
| const group = getLaneGroup(lane); |
| if (!group || DRAINING_GROUPS.has(group)) { |
| return; |
| } |
| DRAINING_GROUPS.add(group); |
| try { |
| while (getQueueState().laneGroups.get(group.group) === group) { |
| const selectedLane = resolveNextGroupLane(group); |
| if (!selectedLane || drainLane(selectedLane, 1) === 0) { |
| return; |
| } |
| } |
| } finally { |
| DRAINING_GROUPS.delete(group); |
| } |
| } |
|
|