feat: capacity-aware scheduling (#8) — real focus factors drive every forecast
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>
This commit is contained in:
119
packages/core/src/scheduler/scheduler-capacity-v0.ts
Normal file
119
packages/core/src/scheduler/scheduler-capacity-v0.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface SchedulableIssue {
|
||||
estimateDays: number | null
|
||||
/** 1 (most urgent) … 4; null when unset. */
|
||||
priority: number | null
|
||||
/** gitea assignee login, for capacity-aware lane routing; optional. */
|
||||
assignee?: string | null
|
||||
}
|
||||
|
||||
export interface DependencyEdge {
|
||||
@@ -45,6 +47,8 @@ export interface ScheduledItem {
|
||||
/** On a longest-duration dependency chain. */
|
||||
critical: boolean
|
||||
rationale: string
|
||||
/** Lane it's scheduled on, when capacity-aware; absent for the single-worker plan. */
|
||||
worker?: string
|
||||
}
|
||||
|
||||
export interface SchedulePlan {
|
||||
|
||||
Reference in New Issue
Block a user