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:
@@ -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: [] }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user