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>
89 lines
3.7 KiB
TypeScript
89 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
|
||
import { makespan, scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
|
||
import { capacityPerWorkday, parseCapacityConfig } from './capacity-v0.js'
|
||
|
||
describe('capacity model', () => {
|
||
it('capacityPerWorkday = focusFactor × allocation', () => {
|
||
expect(capacityPerWorkday({ person: 'a', focusFactor: 0.8, allocation: 0.5 })).toBeCloseTo(0.4)
|
||
})
|
||
|
||
it('parses + clamps config, drops invalid members', () => {
|
||
const members = parseCapacityConfig({
|
||
members: [
|
||
{ person: 'christian', focusFactor: 0.8, allocation: 1 },
|
||
{ person: 'ak', focusFactor: 1.5, allocation: -1 }, // clamps to 1 / 0 → zero capacity → dropped
|
||
{ focusFactor: 0.8 }, // no person → dropped
|
||
],
|
||
})
|
||
expect(members).toEqual([{ person: 'christian', focusFactor: 0.8, allocation: 1 }])
|
||
})
|
||
|
||
it('returns [] for a non-array/absent members field', () => {
|
||
expect(parseCapacityConfig({})).toEqual([])
|
||
expect(parseCapacityConfig({ members: 'nope' })).toEqual([])
|
||
})
|
||
})
|
||
|
||
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
||
return { number, title: `#${number}`, labels: [], estimateDays: 4, priority: 2, ...over }
|
||
}
|
||
|
||
describe('scheduleWithCapacity', () => {
|
||
it('with no workers, falls back to the single serial plan', () => {
|
||
const issues = [issue(1), issue(2)]
|
||
const plan = scheduleWithCapacity(issues, [], [])
|
||
expect(makespan(plan)).toBe(8) // 4 + 4 serial
|
||
})
|
||
|
||
it('parallelizes independent work across lanes (makespan shrinks)', () => {
|
||
const issues = [issue(1), issue(2), issue(3), issue(4)] // 4×4d = 16d serial
|
||
const one: Worker[] = [{ person: 'a', speed: 1 }]
|
||
const two: Worker[] = [
|
||
{ person: 'a', speed: 1 },
|
||
{ person: 'b', speed: 1 },
|
||
]
|
||
expect(makespan(scheduleWithCapacity(issues, [], one))).toBe(16)
|
||
expect(makespan(scheduleWithCapacity(issues, [], two))).toBe(8) // two lanes → half
|
||
})
|
||
|
||
it('scales duration by a lane speed (slower lane takes longer)', () => {
|
||
const plan = scheduleWithCapacity([issue(1, { estimateDays: 4 })], [], [{ person: 'a', speed: 0.5 }])
|
||
expect(plan.items[0].durationDays).toBe(8) // 4 / 0.5
|
||
expect(plan.items[0].worker).toBe('a')
|
||
})
|
||
|
||
it('routes an issue to its assignee lane', () => {
|
||
const issues = [issue(1, { assignee: 'ak' }), issue(2, { assignee: 'sm' })]
|
||
const workers: Worker[] = [
|
||
{ person: 'ak', speed: 1 },
|
||
{ person: 'sm', speed: 1 },
|
||
]
|
||
const plan = scheduleWithCapacity(issues, [], workers)
|
||
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||
expect(byN[1].worker).toBe('ak')
|
||
expect(byN[2].worker).toBe('sm')
|
||
// both start at 0 (different lanes) → parallel
|
||
expect(byN[1].startDay).toBe(0)
|
||
expect(byN[2].startDay).toBe(0)
|
||
})
|
||
|
||
it('adds a lane for an assignee not in the config (mean speed)', () => {
|
||
const plan = scheduleWithCapacity([issue(1, { assignee: 'newbie' })], [], [{ person: 'a', speed: 0.5 }])
|
||
expect(plan.items[0].worker).toBe('newbie')
|
||
expect(plan.items[0].durationDays).toBe(8) // mean speed 0.5 → 4/0.5
|
||
})
|
||
|
||
it('respects dependencies across lanes (a blocker finishes before its dependent starts)', () => {
|
||
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
|
||
const workers: Worker[] = [
|
||
{ person: 'a', speed: 1 },
|
||
{ person: 'b', speed: 1 },
|
||
]
|
||
const plan = scheduleWithCapacity([issue(1), issue(2)], edges, workers)
|
||
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||
expect(byN[2].startDay).toBeGreaterThanOrEqual(byN[1].endDay) // #2 waits for #1 even on another lane
|
||
})
|
||
})
|