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>
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
/**
|
|
* Deterministic scheduler, v0. Orders the open backlog by dependency topology,
|
|
* then priority, then estimate; lays it out on a single serial capacity and
|
|
* marks the critical path. The LLM never does this — it's plain code.
|
|
*
|
|
* v0 simplifications (each is a later slice, not a hack):
|
|
* - single serial worker; per-person capacity is #8.
|
|
* - point durations from estimate labels; Monte Carlo cones are #10.
|
|
* - a null estimate defaults to DEFAULT_ESTIMATE_DAYS.
|
|
* Dependencies on out-of-scope issues (closed/done, or not passed in) are
|
|
* treated as satisfied and dropped — you schedule what's left to do.
|
|
*/
|
|
|
|
export const DEFAULT_ESTIMATE_DAYS = 2
|
|
|
|
export interface SchedulableIssue {
|
|
number: number
|
|
title: string
|
|
labels: string[]
|
|
/** From label facts; null when unestimated. */
|
|
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 {
|
|
issue: number
|
|
dependsOn: number
|
|
}
|
|
|
|
export interface ScheduledItem {
|
|
number: number
|
|
title: string
|
|
labels: string[]
|
|
/** 0-based position in the plan. */
|
|
order: number
|
|
/** Working-day offsets from now. */
|
|
startDay: number
|
|
endDay: number
|
|
durationDays: number
|
|
/** In-scope dependencies (all still open, so all still blocking). */
|
|
blockedBy: number[]
|
|
/** Issues that depend on this one. */
|
|
blocks: number[]
|
|
/** 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 {
|
|
items: ScheduledItem[]
|
|
/** A dependency cycle (issue numbers) if one was found — the plan is then empty. */
|
|
cycle: number[] | null
|
|
}
|
|
|
|
function durationOf(issue: SchedulableIssue): number {
|
|
return issue.estimateDays ?? DEFAULT_ESTIMATE_DAYS
|
|
}
|
|
|
|
/** Best-first comparison among ready nodes: priority (1 first), then larger estimate, then number. */
|
|
function readyRank(a: SchedulableIssue, b: SchedulableIssue): number {
|
|
const pa = a.priority ?? 99
|
|
const pb = b.priority ?? 99
|
|
if (pa !== pb) return pa - pb
|
|
const da = durationOf(a)
|
|
const db = durationOf(b)
|
|
if (da !== db) return db - da
|
|
return a.number - b.number
|
|
}
|
|
|
|
function rationaleFor(item: {
|
|
critical: boolean
|
|
blockedBy: number[]
|
|
blocks: number[]
|
|
priority: number | null
|
|
}): string {
|
|
const blocksNote = item.blocks.length ? `unblocks ${item.blocks.map((n) => `#${n}`).join(', ')}` : ''
|
|
if (item.critical) return ['on the critical path', blocksNote].filter(Boolean).join(' · ')
|
|
if (item.blockedBy.length) return `waits on ${item.blockedBy.map((n) => `#${n}`).join(', ')}`
|
|
if (blocksNote) return blocksNote
|
|
if (item.priority != null) return `p/${item.priority} · ready`
|
|
return 'ready'
|
|
}
|
|
|
|
export function schedule(issues: SchedulableIssue[], edges: DependencyEdge[]): SchedulePlan {
|
|
const byNumber = new Map(issues.map((i) => [i.number, i]))
|
|
|
|
// keep only edges whose endpoints are both in scope
|
|
const scoped = edges.filter((e) => byNumber.has(e.issue) && byNumber.has(e.dependsOn))
|
|
const deps = new Map<number, number[]>() // issue → its dependencies
|
|
const dependents = new Map<number, number[]>() // issue → who depends on it
|
|
for (const i of issues) {
|
|
deps.set(i.number, [])
|
|
dependents.set(i.number, [])
|
|
}
|
|
for (const e of scoped) {
|
|
deps.get(e.issue)!.push(e.dependsOn)
|
|
dependents.get(e.dependsOn)!.push(e.issue)
|
|
}
|
|
|
|
// Kahn topo with priority tie-break
|
|
const indegree = new Map(issues.map((i) => [i.number, deps.get(i.number)!.length]))
|
|
const order: number[] = []
|
|
const ready = issues.filter((i) => indegree.get(i.number) === 0)
|
|
while (ready.length) {
|
|
ready.sort(readyRank)
|
|
const next = ready.shift()!
|
|
order.push(next.number)
|
|
for (const dep of dependents.get(next.number)!) {
|
|
const d = indegree.get(dep)! - 1
|
|
indegree.set(dep, d)
|
|
if (d === 0) ready.push(byNumber.get(dep)!)
|
|
}
|
|
}
|
|
|
|
if (order.length < issues.length) {
|
|
// cycle: report the nodes that never became ready
|
|
const stuck = issues.filter((i) => indegree.get(i.number)! > 0).map((i) => i.number)
|
|
return { items: [], cycle: stuck }
|
|
}
|
|
|
|
// longest-path (by duration) for critical-path marking
|
|
const longestThrough = new Map<number, number>() // node → longest chain duration passing through it
|
|
const before = new Map<number, number>() // longest duration of chain ending at node's start
|
|
for (const n of order) {
|
|
const depMax = Math.max(0, ...deps.get(n)!.map((d) => before.get(d)! + durationOf(byNumber.get(d)!)))
|
|
before.set(n, depMax)
|
|
}
|
|
const after = new Map<number, number>()
|
|
for (const n of [...order].reverse()) {
|
|
const depMax = Math.max(0, ...dependents.get(n)!.map((d) => after.get(d)! + durationOf(byNumber.get(d)!)))
|
|
after.set(n, depMax)
|
|
}
|
|
let globalMax = 0
|
|
for (const n of order) {
|
|
const total = before.get(n)! + durationOf(byNumber.get(n)!) + after.get(n)!
|
|
longestThrough.set(n, total)
|
|
globalMax = Math.max(globalMax, total)
|
|
}
|
|
|
|
// serial layout
|
|
let cursor = 0
|
|
const items: ScheduledItem[] = order.map((n, idx) => {
|
|
const issue = byNumber.get(n)!
|
|
const dur = durationOf(issue)
|
|
const start = cursor
|
|
cursor += dur
|
|
const blockedBy = deps.get(n)!
|
|
const blocks = dependents.get(n)!
|
|
const critical = globalMax > 0 && longestThrough.get(n) === globalMax
|
|
return {
|
|
number: n,
|
|
title: issue.title,
|
|
labels: issue.labels,
|
|
order: idx,
|
|
startDay: start,
|
|
endDay: cursor,
|
|
durationDays: dur,
|
|
blockedBy,
|
|
blocks,
|
|
critical,
|
|
rationale: rationaleFor({ critical, blockedBy, blocks, priority: issue.priority }),
|
|
}
|
|
})
|
|
|
|
return { items, cycle: null }
|
|
}
|
|
|
|
export interface Focus {
|
|
now: ScheduledItem | null
|
|
next: ScheduledItem | null
|
|
later: ScheduledItem | null
|
|
}
|
|
|
|
/** Now/Next/Later = the first three of the plan (already dependency- + priority-ordered). */
|
|
export function selectFocus(plan: SchedulePlan): Focus {
|
|
return {
|
|
now: plan.items[0] ?? null,
|
|
next: plan.items[1] ?? null,
|
|
later: plan.items[2] ?? null,
|
|
}
|
|
}
|