Skip to content

Commit 90fffbf

Browse files
committed
feat(orchestration): add NodeScheduler with topological sort and cycle detection
1 parent 08db90e commit 90fffbf

2 files changed

Lines changed: 446 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* @file node-scheduler.test.ts
3+
* @description Unit tests for NodeScheduler — topological ordering, cycle detection,
4+
* ready-node detection, and reachability analysis.
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import { NodeScheduler } from '../runtime/NodeScheduler.js';
9+
import { START, END } from '../ir/types.js';
10+
import type { GraphNode, GraphEdge } from '../ir/types.js';
11+
12+
// ---------------------------------------------------------------------------
13+
// Helpers
14+
// ---------------------------------------------------------------------------
15+
16+
function makeNode(id: string, type: GraphNode['type'] = 'tool'): GraphNode {
17+
return {
18+
id,
19+
type,
20+
executorConfig: { type: 'tool', toolName: 'test' },
21+
executionMode: 'single_turn',
22+
effectClass: 'pure',
23+
checkpoint: 'none',
24+
};
25+
}
26+
27+
function makeEdge(source: string, target: string, id?: string): GraphEdge {
28+
return { id: id ?? `${source}->${target}`, source, target, type: 'static' };
29+
}
30+
31+
// ---------------------------------------------------------------------------
32+
// topologicalSort
33+
// ---------------------------------------------------------------------------
34+
35+
describe('NodeScheduler.topologicalSort', () => {
36+
it('returns nodes in topological order for a linear graph (START→a→b→c→END)', () => {
37+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
38+
const edges = [
39+
makeEdge(START, 'a'),
40+
makeEdge('a', 'b'),
41+
makeEdge('b', 'c'),
42+
makeEdge('c', END),
43+
];
44+
const scheduler = new NodeScheduler(nodes, edges);
45+
const order = scheduler.topologicalSort();
46+
47+
expect(order).toEqual(['a', 'b', 'c']);
48+
});
49+
50+
it('places a join node after both parallel branches (START→a, START→b, a→c, b→c)', () => {
51+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
52+
const edges = [
53+
makeEdge(START, 'a'),
54+
makeEdge(START, 'b'),
55+
makeEdge('a', 'c'),
56+
makeEdge('b', 'c'),
57+
makeEdge('c', END),
58+
];
59+
const scheduler = new NodeScheduler(nodes, edges);
60+
const order = scheduler.topologicalSort();
61+
62+
// a and b must both appear before c
63+
expect(order.indexOf('c')).toBeGreaterThan(order.indexOf('a'));
64+
expect(order.indexOf('c')).toBeGreaterThan(order.indexOf('b'));
65+
expect(order).toHaveLength(3);
66+
});
67+
68+
it('returns all nodes even when some have no edges to/from sentinels (standalone real edges)', () => {
69+
// a→b with no START/END wiring — both should still appear in topological order
70+
const nodes = [makeNode('a'), makeNode('b')];
71+
const edges = [makeEdge('a', 'b')];
72+
const scheduler = new NodeScheduler(nodes, edges);
73+
const order = scheduler.topologicalSort();
74+
75+
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
76+
});
77+
78+
it('returns empty array for a graph with no real nodes', () => {
79+
const scheduler = new NodeScheduler([], [makeEdge(START, END)]);
80+
expect(scheduler.topologicalSort()).toEqual([]);
81+
});
82+
});
83+
84+
// ---------------------------------------------------------------------------
85+
// hasCycles
86+
// ---------------------------------------------------------------------------
87+
88+
describe('NodeScheduler.hasCycles', () => {
89+
it('detects a simple cycle (a→b→a)', () => {
90+
const nodes = [makeNode('a'), makeNode('b')];
91+
const edges = [
92+
makeEdge(START, 'a'),
93+
makeEdge('a', 'b'),
94+
makeEdge('b', 'a'), // cycle
95+
makeEdge('b', END),
96+
];
97+
const scheduler = new NodeScheduler(nodes, edges);
98+
expect(scheduler.hasCycles()).toBe(true);
99+
});
100+
101+
it('detects a self-loop (a→a)', () => {
102+
const nodes = [makeNode('a')];
103+
const edges = [
104+
makeEdge(START, 'a'),
105+
makeEdge('a', 'a'), // self-loop
106+
makeEdge('a', END),
107+
];
108+
const scheduler = new NodeScheduler(nodes, edges);
109+
expect(scheduler.hasCycles()).toBe(true);
110+
});
111+
112+
it('reports no cycles for a valid DAG', () => {
113+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
114+
const edges = [
115+
makeEdge(START, 'a'),
116+
makeEdge('a', 'b'),
117+
makeEdge('a', 'c'),
118+
makeEdge('b', END),
119+
makeEdge('c', END),
120+
];
121+
const scheduler = new NodeScheduler(nodes, edges);
122+
expect(scheduler.hasCycles()).toBe(false);
123+
});
124+
125+
it('reports no cycles for a linear graph', () => {
126+
const nodes = [makeNode('x'), makeNode('y')];
127+
const edges = [makeEdge(START, 'x'), makeEdge('x', 'y'), makeEdge('y', END)];
128+
const scheduler = new NodeScheduler(nodes, edges);
129+
expect(scheduler.hasCycles()).toBe(false);
130+
});
131+
});
132+
133+
// ---------------------------------------------------------------------------
134+
// getReadyNodes
135+
// ---------------------------------------------------------------------------
136+
137+
describe('NodeScheduler.getReadyNodes', () => {
138+
it('returns nodes directly connected from START when nothing is completed', () => {
139+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
140+
const edges = [
141+
makeEdge(START, 'a'),
142+
makeEdge(START, 'b'),
143+
makeEdge('a', 'c'),
144+
makeEdge('b', 'c'),
145+
makeEdge('c', END),
146+
];
147+
const scheduler = new NodeScheduler(nodes, edges);
148+
const ready = scheduler.getReadyNodes([]);
149+
150+
expect(ready.sort()).toEqual(['a', 'b']);
151+
});
152+
153+
it('unlocks a join node only after ALL its predecessors complete', () => {
154+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
155+
const edges = [
156+
makeEdge(START, 'a'),
157+
makeEdge(START, 'b'),
158+
makeEdge('a', 'c'),
159+
makeEdge('b', 'c'),
160+
makeEdge('c', END),
161+
];
162+
const scheduler = new NodeScheduler(nodes, edges);
163+
164+
// After only 'a' completes, 'c' is still blocked by 'b'.
165+
expect(scheduler.getReadyNodes(['a'])).not.toContain('c');
166+
167+
// After both complete, 'c' becomes ready.
168+
expect(scheduler.getReadyNodes(['a', 'b'])).toContain('c');
169+
});
170+
171+
it('excludes already-completed nodes from the ready set', () => {
172+
const nodes = [makeNode('a'), makeNode('b')];
173+
const edges = [makeEdge(START, 'a'), makeEdge('a', 'b'), makeEdge('b', END)];
174+
const scheduler = new NodeScheduler(nodes, edges);
175+
176+
const ready = scheduler.getReadyNodes(['a']);
177+
expect(ready).toContain('b');
178+
expect(ready).not.toContain('a');
179+
});
180+
181+
it('treats skipped nodes as satisfied for dependency purposes', () => {
182+
const nodes = [makeNode('a'), makeNode('b'), makeNode('c')];
183+
const edges = [
184+
makeEdge(START, 'a'),
185+
makeEdge('a', 'b'),
186+
makeEdge('a', 'c'),
187+
makeEdge('b', END),
188+
makeEdge('c', END),
189+
];
190+
const scheduler = new NodeScheduler(nodes, edges);
191+
192+
// 'a' completed, 'b' skipped → 'c' should be ready
193+
const ready = scheduler.getReadyNodes(['a'], ['b']);
194+
expect(ready).toContain('c');
195+
expect(ready).not.toContain('b');
196+
expect(ready).not.toContain('a');
197+
});
198+
});
199+
200+
// ---------------------------------------------------------------------------
201+
// getUnreachableNodes
202+
// ---------------------------------------------------------------------------
203+
204+
describe('NodeScheduler.getUnreachableNodes', () => {
205+
it('returns empty array when all nodes are reachable from START', () => {
206+
const nodes = [makeNode('a'), makeNode('b')];
207+
const edges = [makeEdge(START, 'a'), makeEdge('a', 'b'), makeEdge('b', END)];
208+
const scheduler = new NodeScheduler(nodes, edges);
209+
expect(scheduler.getUnreachableNodes()).toEqual([]);
210+
});
211+
212+
it('detects orphan nodes with no incoming edges from the reachable subgraph', () => {
213+
const nodes = [makeNode('a'), makeNode('orphan')];
214+
const edges = [
215+
makeEdge(START, 'a'),
216+
makeEdge('a', END),
217+
// 'orphan' has no edge from START or any reachable node
218+
];
219+
const scheduler = new NodeScheduler(nodes, edges);
220+
expect(scheduler.getUnreachableNodes()).toContain('orphan');
221+
});
222+
223+
it('detects a node reachable only from another orphan (transitive unreachability)', () => {
224+
const nodes = [makeNode('a'), makeNode('orphan1'), makeNode('orphan2')];
225+
const edges = [
226+
makeEdge(START, 'a'),
227+
makeEdge('a', END),
228+
makeEdge('orphan1', 'orphan2'), // orphan2 reachable from orphan1, not from START
229+
];
230+
const scheduler = new NodeScheduler(nodes, edges);
231+
const unreachable = scheduler.getUnreachableNodes();
232+
expect(unreachable).toContain('orphan1');
233+
expect(unreachable).toContain('orphan2');
234+
});
235+
});

0 commit comments

Comments
 (0)