Files
commitea/packages/core/src/scheduler/scheduler-v0.test.ts
Croissant Le Doux 68a93de098 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>
2026-07-08 17:12:22 -04:00

100 lines
3.9 KiB
TypeScript

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()
})
})