feat: deterministic scheduler → real Now/Next/Later (P2 thin slice)
CommiTea now recommends its own next unit of work from the live backlog. - @commitea/core: `schedule()` — dependency topo-sort with priority + estimate tie-breaks, single serial capacity, cycle detection, and critical-path marking; `selectFocus()` takes the top three. Pure, deterministic; the LLM does none of this. +11 tests (39 in core). Client gains `getIssueDependencies`. - main: reconcile also fetches native issue dependencies for the open scope and returns edges. - renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later; Focus renders scheduler output (fixture fallback when unconfigured). v0 scope (each a later slice): single serial worker (per-person capacity #8), point durations (Monte Carlo cone #10), estimate-only (calibration #5). Verified: 14 e2e green (fixtures) + gated live spec — the board shows the real 25 open + 9 closed, and Focus picks #2 ChangeSource (critical path) as Now. Screenshots confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,8 @@ export interface GiteaClient {
|
||||
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
|
||||
/** Fetch every milestone (all pages). */
|
||||
listMilestones(): Promise<GiteaMilestone[]>
|
||||
/** The issue indices this issue depends on (its blockers). */
|
||||
getIssueDependencies(index: number): Promise<number[]>
|
||||
}
|
||||
|
||||
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
|
||||
@@ -156,5 +158,10 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
|
||||
)
|
||||
return raw.map(normalizeMilestone)
|
||||
},
|
||||
|
||||
async getIssueDependencies(index) {
|
||||
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
|
||||
return raw.map((d) => d.number)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,12 @@ export type {
|
||||
|
||||
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
|
||||
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'
|
||||
|
||||
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
|
||||
export type {
|
||||
DependencyEdge,
|
||||
Focus,
|
||||
SchedulableIssue,
|
||||
ScheduledItem,
|
||||
SchedulePlan,
|
||||
} from './scheduler/scheduler-v0.js'
|
||||
|
||||
99
packages/core/src/scheduler/scheduler-v0.test.ts
Normal file
99
packages/core/src/scheduler/scheduler-v0.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js'
|
||||
|
||||
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
||||
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
|
||||
}
|
||||
|
||||
describe('schedule', () => {
|
||||
it('lays open issues out serially with cumulative start/finish', () => {
|
||||
const plan = schedule([issue(1, { estimateDays: 2 }), issue(2, { estimateDays: 3 })], [])
|
||||
expect(plan.cycle).toBeNull()
|
||||
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
||||
// total span = sum of durations, no overlap (single serial worker)
|
||||
const spans = plan.items.map((i) => [i.startDay, i.endDay])
|
||||
expect(spans).toContainEqual([0, expect.any(Number)])
|
||||
expect(byN[1].durationDays + byN[2].durationDays).toBe(5)
|
||||
expect(Math.max(...plan.items.map((i) => i.endDay))).toBe(5)
|
||||
})
|
||||
|
||||
it('respects dependencies: a dependency is scheduled before its dependent', () => {
|
||||
// #2 depends on #1
|
||||
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
|
||||
const plan = schedule([issue(2, { priority: 1 }), issue(1, { priority: 4 })], edges)
|
||||
const order = plan.items.map((i) => i.number)
|
||||
expect(order.indexOf(1)).toBeLessThan(order.indexOf(2))
|
||||
// #2 records #1 as a blocker; #1 records #2 as blocked
|
||||
const two = plan.items.find((i) => i.number === 2)!
|
||||
expect(two.blockedBy).toEqual([1])
|
||||
const one = plan.items.find((i) => i.number === 1)!
|
||||
expect(one.blocks).toEqual([2])
|
||||
})
|
||||
|
||||
it('breaks ties among ready roots by priority, then estimate', () => {
|
||||
const plan = schedule(
|
||||
[issue(1, { priority: 3 }), issue(2, { priority: 1 }), issue(3, { priority: 2 })],
|
||||
[],
|
||||
)
|
||||
expect(plan.items.map((i) => i.number)).toEqual([2, 3, 1])
|
||||
})
|
||||
|
||||
it('detects a dependency cycle instead of looping', () => {
|
||||
const edges: DependencyEdge[] = [
|
||||
{ issue: 1, dependsOn: 2 },
|
||||
{ issue: 2, dependsOn: 1 },
|
||||
]
|
||||
const plan = schedule([issue(1), issue(2)], edges)
|
||||
expect(plan.items).toHaveLength(0)
|
||||
expect(plan.cycle).not.toBeNull()
|
||||
expect(plan.cycle!.sort()).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('drops dependencies on out-of-scope (closed) issues', () => {
|
||||
// #2 depends on #9 which isn't in scope → treated as satisfied
|
||||
const plan = schedule([issue(2)], [{ issue: 2, dependsOn: 9 }])
|
||||
expect(plan.cycle).toBeNull()
|
||||
expect(plan.items.find((i) => i.number === 2)!.blockedBy).toEqual([])
|
||||
})
|
||||
|
||||
it('marks the longest dependency chain critical', () => {
|
||||
// chain 1→2→3 (each 3d = 9d) vs standalone 4 (2d): 1,2,3 critical, 4 not
|
||||
const edges: DependencyEdge[] = [
|
||||
{ issue: 2, dependsOn: 1 },
|
||||
{ issue: 3, dependsOn: 2 },
|
||||
]
|
||||
const issues = [
|
||||
issue(1, { estimateDays: 3 }),
|
||||
issue(2, { estimateDays: 3 }),
|
||||
issue(3, { estimateDays: 3 }),
|
||||
issue(4, { estimateDays: 2 }),
|
||||
]
|
||||
const plan = schedule(issues, edges)
|
||||
const crit = plan.items.filter((i) => i.critical).map((i) => i.number).sort()
|
||||
expect(crit).toEqual([1, 2, 3])
|
||||
expect(plan.items.find((i) => i.number === 4)!.critical).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults a null estimate to DEFAULT_ESTIMATE_DAYS', () => {
|
||||
const plan = schedule([issue(1, { estimateDays: null })], [])
|
||||
expect(plan.items[0].durationDays).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFocus', () => {
|
||||
it('picks the first three of the ordered plan as now/next/later', () => {
|
||||
const plan = schedule([issue(1, { priority: 1 }), issue(2, { priority: 2 }), issue(3, { priority: 3 })], [])
|
||||
const focus = selectFocus(plan)
|
||||
expect(focus.now?.number).toBe(1)
|
||||
expect(focus.next?.number).toBe(2)
|
||||
expect(focus.later?.number).toBe(3)
|
||||
})
|
||||
|
||||
it('returns nulls past the end of a short plan', () => {
|
||||
const focus = selectFocus(schedule([issue(1)], []))
|
||||
expect(focus.now?.number).toBe(1)
|
||||
expect(focus.next).toBeNull()
|
||||
expect(focus.later).toBeNull()
|
||||
})
|
||||
})
|
||||
182
packages/core/src/scheduler/scheduler-v0.ts
Normal file
182
packages/core/src/scheduler/scheduler-v0.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user