/** * Capacity-aware scheduler (#8). Reuses the single-worker scheduler's topological * order + critical-path marking, then re-lays the work across lanes: an issue * runs on its assignee's lane (or the earliest-free lane when unassigned), its * duration scaled by that lane's speed (ideal person-days/workday). Makespan * shrinks toward the critical path as lanes are added. Deterministic; falls back * to the single serial worker when no capacity is configured. The lane layout is * factored so the Monte Carlo forecast reuses it per trial with sampled durations. */ import { type DependencyEdge, schedule, type SchedulableIssue, type SchedulePlan, } from './scheduler-v0.js' /** A scheduling lane: a person and their throughput (ideal person-days / workday). */ export interface Worker { person: string speed: number } /** The topological order + per-issue relations the layout needs (from the base plan). */ export interface LaneInputs { order: number[] blockedBy: Map assignee: Map } /** Ensure a lane exists for every assignee; unconfigured assignees get the mean speed. */ export function resolveLanes(workers: Worker[], assignees: (string | null)[]): Worker[] { const lanes = [...workers] const known = new Set(lanes.map((w) => w.person)) const meanSpeed = lanes.length ? lanes.reduce((s, w) => s + w.speed, 0) / lanes.length : 1 for (const a of assignees) { if (a && !known.has(a)) { lanes.push({ person: a, speed: meanSpeed }) known.add(a) } } return lanes } function pickWorker(lanes: Worker[], freeAt: Map, assignee: string | null | undefined): Worker { if (assignee) { const own = lanes.find((w) => w.person === assignee) if (own) return own } let best = lanes[0] for (const w of lanes) if (freeAt.get(w.person)! < freeAt.get(best.person)!) best = w return best } /** * Lay a topologically-ordered set out across lanes. `duration(n)` supplies each * issue's duration for this layout (estimate, or a sampled value in a MC trial). * Returns each issue's finish day + the lane it ran on. Order guarantees a * dependency is always laid out before its dependents. */ export function layoutOnLanes( inputs: LaneInputs, lanes: Worker[], duration: (n: number) => number, ): { finishAt: Map; startAt: Map; laneOf: Map } { const freeAt = new Map(lanes.map((w) => [w.person, 0])) const finishAt = new Map() const startAt = new Map() const laneOf = new Map() for (const n of inputs.order) { const worker = pickWorker(lanes, freeAt, inputs.assignee.get(n)) const blockers = inputs.blockedBy.get(n) ?? [] const depFinish = blockers.length ? Math.max(...blockers.map((d) => finishAt.get(d) ?? 0)) : 0 const start = Math.max(freeAt.get(worker.person)!, depFinish) const end = start + duration(n) / worker.speed startAt.set(n, start) finishAt.set(n, end) laneOf.set(n, worker.person) freeAt.set(worker.person, end) } return { finishAt, startAt, laneOf } } /** * Capacity-aware plan. An empty `workers` means no capacity is configured → the * single-worker plan verbatim. */ export function scheduleWithCapacity( issues: SchedulableIssue[], edges: DependencyEdge[], workers: Worker[], ): SchedulePlan { if (workers.length === 0) return schedule(issues, edges) const base = schedule(issues, edges) if (base.cycle || base.items.length === 0) return base const inputs: LaneInputs = { order: base.items.map((it) => it.number), blockedBy: new Map(base.items.map((it) => [it.number, it.blockedBy])), assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])), } const lanes = resolveLanes(workers, [...inputs.assignee.values()]) const durationOf = new Map(base.items.map((it) => [it.number, it.durationDays])) const { finishAt, startAt, laneOf } = layoutOnLanes(inputs, lanes, (n) => durationOf.get(n)!) const items = base.items.map((it) => ({ ...it, startDay: startAt.get(it.number)!, endDay: finishAt.get(it.number)!, durationDays: finishAt.get(it.number)! - startAt.get(it.number)!, worker: laneOf.get(it.number), })) return { items, cycle: null } } /** Makespan (last finish) of a plan — the project's landing day. */ export function makespan(plan: SchedulePlan): number { return plan.items.reduce((m, it) => Math.max(m, it.endDay), 0) }