Turns the single-serial-worker scheduler into a capacity-aware, multi-lane one. Configured team members become lanes; an issue runs on its assignee's lane (or the earliest-free lane), its duration scaled by that lane's throughput (focusFactor × allocation). Every forecast — Focus cone, Runway, milestone drill-in — is now capacity-aware. core (@commitea/core): - capacity/capacity-v0: CapacityMember + capacityPerWorkday + parseCapacityConfig (clamps, drops invalid; degrades to []). - scheduler/scheduler-capacity-v0: scheduleWithCapacity reuses the v0 topo order + critical path, re-lays work across lanes (layoutOnLanes, resolveLanes, makespan). Empty workers → the single serial plan verbatim. - forecast() gains options.workers: each MC trial lays sampled durations across the lanes and takes the makespan; serial path unchanged. SchedulableIssue gains assignee; ScheduledItem gains worker. - 11 new tests (parse/clamp, parallelism halves makespan, speed scaling, assignee routing, cross-lane deps, forecast makespan shrinks with lanes). app: - pm-state capacity/members.json read (readCapacity + pmstate:capacity bridge); useCapacity hook → workers; forecastBacklog/runwayView/milestoneView pass workers. - Runway Capacity card shows the real config (person · focus · alloc · pd/day). Config lives in pm-state (D4); seeded christian(0.8)/stephen(0.6×0.5). Degrades to the fixture/serial when absent. Verified: 128 core tests green, desktop typecheck clean, 14 fixture e2e green. Live: the capacity card is real, and the P2 forecast shifts 32d→37d — honest, since real focus factors (<1) replace the v0 focus-1.0 assumption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
/**
|
|
* 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<number, number[]>
|
|
assignee: Map<number, string | null>
|
|
}
|
|
|
|
/** 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<string, number>, 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<number, number>; startAt: Map<number, number>; laneOf: Map<number, string> } {
|
|
const freeAt = new Map(lanes.map((w) => [w.person, 0]))
|
|
const finishAt = new Map<number, number>()
|
|
const startAt = new Map<number, number>()
|
|
const laneOf = new Map<number, string>()
|
|
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)
|
|
}
|