Skip to content

Commit 7bea83c

Browse files
gewenyu99claude
andauthored
feat(orchestrator): in-memory queue + disk persistence (QueueStore) (#607)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 51706a2 commit 7bea83c

4 files changed

Lines changed: 406 additions & 20 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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', () => {
28+
const t = 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', () => {
38+
const a = q.enqueue({ type: 'install' });
39+
const b = q.enqueue({ type: 'init', dependsOn: [a.id] });
40+
41+
expect(q.nextRunnable().map((t) => t.id)).toEqual([a.id]);
42+
43+
q.start(a.id);
44+
q.complete(a.id);
45+
expect(q.nextRunnable().map((t) => t.id)).toEqual([b.id]);
46+
});
47+
48+
it('returns every runnable task; the graph alone decides parallelism', () => {
49+
const a = q.enqueue({ type: 'install' });
50+
const b = q.enqueue({ type: 'init' });
51+
q.enqueue({ type: 'capture', dependsOn: [a.id, b.id] });
52+
53+
// Both independent tasks are runnable at once; the dependent one is not.
54+
expect(
55+
q
56+
.nextRunnable()
57+
.map((t) => t.id)
58+
.sort(),
59+
).toEqual([a.id, b.id].sort());
60+
61+
q.start(a.id);
62+
// An in-progress task is no longer offered.
63+
expect(q.nextRunnable().map((t) => t.id)).toEqual([b.id]);
64+
});
65+
66+
it('treats a skipped dependency as satisfied', () => {
67+
const a = q.enqueue({ type: 'install' });
68+
const b = q.enqueue({ type: 'init', dependsOn: [a.id] });
69+
70+
q.start(a.id);
71+
q.skip(a.id);
72+
expect(q.nextRunnable().map((t) => t.id)).toEqual([b.id]);
73+
});
74+
75+
it('start increments attempts and supports within-run retry while attempts remain', () => {
76+
const t = q.enqueue({ type: 'install', maxAttempts: 2 });
77+
q.start(t.id);
78+
expect(q.get(t.id)?.attempts).toBe(1);
79+
80+
q.fail(t.id, { type: 'API_ERROR', message: 'boom' });
81+
expect(q.get(t.id)?.status).toBe('failed');
82+
83+
// Retry: attempts (1) < maxAttempts (2), so requeue and run again.
84+
q.requeue(t.id);
85+
expect(q.get(t.id)?.status).toBe('pending');
86+
q.start(t.id);
87+
expect(q.get(t.id)?.attempts).toBe(2);
88+
});
89+
90+
it('completing a task records and reads back a structured handoff', () => {
91+
const t = q.enqueue({ type: 'install' });
92+
const handoff: TaskHandoff = {
93+
goals: 'install the sdk',
94+
did: 'added posthog-js',
95+
forNextAgent: 'env vars not set yet',
96+
filesTouched: ['package.json'],
97+
};
98+
q.start(t.id);
99+
q.complete(t.id, handoff);
100+
101+
expect(q.get(t.id)?.status).toBe('done');
102+
expect(q.readHandoff(t.id)).toEqual(handoff);
103+
expect(q.readHandoffsByType('install')).toEqual([handoff]);
104+
});
105+
106+
it('is drained when a pending task is blocked by a failed dependency', () => {
107+
const a = q.enqueue({ type: 'install' });
108+
q.enqueue({ type: 'init', dependsOn: [a.id] });
109+
110+
expect(q.isDrained()).toBe(false);
111+
q.start(a.id);
112+
q.fail(a.id, { type: 'API_ERROR', message: 'boom' });
113+
114+
// init can never run now, and nothing is in progress.
115+
expect(q.nextRunnable()).toHaveLength(0);
116+
expect(q.isDrained()).toBe(true);
117+
});
118+
119+
it('reflects every transition to queue.json, handoffs included', () => {
120+
const a = q.enqueue({ type: 'install' });
121+
q.start(a.id);
122+
q.complete(a.id, {
123+
goals: 'g',
124+
did: 'd',
125+
forNextAgent: 'n',
126+
});
127+
128+
const file = JSON.parse(fs.readFileSync(q.queuePath, 'utf8')) as QueueFile;
129+
expect(file.version).toBe(1);
130+
expect(file.runId).toBe('run-1');
131+
expect(file.tasks).toHaveLength(1);
132+
expect(file.tasks[0].status).toBe('done');
133+
expect(file.tasks[0].handoff?.did).toBe('d');
134+
});
135+
});
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* The orchestrator task queue.
3+
*
4+
* In memory, synchronous, single-owner: one Node process drives the run, so
5+
* there is no locking. The queue imposes no execution policy — `nextRunnable`
6+
* returns every pending task whose dependencies are satisfied, and how many of
7+
* those run at once is decided by the task graph, not the queue.
8+
*
9+
* Every transition rewrites `<installDir>/.posthog-wizard/queue.json`, a small
10+
* file holding the whole queue, handoffs included. Today it is the run's
11+
* log and the report's source; later it is the resume point.
12+
*/
13+
import * as fs from 'fs';
14+
import * as path from 'path';
15+
import { randomUUID } from 'crypto';
16+
import { writeJsonAtomic } from '../../../utils/atomic-ledger';
17+
18+
export const TaskStatus = {
19+
Pending: 'pending',
20+
Running: 'running',
21+
Done: 'done',
22+
Skipped: 'skipped',
23+
Failed: 'failed',
24+
} as const;
25+
26+
export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];
27+
28+
export interface QueuedTask {
29+
id: string;
30+
type: string;
31+
status: TaskStatus;
32+
dependsOn: string[];
33+
inputs: Record<string, unknown>;
34+
model?: string;
35+
attempts: number;
36+
maxAttempts: number;
37+
/** The structured handoff the task reported on completion. */
38+
handoff?: TaskHandoff;
39+
/** 'orchestrator' for seeded tasks, or the id of the task that enqueued this one. */
40+
enqueuedBy: string;
41+
createdAt: string;
42+
startedAt?: string;
43+
finishedAt?: string;
44+
error?: { type: string; message: string };
45+
}
46+
47+
export interface QueueFile {
48+
version: 1;
49+
runId: string;
50+
tasks: QueuedTask[];
51+
}
52+
53+
/** The structured handoff a task leaves for the next agent. */
54+
export interface TaskHandoff {
55+
goals: string;
56+
did: string;
57+
forNextAgent: string;
58+
filesTouched?: string[];
59+
}
60+
61+
export interface EnqueueInput {
62+
type: string;
63+
inputs?: Record<string, unknown>;
64+
dependsOn?: string[];
65+
model?: string;
66+
maxAttempts?: number;
67+
enqueuedBy?: string;
68+
}
69+
70+
export const QUEUE_DIR_NAME = '.posthog-wizard';
71+
const DEFAULT_MAX_ATTEMPTS = 2;
72+
73+
function nowIso(): string {
74+
return new Date().toISOString();
75+
}
76+
77+
export class QueueStore {
78+
private tasks: QueuedTask[] = [];
79+
80+
readonly runId: string;
81+
readonly queuePath: string;
82+
83+
constructor(installDir: string, runId: string) {
84+
this.runId = runId;
85+
const dir = path.join(installDir, QUEUE_DIR_NAME);
86+
this.queuePath = path.join(dir, 'queue.json');
87+
fs.mkdirSync(dir, { recursive: true });
88+
}
89+
90+
// ── Reads ───────────────────────────────────────────────────────────
91+
92+
list(): readonly QueuedTask[] {
93+
return this.tasks;
94+
}
95+
96+
get(id: string): QueuedTask | undefined {
97+
return this.tasks.find((t) => t.id === id);
98+
}
99+
100+
/**
101+
* Every pending task whose dependencies are all satisfied (`done` or
102+
* `skipped`). A skipped dependency does not block downstream work.
103+
*/
104+
nextRunnable(): QueuedTask[] {
105+
const doneIds = new Set(
106+
this.tasks
107+
.filter(
108+
(t) =>
109+
t.status === TaskStatus.Done || t.status === TaskStatus.Skipped,
110+
)
111+
.map((t) => t.id),
112+
);
113+
return this.tasks.filter(
114+
(t) =>
115+
t.status === TaskStatus.Pending &&
116+
t.dependsOn.every((d) => doneIds.has(d)),
117+
);
118+
}
119+
120+
/**
121+
* True when no task is in progress and none can be started. Either everything
122+
* is terminal, or the only pending tasks are blocked by a failed dependency.
123+
*/
124+
isDrained(): boolean {
125+
if (this.tasks.some((t) => t.status === TaskStatus.Running)) return false;
126+
return this.nextRunnable().length === 0;
127+
}
128+
129+
summary(): Record<TaskStatus, number> & { total: number } {
130+
const counts: Record<TaskStatus, number> = {
131+
[TaskStatus.Pending]: 0,
132+
[TaskStatus.Running]: 0,
133+
[TaskStatus.Done]: 0,
134+
[TaskStatus.Skipped]: 0,
135+
[TaskStatus.Failed]: 0,
136+
};
137+
for (const t of this.tasks) counts[t.status] += 1;
138+
return { ...counts, total: this.tasks.length };
139+
}
140+
141+
readHandoff(id: string): TaskHandoff | null {
142+
return this.get(id)?.handoff ?? null;
143+
}
144+
145+
/** Handoffs of completed tasks of a given type, oldest first. */
146+
readHandoffsByType(type: string): TaskHandoff[] {
147+
return this.tasks
148+
.filter((t) => t.type === type && t.handoff)
149+
.map((t) => t.handoff as TaskHandoff);
150+
}
151+
152+
// ── Transitions (each one reflected to queue.json) ──────────────────
153+
154+
enqueue(input: EnqueueInput): QueuedTask {
155+
const task: QueuedTask = {
156+
id: randomUUID(),
157+
type: input.type,
158+
status: TaskStatus.Pending,
159+
dependsOn: input.dependsOn ?? [],
160+
inputs: input.inputs ?? {},
161+
model: input.model,
162+
attempts: 0,
163+
maxAttempts: input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
164+
enqueuedBy: input.enqueuedBy ?? 'orchestrator',
165+
createdAt: nowIso(),
166+
};
167+
this.tasks.push(task);
168+
this.reflect();
169+
return task;
170+
}
171+
172+
start(id: string): QueuedTask {
173+
const t = this.require(id);
174+
t.status = TaskStatus.Running;
175+
t.startedAt = nowIso();
176+
t.attempts += 1;
177+
this.reflect();
178+
return t;
179+
}
180+
181+
complete(id: string, handoff?: TaskHandoff): QueuedTask {
182+
return this.finish(id, TaskStatus.Done, handoff);
183+
}
184+
185+
/** Terminal: the agent could not do the task. Not done, not failed. */
186+
skip(id: string, handoff?: TaskHandoff): QueuedTask {
187+
return this.finish(id, TaskStatus.Skipped, handoff);
188+
}
189+
190+
fail(
191+
id: string,
192+
error: { type: string; message: string },
193+
handoff?: TaskHandoff,
194+
): QueuedTask {
195+
const t = this.require(id);
196+
t.error = error;
197+
return this.finish(id, TaskStatus.Failed, handoff);
198+
}
199+
200+
/** Put a failed/in-progress task back to pending for a retry within the run. */
201+
requeue(id: string): QueuedTask {
202+
const t = this.require(id);
203+
t.status = TaskStatus.Pending;
204+
t.startedAt = undefined;
205+
t.finishedAt = undefined;
206+
this.reflect();
207+
return t;
208+
}
209+
210+
// ── Internals ───────────────────────────────────────────────────────
211+
212+
private finish(
213+
id: string,
214+
status: 'done' | 'skipped' | 'failed',
215+
handoff?: TaskHandoff,
216+
): QueuedTask {
217+
const t = this.require(id);
218+
if (handoff) t.handoff = handoff;
219+
t.status = status;
220+
t.finishedAt = nowIso();
221+
this.reflect();
222+
return t;
223+
}
224+
225+
private reflect(): void {
226+
const file: QueueFile = {
227+
version: 1,
228+
runId: this.runId,
229+
tasks: this.tasks,
230+
};
231+
writeJsonAtomic(this.queuePath, file);
232+
}
233+
234+
private require(id: string): QueuedTask {
235+
const t = this.get(id);
236+
if (!t) throw new Error(`No task ${id} in the queue`);
237+
return t;
238+
}
239+
}

0 commit comments

Comments
 (0)