@jackchen_me/open-multi-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +280 -0
- package/dist/agent/agent.d.ts +121 -0
- package/dist/agent/agent.d.ts.map +1 -0
- package/dist/agent/agent.js +294 -0
- package/dist/agent/agent.js.map +1 -0
- package/dist/agent/pool.d.ts +128 -0
- package/dist/agent/pool.d.ts.map +1 -0
- package/dist/agent/pool.js +236 -0
- package/dist/agent/pool.js.map +1 -0
- package/dist/agent/runner.d.ts +120 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +274 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/adapter.d.ts +38 -0
- package/dist/llm/adapter.d.ts.map +1 -0
- package/dist/llm/adapter.js +46 -0
- package/dist/llm/adapter.js.map +1 -0
- package/dist/llm/anthropic.d.ts +56 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +307 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/openai.d.ts +62 -0
- package/dist/llm/openai.d.ts.map +1 -0
- package/dist/llm/openai.js +424 -0
- package/dist/llm/openai.js.map +1 -0
- package/dist/memory/shared.d.ts +86 -0
- package/dist/memory/shared.d.ts.map +1 -0
- package/dist/memory/shared.js +155 -0
- package/dist/memory/shared.js.map +1 -0
- package/dist/memory/store.d.ts +64 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +103 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/orchestrator/orchestrator.d.ts +173 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator/orchestrator.js +698 -0
- package/dist/orchestrator/orchestrator.js.map +1 -0
- package/dist/orchestrator/scheduler.d.ts +112 -0
- package/dist/orchestrator/scheduler.d.ts.map +1 -0
- package/dist/orchestrator/scheduler.js +282 -0
- package/dist/orchestrator/scheduler.js.map +1 -0
- package/dist/task/queue.d.ts +160 -0
- package/dist/task/queue.d.ts.map +1 -0
- package/dist/task/queue.js +337 -0
- package/dist/task/queue.js.map +1 -0
- package/dist/task/task.d.ts +86 -0
- package/dist/task/task.d.ts.map +1 -0
- package/dist/task/task.js +201 -0
- package/dist/task/task.js.map +1 -0
- package/dist/team/messaging.d.ts +106 -0
- package/dist/team/messaging.d.ts.map +1 -0
- package/dist/team/messaging.js +182 -0
- package/dist/team/messaging.js.map +1 -0
- package/dist/team/team.d.ts +141 -0
- package/dist/team/team.d.ts.map +1 -0
- package/dist/team/team.js +282 -0
- package/dist/team/team.js.map +1 -0
- package/dist/tool/built-in/bash.d.ts +12 -0
- package/dist/tool/built-in/bash.d.ts.map +1 -0
- package/dist/tool/built-in/bash.js +133 -0
- package/dist/tool/built-in/bash.js.map +1 -0
- package/dist/tool/built-in/file-edit.d.ts +14 -0
- package/dist/tool/built-in/file-edit.d.ts.map +1 -0
- package/dist/tool/built-in/file-edit.js +130 -0
- package/dist/tool/built-in/file-edit.js.map +1 -0
- package/dist/tool/built-in/file-read.d.ts +12 -0
- package/dist/tool/built-in/file-read.d.ts.map +1 -0
- package/dist/tool/built-in/file-read.js +82 -0
- package/dist/tool/built-in/file-read.js.map +1 -0
- package/dist/tool/built-in/file-write.d.ts +11 -0
- package/dist/tool/built-in/file-write.d.ts.map +1 -0
- package/dist/tool/built-in/file-write.js +70 -0
- package/dist/tool/built-in/file-write.js.map +1 -0
- package/dist/tool/built-in/grep.d.ts +15 -0
- package/dist/tool/built-in/grep.d.ts.map +1 -0
- package/dist/tool/built-in/grep.js +287 -0
- package/dist/tool/built-in/grep.js.map +1 -0
- package/dist/tool/built-in/index.d.ts +36 -0
- package/dist/tool/built-in/index.d.ts.map +1 -0
- package/dist/tool/built-in/index.js +45 -0
- package/dist/tool/built-in/index.js.map +1 -0
- package/dist/tool/executor.d.ts +71 -0
- package/dist/tool/executor.d.ts.map +1 -0
- package/dist/tool/executor.js +116 -0
- package/dist/tool/executor.js.map +1 -0
- package/dist/tool/framework.d.ts +143 -0
- package/dist/tool/framework.d.ts.map +1 -0
- package/dist/tool/framework.js +371 -0
- package/dist/tool/framework.js.map +1 -0
- package/dist/types.d.ts +285 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/semaphore.d.ts +47 -0
- package/dist/utils/semaphore.d.ts.map +1 -0
- package/dist/utils/semaphore.js +85 -0
- package/dist/utils/semaphore.js.map +1 -0
- package/examples/01-single-agent.ts +131 -0
- package/examples/02-team-collaboration.ts +167 -0
- package/examples/03-task-pipeline.ts +201 -0
- package/examples/04-multi-model-team.ts +261 -0
- package/package.json +49 -0
- package/src/agent/agent.ts +364 -0
- package/src/agent/pool.ts +278 -0
- package/src/agent/runner.ts +413 -0
- package/src/index.ts +166 -0
- package/src/llm/adapter.ts +74 -0
- package/src/llm/anthropic.ts +388 -0
- package/src/llm/openai.ts +522 -0
- package/src/memory/shared.ts +181 -0
- package/src/memory/store.ts +124 -0
- package/src/orchestrator/orchestrator.ts +851 -0
- package/src/orchestrator/scheduler.ts +352 -0
- package/src/task/queue.ts +394 -0
- package/src/task/task.ts +232 -0
- package/src/team/messaging.ts +230 -0
- package/src/team/team.ts +334 -0
- package/src/tool/built-in/bash.ts +187 -0
- package/src/tool/built-in/file-edit.ts +154 -0
- package/src/tool/built-in/file-read.ts +105 -0
- package/src/tool/built-in/file-write.ts +81 -0
- package/src/tool/built-in/grep.ts +362 -0
- package/src/tool/built-in/index.ts +50 -0
- package/src/tool/executor.ts +178 -0
- package/src/tool/framework.ts +557 -0
- package/src/types.ts +362 -0
- package/src/utils/semaphore.ts +89 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dependency-aware task queue.
|
|
3
|
+
*
|
|
4
|
+
* {@link TaskQueue} owns the mutable lifecycle of every task it holds.
|
|
5
|
+
* Completing a task automatically unblocks dependents and fires events so
|
|
6
|
+
* orchestrators can react without polling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Task, TaskStatus } from '../types.js'
|
|
10
|
+
import { isTaskReady } from './task.js'
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Event types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Named event types emitted by {@link TaskQueue}. */
|
|
17
|
+
export type TaskQueueEvent =
|
|
18
|
+
| 'task:ready'
|
|
19
|
+
| 'task:complete'
|
|
20
|
+
| 'task:failed'
|
|
21
|
+
| 'all:complete'
|
|
22
|
+
|
|
23
|
+
/** Handler for `'task:ready' | 'task:complete' | 'task:failed'` events. */
|
|
24
|
+
type TaskHandler = (task: Task) => void
|
|
25
|
+
/** Handler for `'all:complete'` (no task argument). */
|
|
26
|
+
type AllCompleteHandler = () => void
|
|
27
|
+
|
|
28
|
+
type HandlerFor<E extends TaskQueueEvent> = E extends 'all:complete'
|
|
29
|
+
? AllCompleteHandler
|
|
30
|
+
: TaskHandler
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// TaskQueue
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mutable, event-driven queue with topological dependency resolution.
|
|
38
|
+
*
|
|
39
|
+
* Tasks enter in `'pending'` state. The queue promotes them to `'blocked'`
|
|
40
|
+
* when unresolved dependencies exist, and back to `'pending'` (firing
|
|
41
|
+
* `'task:ready'`) when those dependencies complete. Callers drive execution by
|
|
42
|
+
* calling {@link next} / {@link nextAvailable} and updating task state via
|
|
43
|
+
* {@link complete} or {@link fail}.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const queue = new TaskQueue()
|
|
48
|
+
* queue.on('task:ready', (task) => scheduleExecution(task))
|
|
49
|
+
* queue.on('all:complete', () => shutdown())
|
|
50
|
+
*
|
|
51
|
+
* queue.addBatch(tasks)
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export class TaskQueue {
|
|
55
|
+
private readonly tasks = new Map<string, Task>()
|
|
56
|
+
|
|
57
|
+
/** Listeners keyed by event type, stored as symbol → handler pairs. */
|
|
58
|
+
private readonly listeners = new Map<
|
|
59
|
+
TaskQueueEvent,
|
|
60
|
+
Map<symbol, TaskHandler | AllCompleteHandler>
|
|
61
|
+
>()
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Mutation: add
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Adds a single task.
|
|
69
|
+
*
|
|
70
|
+
* If the task has unresolved dependencies it is immediately promoted to
|
|
71
|
+
* `'blocked'`; otherwise it stays `'pending'` and `'task:ready'` fires.
|
|
72
|
+
*/
|
|
73
|
+
add(task: Task): void {
|
|
74
|
+
const resolved = this.resolveInitialStatus(task)
|
|
75
|
+
this.tasks.set(resolved.id, resolved)
|
|
76
|
+
if (resolved.status === 'pending') {
|
|
77
|
+
this.emit('task:ready', resolved)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adds multiple tasks at once.
|
|
83
|
+
*
|
|
84
|
+
* Processing each task re-evaluates the current map state, so inserting a
|
|
85
|
+
* batch where some tasks satisfy others' dependencies produces correct initial
|
|
86
|
+
* statuses when the dependencies appear first in the array. Use
|
|
87
|
+
* {@link getTaskDependencyOrder} from `task.ts` to pre-sort if needed.
|
|
88
|
+
*/
|
|
89
|
+
addBatch(tasks: Task[]): void {
|
|
90
|
+
for (const task of tasks) {
|
|
91
|
+
this.add(task)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Mutation: update / complete / fail
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Applies a partial update to an existing task.
|
|
101
|
+
*
|
|
102
|
+
* Only `status`, `result`, and `assignee` are accepted to keep the update
|
|
103
|
+
* surface narrow. Use {@link complete} and {@link fail} for terminal states.
|
|
104
|
+
*
|
|
105
|
+
* @throws {Error} when `taskId` is not found.
|
|
106
|
+
*/
|
|
107
|
+
update(
|
|
108
|
+
taskId: string,
|
|
109
|
+
update: Partial<Pick<Task, 'status' | 'result' | 'assignee'>>,
|
|
110
|
+
): Task {
|
|
111
|
+
const task = this.requireTask(taskId)
|
|
112
|
+
const updated: Task = {
|
|
113
|
+
...task,
|
|
114
|
+
...update,
|
|
115
|
+
updatedAt: new Date(),
|
|
116
|
+
}
|
|
117
|
+
this.tasks.set(taskId, updated)
|
|
118
|
+
return updated
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Marks `taskId` as `'completed'`, records an optional `result` string, and
|
|
123
|
+
* unblocks any dependents that are now ready to run.
|
|
124
|
+
*
|
|
125
|
+
* Fires `'task:complete'`, then `'task:ready'` for each newly-unblocked task,
|
|
126
|
+
* then `'all:complete'` when the queue is fully resolved.
|
|
127
|
+
*
|
|
128
|
+
* @throws {Error} when `taskId` is not found.
|
|
129
|
+
*/
|
|
130
|
+
complete(taskId: string, result?: string): Task {
|
|
131
|
+
const completed = this.update(taskId, { status: 'completed', result })
|
|
132
|
+
this.emit('task:complete', completed)
|
|
133
|
+
this.unblockDependents(taskId)
|
|
134
|
+
if (this.isComplete()) {
|
|
135
|
+
this.emitAllComplete()
|
|
136
|
+
}
|
|
137
|
+
return completed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Marks `taskId` as `'failed'` and records `error` in the `result` field.
|
|
142
|
+
*
|
|
143
|
+
* Fires `'task:failed'` for the failed task and for every downstream task
|
|
144
|
+
* that transitively depended on it (cascade failure). This prevents blocked
|
|
145
|
+
* tasks from remaining stuck indefinitely when an upstream dependency fails.
|
|
146
|
+
*
|
|
147
|
+
* @throws {Error} when `taskId` is not found.
|
|
148
|
+
*/
|
|
149
|
+
fail(taskId: string, error: string): Task {
|
|
150
|
+
const failed = this.update(taskId, { status: 'failed', result: error })
|
|
151
|
+
this.emit('task:failed', failed)
|
|
152
|
+
this.cascadeFailure(taskId)
|
|
153
|
+
if (this.isComplete()) {
|
|
154
|
+
this.emitAllComplete()
|
|
155
|
+
}
|
|
156
|
+
return failed
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Recursively marks all tasks that (transitively) depend on `failedTaskId`
|
|
161
|
+
* as `'failed'` with an informative message, firing `'task:failed'` for each.
|
|
162
|
+
*
|
|
163
|
+
* Only tasks in `'blocked'` or `'pending'` state are affected; tasks already
|
|
164
|
+
* in a terminal state are left untouched.
|
|
165
|
+
*/
|
|
166
|
+
private cascadeFailure(failedTaskId: string): void {
|
|
167
|
+
for (const task of this.tasks.values()) {
|
|
168
|
+
if (task.status !== 'blocked' && task.status !== 'pending') continue
|
|
169
|
+
if (!task.dependsOn?.includes(failedTaskId)) continue
|
|
170
|
+
|
|
171
|
+
const cascaded = this.update(task.id, {
|
|
172
|
+
status: 'failed',
|
|
173
|
+
result: `Cancelled: dependency "${failedTaskId}" failed.`,
|
|
174
|
+
})
|
|
175
|
+
this.emit('task:failed', cascaded)
|
|
176
|
+
// Recurse to handle transitive dependents.
|
|
177
|
+
this.cascadeFailure(task.id)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Queries
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Returns the next `'pending'` task for `assignee` (matched against
|
|
187
|
+
* `task.assignee`), or `undefined` if none exists.
|
|
188
|
+
*
|
|
189
|
+
* If `assignee` is omitted, behaves like {@link nextAvailable}.
|
|
190
|
+
*/
|
|
191
|
+
next(assignee?: string): Task | undefined {
|
|
192
|
+
if (assignee === undefined) return this.nextAvailable()
|
|
193
|
+
|
|
194
|
+
for (const task of this.tasks.values()) {
|
|
195
|
+
if (task.status === 'pending' && task.assignee === assignee) {
|
|
196
|
+
return task
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Returns the next `'pending'` task that has no `assignee` restriction, or
|
|
204
|
+
* the first `'pending'` task overall when all pending tasks have an assignee.
|
|
205
|
+
*/
|
|
206
|
+
nextAvailable(): Task | undefined {
|
|
207
|
+
let fallback: Task | undefined
|
|
208
|
+
|
|
209
|
+
for (const task of this.tasks.values()) {
|
|
210
|
+
if (task.status !== 'pending') continue
|
|
211
|
+
if (!task.assignee) return task
|
|
212
|
+
if (!fallback) fallback = task
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return fallback
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Returns a snapshot array of all tasks (any status). */
|
|
219
|
+
list(): Task[] {
|
|
220
|
+
return Array.from(this.tasks.values())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Returns all tasks whose `status` matches `status`. */
|
|
224
|
+
getByStatus(status: TaskStatus): Task[] {
|
|
225
|
+
return this.list().filter((t) => t.status === status)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Returns `true` when every task in the queue has reached a terminal state
|
|
230
|
+
* (`'completed'` or `'failed'`), **or** the queue is empty.
|
|
231
|
+
*/
|
|
232
|
+
isComplete(): boolean {
|
|
233
|
+
for (const task of this.tasks.values()) {
|
|
234
|
+
if (task.status !== 'completed' && task.status !== 'failed') return false
|
|
235
|
+
}
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Returns a progress snapshot.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const { completed, total } = queue.getProgress()
|
|
245
|
+
* console.log(`${completed}/${total} tasks done`)
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
getProgress(): {
|
|
249
|
+
total: number
|
|
250
|
+
completed: number
|
|
251
|
+
failed: number
|
|
252
|
+
inProgress: number
|
|
253
|
+
pending: number
|
|
254
|
+
blocked: number
|
|
255
|
+
} {
|
|
256
|
+
let completed = 0
|
|
257
|
+
let failed = 0
|
|
258
|
+
let inProgress = 0
|
|
259
|
+
let pending = 0
|
|
260
|
+
let blocked = 0
|
|
261
|
+
|
|
262
|
+
for (const task of this.tasks.values()) {
|
|
263
|
+
switch (task.status) {
|
|
264
|
+
case 'completed':
|
|
265
|
+
completed++
|
|
266
|
+
break
|
|
267
|
+
case 'failed':
|
|
268
|
+
failed++
|
|
269
|
+
break
|
|
270
|
+
case 'in_progress':
|
|
271
|
+
inProgress++
|
|
272
|
+
break
|
|
273
|
+
case 'pending':
|
|
274
|
+
pending++
|
|
275
|
+
break
|
|
276
|
+
case 'blocked':
|
|
277
|
+
blocked++
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
total: this.tasks.size,
|
|
284
|
+
completed,
|
|
285
|
+
failed,
|
|
286
|
+
inProgress,
|
|
287
|
+
pending,
|
|
288
|
+
blocked,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Events
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Subscribes to a queue event.
|
|
298
|
+
*
|
|
299
|
+
* @returns An unsubscribe function. Calling it is idempotent.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```ts
|
|
303
|
+
* const off = queue.on('task:ready', (task) => execute(task))
|
|
304
|
+
* // later…
|
|
305
|
+
* off()
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
on<E extends TaskQueueEvent>(
|
|
309
|
+
event: E,
|
|
310
|
+
handler: HandlerFor<E>,
|
|
311
|
+
): () => void {
|
|
312
|
+
let map = this.listeners.get(event)
|
|
313
|
+
if (!map) {
|
|
314
|
+
map = new Map()
|
|
315
|
+
this.listeners.set(event, map)
|
|
316
|
+
}
|
|
317
|
+
const id = Symbol()
|
|
318
|
+
map.set(id, handler as TaskHandler | AllCompleteHandler)
|
|
319
|
+
return () => {
|
|
320
|
+
map!.delete(id)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Private helpers
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Evaluates whether `task` should start as `'blocked'` based on the tasks
|
|
330
|
+
* already registered in the queue.
|
|
331
|
+
*/
|
|
332
|
+
private resolveInitialStatus(task: Task): Task {
|
|
333
|
+
if (!task.dependsOn || task.dependsOn.length === 0) return task
|
|
334
|
+
|
|
335
|
+
const allCurrent = Array.from(this.tasks.values())
|
|
336
|
+
const ready = isTaskReady(task, allCurrent)
|
|
337
|
+
if (ready) return task
|
|
338
|
+
|
|
339
|
+
return { ...task, status: 'blocked', updatedAt: new Date() }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* After a task completes, scan all `'blocked'` tasks and promote any that are
|
|
344
|
+
* now fully satisfied to `'pending'`, firing `'task:ready'` for each.
|
|
345
|
+
*
|
|
346
|
+
* The task array and lookup map are built once for the entire scan to keep
|
|
347
|
+
* the operation O(n) rather than O(n²).
|
|
348
|
+
*/
|
|
349
|
+
private unblockDependents(completedId: string): void {
|
|
350
|
+
const allTasks = Array.from(this.tasks.values())
|
|
351
|
+
const taskById = new Map<string, Task>(allTasks.map((t) => [t.id, t]))
|
|
352
|
+
|
|
353
|
+
for (const task of allTasks) {
|
|
354
|
+
if (task.status !== 'blocked') continue
|
|
355
|
+
if (!task.dependsOn?.includes(completedId)) continue
|
|
356
|
+
|
|
357
|
+
// Re-check against the current state of the whole task set.
|
|
358
|
+
// Pass the pre-built map to avoid rebuilding it for every candidate task.
|
|
359
|
+
if (isTaskReady(task, allTasks, taskById)) {
|
|
360
|
+
const unblocked: Task = {
|
|
361
|
+
...task,
|
|
362
|
+
status: 'pending',
|
|
363
|
+
updatedAt: new Date(),
|
|
364
|
+
}
|
|
365
|
+
this.tasks.set(task.id, unblocked)
|
|
366
|
+
// Update the map so subsequent iterations in the same call see the new status.
|
|
367
|
+
taskById.set(task.id, unblocked)
|
|
368
|
+
this.emit('task:ready', unblocked)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private emit(event: 'task:ready' | 'task:complete' | 'task:failed', task: Task): void {
|
|
374
|
+
const map = this.listeners.get(event)
|
|
375
|
+
if (!map) return
|
|
376
|
+
for (const handler of map.values()) {
|
|
377
|
+
;(handler as TaskHandler)(task)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private emitAllComplete(): void {
|
|
382
|
+
const map = this.listeners.get('all:complete')
|
|
383
|
+
if (!map) return
|
|
384
|
+
for (const handler of map.values()) {
|
|
385
|
+
;(handler as AllCompleteHandler)()
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private requireTask(taskId: string): Task {
|
|
390
|
+
const task = this.tasks.get(taskId)
|
|
391
|
+
if (!task) throw new Error(`TaskQueue: task "${taskId}" not found.`)
|
|
392
|
+
return task
|
|
393
|
+
}
|
|
394
|
+
}
|
package/src/task/task.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure task utility functions.
|
|
3
|
+
*
|
|
4
|
+
* These helpers operate on plain {@link Task} values without any mutable
|
|
5
|
+
* state, making them safe to use in reducers, tests, and reactive pipelines.
|
|
6
|
+
* Stateful orchestration belongs in {@link TaskQueue}.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Task, TaskStatus } from '../types.js'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Factory
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new {@link Task} with a generated UUID, `'pending'` status, and
|
|
17
|
+
* `createdAt`/`updatedAt` timestamps set to the current instant.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const task = createTask({
|
|
22
|
+
* title: 'Research competitors',
|
|
23
|
+
* description: 'Identify the top 5 competitors and their pricing',
|
|
24
|
+
* assignee: 'researcher',
|
|
25
|
+
* })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function createTask(input: {
|
|
29
|
+
title: string
|
|
30
|
+
description: string
|
|
31
|
+
assignee?: string
|
|
32
|
+
dependsOn?: string[]
|
|
33
|
+
}): Task {
|
|
34
|
+
const now = new Date()
|
|
35
|
+
return {
|
|
36
|
+
id: crypto.randomUUID(),
|
|
37
|
+
title: input.title,
|
|
38
|
+
description: input.description,
|
|
39
|
+
status: 'pending' as TaskStatus,
|
|
40
|
+
assignee: input.assignee,
|
|
41
|
+
dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
|
|
42
|
+
result: undefined,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Readiness
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns `true` when `task` can be started immediately.
|
|
54
|
+
*
|
|
55
|
+
* A task is considered ready when:
|
|
56
|
+
* 1. Its status is `'pending'`.
|
|
57
|
+
* 2. Every task listed in `task.dependsOn` has status `'completed'`.
|
|
58
|
+
*
|
|
59
|
+
* Tasks whose dependencies are missing from `allTasks` are treated as
|
|
60
|
+
* unresolvable and therefore **not** ready.
|
|
61
|
+
*
|
|
62
|
+
* @param task - The task to evaluate.
|
|
63
|
+
* @param allTasks - The full collection of tasks in the current queue/plan.
|
|
64
|
+
* @param taskById - Optional pre-built id→task map. When provided the function
|
|
65
|
+
* skips rebuilding the map, reducing the complexity of
|
|
66
|
+
* call-sites that invoke `isTaskReady` inside a loop from
|
|
67
|
+
* O(n²) to O(n).
|
|
68
|
+
*/
|
|
69
|
+
export function isTaskReady(
|
|
70
|
+
task: Task,
|
|
71
|
+
allTasks: Task[],
|
|
72
|
+
taskById?: Map<string, Task>,
|
|
73
|
+
): boolean {
|
|
74
|
+
if (task.status !== 'pending') return false
|
|
75
|
+
if (!task.dependsOn || task.dependsOn.length === 0) return true
|
|
76
|
+
|
|
77
|
+
const map = taskById ?? new Map<string, Task>(allTasks.map((t) => [t.id, t]))
|
|
78
|
+
|
|
79
|
+
for (const depId of task.dependsOn) {
|
|
80
|
+
const dep = map.get(depId)
|
|
81
|
+
if (!dep || dep.status !== 'completed') return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Topological sort
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns `tasks` sorted so that each task appears after all of its
|
|
93
|
+
* dependencies — a standard topological (Kahn's algorithm) ordering.
|
|
94
|
+
*
|
|
95
|
+
* Tasks with no dependencies come first. If the graph contains a cycle the
|
|
96
|
+
* function returns a partial result containing only the tasks that could be
|
|
97
|
+
* ordered; use {@link validateTaskDependencies} to detect cycles before calling
|
|
98
|
+
* this function in production paths.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const ordered = getTaskDependencyOrder(tasks)
|
|
103
|
+
* for (const task of ordered) {
|
|
104
|
+
* await run(task)
|
|
105
|
+
* }
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function getTaskDependencyOrder(tasks: Task[]): Task[] {
|
|
109
|
+
if (tasks.length === 0) return []
|
|
110
|
+
|
|
111
|
+
const taskById = new Map<string, Task>(tasks.map((t) => [t.id, t]))
|
|
112
|
+
|
|
113
|
+
// Build adjacency: dependsOn edges become "predecessors" for in-degree count.
|
|
114
|
+
const inDegree = new Map<string, number>()
|
|
115
|
+
// successors[id] = list of task IDs that depend on `id`
|
|
116
|
+
const successors = new Map<string, string[]>()
|
|
117
|
+
|
|
118
|
+
for (const task of tasks) {
|
|
119
|
+
if (!inDegree.has(task.id)) inDegree.set(task.id, 0)
|
|
120
|
+
if (!successors.has(task.id)) successors.set(task.id, [])
|
|
121
|
+
|
|
122
|
+
for (const depId of task.dependsOn ?? []) {
|
|
123
|
+
// Only count dependencies that exist in this task set.
|
|
124
|
+
if (taskById.has(depId)) {
|
|
125
|
+
inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1)
|
|
126
|
+
const deps = successors.get(depId) ?? []
|
|
127
|
+
deps.push(task.id)
|
|
128
|
+
successors.set(depId, deps)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Kahn's algorithm: start with all nodes of in-degree 0.
|
|
134
|
+
const queue: string[] = []
|
|
135
|
+
for (const [id, degree] of inDegree) {
|
|
136
|
+
if (degree === 0) queue.push(id)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ordered: Task[] = []
|
|
140
|
+
while (queue.length > 0) {
|
|
141
|
+
const id = queue.shift()!
|
|
142
|
+
const task = taskById.get(id)
|
|
143
|
+
if (task) ordered.push(task)
|
|
144
|
+
|
|
145
|
+
for (const successorId of successors.get(id) ?? []) {
|
|
146
|
+
const newDegree = (inDegree.get(successorId) ?? 0) - 1
|
|
147
|
+
inDegree.set(successorId, newDegree)
|
|
148
|
+
if (newDegree === 0) queue.push(successorId)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return ordered
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Validation
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validates the dependency graph of a task collection.
|
|
161
|
+
*
|
|
162
|
+
* Checks for:
|
|
163
|
+
* - References to unknown task IDs in `dependsOn`.
|
|
164
|
+
* - Cycles (a task depending on itself, directly or transitively).
|
|
165
|
+
* - Self-dependencies (`task.dependsOn` includes its own `id`).
|
|
166
|
+
*
|
|
167
|
+
* @returns An object with `valid: true` when no issues were found, or
|
|
168
|
+
* `valid: false` with a non-empty `errors` array describing each
|
|
169
|
+
* problem.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* const { valid, errors } = validateTaskDependencies(tasks)
|
|
174
|
+
* if (!valid) throw new Error(errors.join('\n'))
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function validateTaskDependencies(tasks: Task[]): {
|
|
178
|
+
valid: boolean
|
|
179
|
+
errors: string[]
|
|
180
|
+
} {
|
|
181
|
+
const errors: string[] = []
|
|
182
|
+
const taskById = new Map<string, Task>(tasks.map((t) => [t.id, t]))
|
|
183
|
+
|
|
184
|
+
// Pass 1: check for unknown references and self-dependencies.
|
|
185
|
+
for (const task of tasks) {
|
|
186
|
+
for (const depId of task.dependsOn ?? []) {
|
|
187
|
+
if (depId === task.id) {
|
|
188
|
+
errors.push(
|
|
189
|
+
`Task "${task.title}" (${task.id}) depends on itself.`,
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (!taskById.has(depId)) {
|
|
194
|
+
errors.push(
|
|
195
|
+
`Task "${task.title}" (${task.id}) references unknown dependency "${depId}".`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Pass 2: cycle detection via DFS colouring (white=0, grey=1, black=2).
|
|
202
|
+
const colour = new Map<string, 0 | 1 | 2>()
|
|
203
|
+
for (const task of tasks) colour.set(task.id, 0)
|
|
204
|
+
|
|
205
|
+
const visit = (id: string, path: string[]): void => {
|
|
206
|
+
if (colour.get(id) === 2) return // Already fully explored.
|
|
207
|
+
if (colour.get(id) === 1) {
|
|
208
|
+
// Found a back-edge — cycle.
|
|
209
|
+
const cycleStart = path.indexOf(id)
|
|
210
|
+
const cycle = path.slice(cycleStart).concat(id)
|
|
211
|
+
errors.push(`Cyclic dependency detected: ${cycle.join(' -> ')}`)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
colour.set(id, 1)
|
|
216
|
+
const task = taskById.get(id)
|
|
217
|
+
for (const depId of task?.dependsOn ?? []) {
|
|
218
|
+
if (taskById.has(depId)) {
|
|
219
|
+
visit(depId, [...path, id])
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
colour.set(id, 2)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const task of tasks) {
|
|
226
|
+
if (colour.get(task.id) === 0) {
|
|
227
|
+
visit(task.id, [])
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { valid: errors.length === 0, errors }
|
|
232
|
+
}
|