/** * 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() // issue → its dependencies const dependents = new Map() // 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() // node → longest chain duration passing through it const before = new Map() // 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() 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, } }