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:
49
packages/core/src/capacity/capacity-v0.ts
Normal file
49
packages/core/src/capacity/capacity-v0.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Capacity model (#8). A member's throughput is `focusFactor × allocation` =
|
||||
* ideal person-days of project work delivered per calendar working day. The
|
||||
* scheduler treats each member as a lane whose task durations are scaled by that
|
||||
* rate (a 0.5-capacity person takes twice as long on an est/2d task). Config
|
||||
* lives in the pm-state repo (`capacity/members.yaml/json`, D4); estimates are
|
||||
* in ideal person-days (pm-state.md).
|
||||
*/
|
||||
|
||||
export interface CapacityMember {
|
||||
/** gitea login. */
|
||||
person: string
|
||||
/** Productive fraction of a working day (0..1). */
|
||||
focusFactor: number
|
||||
/** Fraction of that allocated to this project (0..1). */
|
||||
allocation: number
|
||||
}
|
||||
|
||||
/** Ideal person-days delivered per calendar working day. */
|
||||
export function capacityPerWorkday(m: CapacityMember): number {
|
||||
return m.focusFactor * m.allocation
|
||||
}
|
||||
|
||||
function clamp01(n: unknown, fallback: number): number {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw capacity config (`{ members: [...] }`) into members. Unknown
|
||||
* shapes degrade to `[]` (→ the scheduler falls back to a single worker), never
|
||||
* throw. focusFactor/allocation clamp to [0,1]; a member without a person is dropped.
|
||||
*/
|
||||
export function parseCapacityConfig(raw: unknown): CapacityMember[] {
|
||||
const list = (raw as { members?: unknown })?.members
|
||||
if (!Array.isArray(list)) return []
|
||||
const out: CapacityMember[] = []
|
||||
for (const m of list) {
|
||||
const r = (m ?? {}) as Record<string, unknown>
|
||||
const person = typeof r.person === 'string' ? r.person.trim() : ''
|
||||
if (!person) continue
|
||||
const member: CapacityMember = {
|
||||
person,
|
||||
focusFactor: clamp01(r.focusFactor, 0.8),
|
||||
allocation: clamp01(r.allocation, 1),
|
||||
}
|
||||
if (capacityPerWorkday(member) > 0) out.push(member)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user