Skip to content

Commit 35e755a

Browse files
gewenyu99claude
andcommitted
feat(orchestrator): in-memory queue + disk persistence (QueueStore)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4438709 commit 35e755a

4 files changed

Lines changed: 471 additions & 20 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import {
5+
QueueStore,
6+
type QueueFile,
7+
type TaskHandoff,
8+
} from '@lib/programs/orchestrator/queue';
9+
10+
function tmpDir(): string {
11+
return fs.mkdtempSync(path.join(os.tmpdir(), 'queue-test-'));
12+
}
13+
14+
describe('QueueStore', () => {
15+
let dir: string;
16+
let q: QueueStore;
17+
18+
beforeEach(() => {
19+
dir = tmpDir();
20+
q = new QueueStore(dir, 'run-1');
21+
});
22+
23+
afterEach(() => {
24+
fs.rmSync(dir, { recursive: true, force: true });
25+
});
26+
27+
it('enqueues a pending task with defaults', async () => {
28+
const t = await q.enqueue({ type: 'install' });
29+
expect(t.status).toBe('pending');
30+
expect(t.attempts).toBe(0);
31+
expect(t.maxAttempts).toBe(2);
32+
expect(t.enqueuedBy).toBe('orchestrator');
33+
expect(t.dependsOn).toEqual([]);
34+
expect(q.list()).toHaveLength(1);
35+
});
36+
37+
it('only marks a task runnable once its dependencies are done', async () => {
38+
const a = await q.enqueue({ type: 'install' });
39+
const b = await q.enqueue({ type: 'init', dependsOn: [a.id] });
40+
41+
expect(q.nextRunnable(10).map((t) => t.id)).toEqual([a.id]);
42+
43+
await q.start(a.id);
44+
await q.complete(a.id);
45+
expect(q.nextRunnable(10).map((t) => t.id)).toEqual([b.id]);
46+
});
47+
48+
it('runs independent tasks in parallel, bounded by the cap and in-progress count', async () => {
49+
const a = await q.enqueue({ type: 'install' });
50+
const b = await q.enqueue({ type: 'init' });
51+
52+
// Both independent, so both are runnable; the cap bounds how many.
53+
expect(
54+
q
55+
.nextRunnable(10)
56+
.map((t) => t.id)
57+
.sort(),
58+
).toEqual([a.id, b.id].sort());
59+
expect(q.nextRunnable(1)).toHaveLength(1);
60+
61+
await q.start(a.id);
62+
// One slot used, so cap 1 yields nothing more until a finishes.
63+
expect(q.nextRunnable(1)).toHaveLength(0);
64+
// Cap 2 still has a free slot for b.
65+
expect(q.nextRunnable(2).map((t) => t.id)).toEqual([b.id]);
66+
});
67+
68+
it('start increments attempts and supports within-run retry while attempts remain', async () => {
69+
const t = await q.enqueue({ type: 'install', maxAttempts: 2 });
70+
await q.start(t.id);
71+
expect(q.get(t.id)?.attempts).toBe(1);
72+
73+
await q.fail(t.id, { type: 'API_ERROR', message: 'boom' });
74+
expect(q.get(t.id)?.status).toBe('failed');
75+
76+
// Retry: attempts (1) < maxAttempts (2), so requeue and run again.
77+
await q.requeue(t.id);
78+
expect(q.get(t.id)?.status).toBe('pending');
79+
await q.start(t.id);
80+
expect(q.get(t.id)?.attempts).toBe(2);
81+
});
82+
83+
it('completing a task writes and reads back a structured handoff', async () => {
84+
const t = await q.enqueue({ type: 'install' });
85+
const handoff: TaskHandoff = {
86+
goals: 'install the sdk',
87+
did: 'added posthog-js',
88+
forNextAgent: 'env vars not set yet',
89+
filesTouched: ['package.json'],
90+
};
91+
await q.start(t.id);
92+
await q.complete(t.id, handoff);
93+
94+
expect(q.get(t.id)?.status).toBe('done');
95+
expect(q.readHandoff(t.id)).toEqual(handoff);
96+
expect(q.readHandoffsByType('install')).toEqual([handoff]);
97+
});
98+
99+
it('is drained when a pending task is blocked by a failed dependency', async () => {
100+
const a = await q.enqueue({ type: 'install' });
101+
await q.enqueue({ type: 'init', dependsOn: [a.id] });
102+
103+
expect(q.isDrained()).toBe(false);
104+
await q.start(a.id);
105+
await q.fail(a.id, { type: 'API_ERROR', message: 'boom' });
106+
107+
// init can never run now, and nothing is in progress.
108+
expect(q.nextRunnable(10)).toHaveLength(0);
109+
expect(q.isDrained()).toBe(true);
110+
});
111+
112+
it('reflects the in-memory queue to queue.json on flush', async () => {
113+
const a = await q.enqueue({ type: 'install' });
114+
await q.start(a.id);
115+
await q.complete(a.id);
116+
q.flushNow();
117+
118+
const file = JSON.parse(fs.readFileSync(q.queuePath, 'utf8')) as QueueFile;
119+
expect(file.version).toBe(1);
120+
expect(file.runId).toBe('run-1');
121+
expect(file.tasks).toHaveLength(1);
122+
expect(file.tasks[0].status).toBe('done');
123+
});
124+
125+
it('appends a line to audit.jsonl for each transition', async () => {
126+
const a = await q.enqueue({ type: 'install' });
127+
await q.start(a.id);
128+
await q.complete(a.id);
129+
130+
const lines = fs
131+
.readFileSync(q.auditPath, 'utf8')
132+
.trim()
133+
.split('\n')
134+
.map((l) => JSON.parse(l) as { event: string });
135+
expect(lines.map((l) => l.event)).toEqual(['enqueue', 'start', 'complete']);
136+
});
137+
138+
it('serializes concurrent enqueues with no lost updates', async () => {
139+
await Promise.all(
140+
Array.from({ length: 25 }, (_, i) => q.enqueue({ type: `t${i}` })),
141+
);
142+
expect(q.list()).toHaveLength(25);
143+
const ids = new Set(q.list().map((t) => t.id));
144+
expect(ids.size).toBe(25);
145+
});
146+
});

0 commit comments

Comments
 (0)