From 1636d6bada0c5a906dd4705ba1d15d4d125fe2cf Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 00:59:03 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20capacity-aware=20scheduling=20(#8)=20?= =?UTF-8?q?=E2=80=94=20real=20focus=20factors=20drive=20every=20forecast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/live-backlog.spec.ts | 3 + apps/desktop/src/main/gitea.ts | 25 ++++ apps/desktop/src/preload/index.ts | 2 + .../src/components/screens/runway-screen.tsx | 16 ++- .../src/components/shell/app-shell.tsx | 13 +- apps/desktop/src/renderer/src/global.d.ts | 7 ++ apps/desktop/src/renderer/src/lib/backlog.ts | 19 ++- .../src/renderer/src/lib/use-capacity.ts | 24 ++++ .../core/src/capacity/capacity-v0.test.ts | 88 +++++++++++++ packages/core/src/capacity/capacity-v0.ts | 49 ++++++++ .../core/src/forecast/forecast-v0.test.ts | 16 +++ packages/core/src/forecast/forecast-v0.ts | 38 ++++-- packages/core/src/index.ts | 6 + .../src/scheduler/scheduler-capacity-v0.ts | 119 ++++++++++++++++++ packages/core/src/scheduler/scheduler-v0.ts | 4 + 15 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/use-capacity.ts create mode 100644 packages/core/src/capacity/capacity-v0.test.ts create mode 100644 packages/core/src/capacity/capacity-v0.ts create mode 100644 packages/core/src/scheduler/scheduler-capacity-v0.ts diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index 1da6fa4..4362ea2 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -44,6 +44,9 @@ test.describe('live backlog', () => { // Real per-milestone forecasts — these milestone names come from gitea, not the // fixture (which lists Beta / Pilot-ready / v1.0). await expect(win.getByText(/P2 — Scheduler/)).toBeVisible() + // Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.) + await expect(win.getByText('christian', { exact: true })).toBeVisible() + await expect(win.getByText(/pd\/day/).first()).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' }) // Milestone drill-in — clicking a real milestone opens its real detail diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index ccead88..032e98e 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -20,6 +20,7 @@ import { type IssueChange, type LifecycleEvent, makeDirectiveEntry, + parseCapacityConfig, parseDirectiveLog, planIssueChange, type ProjectSnapshot, @@ -109,6 +110,19 @@ export async function readDirectives(client: GiteaClient) { return parseDirectiveLog(text) } +const CAPACITY_PATH = 'capacity/members.json' + +/** Read the capacity config from the pm-state repo (empty when absent). */ +export async function readCapacity(client: GiteaClient) { + const file = await client.getFile(CAPACITY_PATH) + if (!file) return [] + try { + return parseCapacityConfig(JSON.parse(Buffer.from(file.contentBase64, 'base64').toString('utf8'))) + } catch { + return [] + } +} + /** Full reconcile: issues + milestones + native deps + lifecycle timelines. */ export async function reconcileSnapshot( client: GiteaClient, @@ -266,4 +280,15 @@ export function registerGiteaIpc(): void { return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) } } }) + + // Read the capacity config from the pm-state repo (for capacity-aware forecasts). + ipcMain.handle('pmstate:capacity', async () => { + const pm = getPmStateClient() + if (!pm) return { ok: false as const, reason: 'unconfigured' as const, members: [] } + try { + return { ok: true as const, members: await readCapacity(pm) } + } catch { + return { ok: true as const, members: [] } + } + }) } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 4705f6c..0215f37 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -19,6 +19,8 @@ const api = { pmstate: { /** Read the directive ledger from the pm-state repo. */ directives: () => ipcRenderer.invoke('pmstate:directives'), + /** Read the capacity config from the pm-state repo. */ + capacity: () => ipcRenderer.invoke('pmstate:capacity'), }, model: { /** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */ diff --git a/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx index cb3ac21..e929110 100644 --- a/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx @@ -1,23 +1,35 @@ import React from 'react' +import { type CapacityMember, capacityPerWorkday } from '@commitea/core' + import { RunwayBar } from '../charts/chart.js' import { Card, Badge, Tag, Icon, IconButton } from '../ui/index.js' import { RUNWAY, CAPACITY, type RunwayMilestone } from '../../data/fixtures.js' // Runway — capacity vs milestone dates; ranges, never points. -// `milestones` (real per-milestone forecasts) overrides the demo when present. +// `milestones` (real per-milestone forecasts) + `capacity` (real config) override the demo. export function RunwayScreen({ onOpenCalibration, onOpenMilestone, calibration, milestones, + capacity, }: { onOpenCalibration: () => void onOpenMilestone: (id?: number) => void calibration?: { n: number; coldStart: boolean } milestones?: RunwayMilestone[] + capacity?: CapacityMember[] }) { const rows = milestones && milestones.length ? milestones : RUNWAY + const capacityRows = + capacity && capacity.length + ? capacity.map((m) => ({ + who: m.person, + slices: `focus ${m.focusFactor} · alloc ${Math.round(m.allocation * 100)}%`, + hours: `${capacityPerWorkday(m).toFixed(2)} pd/day`, + })) + : CAPACITY const calibNote = calibration ? calibration.coldStart ? `cold-start priors · ${calibration.n}/20 closed issues estimated` @@ -59,7 +71,7 @@ export function RunwayScreen({
- {CAPACITY.map((p, i) => ( + {capacityRows.map((p, i) => (
([]) const [milestoneId, setMilestoneId] = useState(null) const [backlog, refetchBacklog] = useBacklog() + const capacityMembers = useCapacity() + const workers = capacityWorkers(capacityMembers) const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined const focus = @@ -104,10 +108,12 @@ export function AppShell() { backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined const forecast = backlog.status === 'ready' - ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined) + ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model, workers) ?? undefined) : undefined const runwayMilestones = - backlog.status === 'ready' ? runwayView(backlog.issues, backlog.milestones, backlog.deps) : undefined + backlog.status === 'ready' + ? runwayView(backlog.issues, backlog.milestones, backlog.deps, new Date(), workers) + : undefined const milestone = backlog.status === 'ready' && milestoneId != null ? (milestoneView( @@ -117,6 +123,8 @@ export function AppShell() { backlog.deps, backlog.timelines, calibration?.model, + new Date(), + workers, ) ?? undefined) : undefined @@ -221,6 +229,7 @@ export function AppShell() { }} calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined} milestones={runwayMilestones} + capacity={capacityMembers} /> ) case 'calibration': diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 93434ba..0ff0861 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,5 +1,6 @@ import type { AgentStep, + CapacityMember, ChangeProposal, ChatMessage, CaptureProposal, @@ -77,9 +78,15 @@ export type DirectivesResult = | { ok: false; reason: 'unconfigured' | 'error'; message?: string } | { ok: true; directives: DirectiveRecord[] } +/** The result of reading the capacity config. */ +export type CapacityResult = + | { ok: false; reason: 'unconfigured'; members: CapacityMember[] } + | { ok: true; members: CapacityMember[] } + /** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */ export interface PmStateBridge { directives(): Promise + capacity(): Promise } declare global { diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts index 6420fd5..e55bbea 100644 --- a/apps/desktop/src/renderer/src/lib/backlog.ts +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -2,10 +2,13 @@ import { type CalibrationModel, type CalibrationSample, calibrationSamples, + type CapacityMember, + capacityPerWorkday, COLD_START_THRESHOLD, type DependencyEdge, fitCalibration, forecast, + type Worker, type GiteaIssue, type GiteaMilestone, inferLifecycle, @@ -107,9 +110,15 @@ function toSchedulable(issues: GiteaIssue[]) { labels: i.labels, estimateDays: i.facts.estimateDays, priority: i.facts.priority, + assignee: i.assignee, })) } +/** gitea CapacityMembers → scheduler lanes (person + throughput). */ +export function capacityWorkers(members: CapacityMember[]): Worker[] { + return members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) })) +} + export interface ForecastView { scope: number cone: BurnUpData @@ -132,9 +141,10 @@ export function forecastBacklog( deps: DependencyEdge[], today: Date = new Date(), calibration?: CalibrationModel, + workers: Worker[] = [], ): ForecastView | null { const model = calibration ? toDurationModel(calibration) : undefined - const f = forecast(toSchedulable(issues), deps, model ? { model } : {}) + const f = forecast(toSchedulable(issues), deps, { ...(model ? { model } : {}), workers }) const cone = buildBurnUpData(f, today) if (!cone) return null return { @@ -252,6 +262,7 @@ export function runwayView( milestones: GiteaMilestone[], deps: DependencyEdge[], today: Date = new Date(), + workers: Worker[] = [], ): RunwayMilestone[] { const open = issues.filter((i) => i.state === 'open') const rows = milestones @@ -266,8 +277,10 @@ export function runwayView( labels: i.labels, estimateDays: i.facts.estimateDays, priority: i.facts.priority, + assignee: i.assignee, })), deps, + { workers }, ) const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day const dueDay = m.dueOn ? workingDaysBetween(today, new Date(m.dueOn)) : null @@ -324,6 +337,7 @@ export function milestoneView( timelines: Timelines = {}, calibration?: CalibrationModel, today: Date = new Date(), + workers: Worker[] = [], ): MilestoneView | null { const m = milestones.find((x) => x.id === id) if (!m) return null @@ -340,9 +354,10 @@ export function milestoneView( labels: i.labels, estimateDays: i.facts.estimateDays, priority: i.facts.priority, + assignee: i.assignee, })), deps, - model ? { model } : {}, + { ...(model ? { model } : {}), workers }, ) const cone = buildBurnUpData(f, today) diff --git a/apps/desktop/src/renderer/src/lib/use-capacity.ts b/apps/desktop/src/renderer/src/lib/use-capacity.ts new file mode 100644 index 0000000..40b6bc5 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/use-capacity.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +import type { CapacityMember } from '@commitea/core' + +/** + * The team's capacity config from the pm-state repo. Empty when unconfigured — + * forecasts then fall back to a single serial worker. Read once on mount. + */ +export function useCapacity(): CapacityMember[] { + const [members, setMembers] = useState([]) + useEffect(() => { + let alive = true + window.commitea.pmstate + .capacity() + .then((r) => { + if (alive) setMembers(r.members) + }) + .catch(() => {}) + return () => { + alive = false + } + }, []) + return members +} diff --git a/packages/core/src/capacity/capacity-v0.test.ts b/packages/core/src/capacity/capacity-v0.test.ts new file mode 100644 index 0000000..f82863e --- /dev/null +++ b/packages/core/src/capacity/capacity-v0.test.ts @@ -0,0 +1,88 @@ +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 { + 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 + }) +}) diff --git a/packages/core/src/capacity/capacity-v0.ts b/packages/core/src/capacity/capacity-v0.ts new file mode 100644 index 0000000..9492069 --- /dev/null +++ b/packages/core/src/capacity/capacity-v0.ts @@ -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 + 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 +} diff --git a/packages/core/src/forecast/forecast-v0.test.ts b/packages/core/src/forecast/forecast-v0.test.ts index 17c9d14..d56d6b6 100644 --- a/packages/core/src/forecast/forecast-v0.test.ts +++ b/packages/core/src/forecast/forecast-v0.test.ts @@ -120,4 +120,20 @@ describe('forecast', () => { expect(fitted.coldStart).toBe(false) expect(fitted.p50Day).toBeLessThan(priors.p50Day) }) + + it('capacity lanes parallelize the sim — the makespan shrinks with more workers', () => { + const independent = [issue(1), issue(2), issue(3), issue(4)] // no deps → fully parallelizable + const serial = forecast(independent, [], { trials: 2000, seed: 7 }) + const twoLanes = forecast(independent, [], { + trials: 2000, + seed: 7, + workers: [ + { person: 'a', speed: 1 }, + { person: 'b', speed: 1 }, + ], + }) + // two equal lanes ≈ half the serial landing (independent work) + expect(twoLanes.p80Day).toBeLessThan(serial.p80Day) + expect(twoLanes.p80Day).toBeLessThan(serial.p80Day * 0.7) + }) }) diff --git a/packages/core/src/forecast/forecast-v0.ts b/packages/core/src/forecast/forecast-v0.ts index 04134fb..f633d03 100644 --- a/packages/core/src/forecast/forecast-v0.ts +++ b/packages/core/src/forecast/forecast-v0.ts @@ -18,6 +18,7 @@ import { schedule, type SchedulableIssue, } from '../scheduler/scheduler-v0.js' +import { type LaneInputs, layoutOnLanes, resolveLanes, type Worker } from '../scheduler/scheduler-capacity-v0.js' export interface LognormalPrior { /** Median log-ratio: sampled median duration = estimate * e^mu. */ @@ -76,6 +77,12 @@ export interface ForecastOptions { * the sim; otherwise the code-resident cold-start priors do. */ model?: DurationModel + /** + * Capacity lanes. When provided, each trial lays sampled durations across the + * lanes (parallel) instead of a single serial worker — the makespan shrinks + * toward the critical path. Empty/absent → single serial worker. + */ + workers?: Worker[] } /** Resolve the lognormal params for an estimate, preferring a fitted model. */ @@ -158,15 +165,32 @@ export function forecast( const priors = order.map((it) => durationParams(it.durationDays, options.model)) const rng = mulberry32(seed) - // endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t. + // Capacity lanes, when configured — the per-trial layout goes parallel. + const laneInputs: LaneInputs = { + order: order.map((it) => it.number), + blockedBy: new Map(order.map((it) => [it.number, it.blockedBy])), + assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])), + } + const lanes = + options.workers && options.workers.length ? resolveLanes(options.workers, [...laneInputs.assignee.values()]) : null + + // endByRank[k][t] = working day the (k+1)-th issue *to finish* completes on trial t. const endByRank: number[][] = Array.from({ length: n }, () => new Array(trials)) for (let t = 0; t < trials; t++) { - let cursor = 0 - for (let k = 0; k < n; k++) { - const p = priors[k] - const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng)) - cursor += sampled - endByRank[k][t] = cursor + const sampled = order.map((it, k) => it.durationDays * Math.exp(priors[k].mu + priors[k].sigma * standardNormal(rng))) + if (lanes) { + // parallel: lay the sampled durations across lanes, then sort finish days + const byNumber = new Map(order.map((it, k) => [it.number, sampled[k]])) + const { finishAt } = layoutOnLanes(laneInputs, lanes, (num) => byNumber.get(num)!) + const finishes = order.map((it) => finishAt.get(it.number)!).sort((a, b) => a - b) + for (let k = 0; k < n; k++) endByRank[k][t] = finishes[k] + } else { + // single serial worker: cumulative sum (already sorted ascending) + let cursor = 0 + for (let k = 0; k < n; k++) { + cursor += sampled[k] + endByRank[k][t] = cursor + } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a08ec87..f54a11d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,12 @@ export type { SchedulePlan, } from './scheduler/scheduler-v0.js' +export { makespan, scheduleWithCapacity } from './scheduler/scheduler-capacity-v0.js' +export type { Worker } from './scheduler/scheduler-capacity-v0.js' + +export { capacityPerWorkday, parseCapacityConfig } from './capacity/capacity-v0.js' +export type { CapacityMember } from './capacity/capacity-v0.js' + export { COLD_START_PRIORS, durationParams, diff --git a/packages/core/src/scheduler/scheduler-capacity-v0.ts b/packages/core/src/scheduler/scheduler-capacity-v0.ts new file mode 100644 index 0000000..a112507 --- /dev/null +++ b/packages/core/src/scheduler/scheduler-capacity-v0.ts @@ -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 + 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) +} diff --git a/packages/core/src/scheduler/scheduler-v0.ts b/packages/core/src/scheduler/scheduler-v0.ts index 26192e4..01f8576 100644 --- a/packages/core/src/scheduler/scheduler-v0.ts +++ b/packages/core/src/scheduler/scheduler-v0.ts @@ -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 {