@pify/swarm 0.8.0 → 0.9.1
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/README.md +20 -2
- package/extensions/swarm.ts +64 -30
- package/package.json +1 -1
- package/src/consent.ts +26 -0
- package/src/graph.ts +89 -0
- package/src/schedule.ts +60 -0
- package/src/types.ts +4 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The catch is that "independent" is usually a small lie — the items do not depe
|
|
|
18
18
|
|
|
19
19
|
| Parameter | Type | Notes |
|
|
20
20
|
|---|---|---|
|
|
21
|
-
| `items` |
|
|
21
|
+
| `items` | array, 1–12 | A plain string per task, or `{task, id, needs}` to declare a dependency |
|
|
22
22
|
| `context` | string, optional | Prepended to every item, so shared constraints are written once |
|
|
23
23
|
| `agent` | string, optional | Force one agent type for all items instead of routing |
|
|
24
24
|
| `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
|
|
@@ -27,6 +27,24 @@ The catch is that "independent" is usually a small lie — the items do not depe
|
|
|
27
27
|
|
|
28
28
|
Blocking by default: returns `N done, M error` plus a per-item report.
|
|
29
29
|
|
|
30
|
+
### Dependencies: `needs`
|
|
31
|
+
|
|
32
|
+
An item can be a plain string (independent, as before) or an object that declares what it depends on:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"items": [
|
|
37
|
+
{ "id": "mig", "task": "write the DB migration for the users table" },
|
|
38
|
+
{ "id": "callers", "task": "update every caller of the old schema", "needs": ["mig"] },
|
|
39
|
+
{ "id": "tests", "task": "run the suite and fix what broke", "needs": ["callers"] }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
An item starts only once its `needs` have finished, and each upstream item's output is prepended to it as a `## Output of <id>` block — so the thing the coordinator used to forget, passing X to the step that needs it, happens by construction. Independent items still run in parallel up to the concurrency cap; a chain runs in order; a diamond joins after both branches. Ids default to `t1`, `t2`, … when you omit them.
|
|
45
|
+
|
|
46
|
+
The whole graph is checked **before anything spawns**: a cycle, a self-edge, a duplicate id, or a reference to an unknown id is rejected outright, so a bad graph costs nothing. A flat list of strings has no edges and behaves exactly as it always did.
|
|
47
|
+
|
|
30
48
|
### `swarm_status`
|
|
31
49
|
|
|
32
50
|
| Parameter | Type | Notes |
|
|
@@ -77,7 +95,7 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
|
|
|
77
95
|
|
|
78
96
|
## Where this sits in the suite
|
|
79
97
|
|
|
80
|
-
[`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many
|
|
98
|
+
[`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many items at once — independent, or wired together with a declarative `needs` graph (fan-out, chains, joins). [`@pify/workflow`](https://github.com/pifydev/workflow) is for when orchestration needs real control flow — loops, conditionals, retries, fan-out computed at run time — that a static graph can't express. Pick the smallest one that fits.
|
|
81
99
|
|
|
82
100
|
## License
|
|
83
101
|
|
package/extensions/swarm.ts
CHANGED
|
@@ -32,8 +32,8 @@ import {
|
|
|
32
32
|
decideConsent,
|
|
33
33
|
envConsent,
|
|
34
34
|
parseConsent,
|
|
35
|
+
persistConsent,
|
|
35
36
|
readConsent,
|
|
36
|
-
writeConsent,
|
|
37
37
|
} from "../src/consent.ts";
|
|
38
38
|
import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
|
|
39
39
|
import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
|
|
@@ -42,6 +42,8 @@ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "
|
|
|
42
42
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
43
43
|
import { buildReport, buildStatusLine } from "../src/report.ts";
|
|
44
44
|
import { routeItem } from "../src/routing.ts";
|
|
45
|
+
import { normalizeItems } from "../src/graph.ts";
|
|
46
|
+
import { runGraph } from "../src/schedule.ts";
|
|
45
47
|
import { buildWidgetLines } from "../src/widget.ts";
|
|
46
48
|
import {
|
|
47
49
|
DEFAULT_CONCURRENCY,
|
|
@@ -328,7 +330,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
328
330
|
}
|
|
329
331
|
}
|
|
330
332
|
|
|
331
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* Readiness scheduler: run each item as soon as its `needs` are done, up to
|
|
335
|
+
* DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
|
|
336
|
+
* item ready immediately, so this is identical to the old parallel pool.
|
|
337
|
+
*/
|
|
332
338
|
async function executeRun(
|
|
333
339
|
ctx: UiContext,
|
|
334
340
|
run: SwarmRun,
|
|
@@ -339,25 +345,33 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
339
345
|
): Promise<void> {
|
|
340
346
|
// One shared log per run; only created when the caller asked for it.
|
|
341
347
|
const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
348
|
+
|
|
349
|
+
// A dependent structurally receives each upstream's output — the thing a
|
|
350
|
+
// hand-sequenced coordinator forgets. Prepended to the shared preamble.
|
|
351
|
+
const contextFor = (upstream: ItemState[]): string => {
|
|
352
|
+
if (upstream.length === 0) return context;
|
|
353
|
+
const blocks = upstream.map((up) => {
|
|
354
|
+
const body = up.result ?? (up.error ? `(failed: ${up.error})` : "(no output)");
|
|
355
|
+
return `## Output of ${up.id}\n${body}`;
|
|
356
|
+
});
|
|
357
|
+
return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Run each item once its needs finish, up to the concurrency cap; a flat
|
|
361
|
+
// run (no needs) has everything ready at once, exactly like the old pool.
|
|
362
|
+
await runGraph(
|
|
363
|
+
run.items,
|
|
364
|
+
DEFAULT_CONCURRENCY,
|
|
365
|
+
async (item, upstream) => {
|
|
350
366
|
const def = routeItem(item.item, defs, fixed);
|
|
351
367
|
item.agent = def.name;
|
|
368
|
+
const itemContext = contextFor(upstream);
|
|
352
369
|
if (isolate) {
|
|
353
370
|
try {
|
|
354
371
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
355
|
-
await runItem(ctx, run.runId, def, item,
|
|
356
|
-
// Remove the worktree when the item changed nothing
|
|
357
|
-
//
|
|
358
|
-
// was imported and never called, leaking a worktree + branch per
|
|
359
|
-
// read-only item). Kept when there is work to merge, and only then
|
|
360
|
-
// is the merge note worth showing.
|
|
372
|
+
await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
|
|
373
|
+
// Remove the worktree when the item changed nothing (the leak
|
|
374
|
+
// removeIfUnchanged fixes); keep it when there is work to merge.
|
|
361
375
|
const removed = removeIfUnchanged(ctx.cwd, iso);
|
|
362
376
|
if (item.result !== null) {
|
|
363
377
|
item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
|
|
@@ -367,11 +381,12 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
367
381
|
item.error = err instanceof Error ? err.message : String(err);
|
|
368
382
|
}
|
|
369
383
|
} else {
|
|
370
|
-
await runItem(ctx, run.runId, def, item,
|
|
384
|
+
await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
|
|
371
385
|
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
|
|
386
|
+
},
|
|
387
|
+
() => run.status === "cancelled",
|
|
388
|
+
);
|
|
389
|
+
|
|
375
390
|
if (run.status !== "cancelled") run.status = "done";
|
|
376
391
|
run.finishedAt = Date.now();
|
|
377
392
|
pi.appendEntry(RUN_ENTRY, run);
|
|
@@ -411,9 +426,24 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
411
426
|
"Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
|
|
412
427
|
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
|
|
413
428
|
"mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
|
|
414
|
-
"conventions instead of silently conflicting."
|
|
429
|
+
"conventions instead of silently conflicting. " +
|
|
430
|
+
"An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
|
|
431
|
+
"dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
|
|
432
|
+
"starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs.",
|
|
415
433
|
parameters: Type.Object({
|
|
416
|
-
items: Type.Array(
|
|
434
|
+
items: Type.Array(
|
|
435
|
+
Type.Union([
|
|
436
|
+
Type.String({ description: "A self-contained task brief (independent item)" }),
|
|
437
|
+
Type.Object({
|
|
438
|
+
task: Type.String({ description: "A self-contained task brief" }),
|
|
439
|
+
id: Type.Optional(Type.String({ description: "Stable id other items can reference in needs (default t1, t2, …)" })),
|
|
440
|
+
needs: Type.Optional(
|
|
441
|
+
Type.Array(Type.String(), { description: "Ids of items that must finish first; their output is prepended to this item" }),
|
|
442
|
+
),
|
|
443
|
+
}),
|
|
444
|
+
]),
|
|
445
|
+
{ minItems: 1, maxItems: MAX_ITEMS },
|
|
446
|
+
),
|
|
417
447
|
context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
|
|
418
448
|
agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
|
|
419
449
|
isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
|
|
@@ -425,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
425
455
|
async execute(
|
|
426
456
|
_id,
|
|
427
457
|
params: {
|
|
428
|
-
items: string[]
|
|
458
|
+
items: Array<string | { task: string; id?: string; needs?: string[] }>;
|
|
429
459
|
context?: string;
|
|
430
460
|
agent?: string;
|
|
431
461
|
background?: boolean;
|
|
@@ -437,8 +467,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
437
467
|
ctx,
|
|
438
468
|
) {
|
|
439
469
|
const uiCtx = ctx as UiContext;
|
|
440
|
-
|
|
441
|
-
|
|
470
|
+
// Normalize strings/objects into graph nodes and reject a bad graph
|
|
471
|
+
// (cycle, self-edge, unknown or duplicate id) BEFORE spawning anything.
|
|
472
|
+
const { nodes, error } = normalizeItems(params.items ?? []);
|
|
473
|
+
if (error) throw new Error(`swarm_run: ${error}`);
|
|
474
|
+
if (nodes.length === 0) throw new Error("swarm_run requires at least one non-empty item.");
|
|
442
475
|
if (params.agent && !defs.has(params.agent.toLowerCase())) {
|
|
443
476
|
throw new Error(`Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`);
|
|
444
477
|
}
|
|
@@ -453,9 +486,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
453
486
|
status: "running",
|
|
454
487
|
startedAt: Date.now(),
|
|
455
488
|
finishedAt: null,
|
|
456
|
-
items:
|
|
489
|
+
items: nodes.map((node, index) => ({
|
|
457
490
|
index,
|
|
458
|
-
|
|
491
|
+
id: node.id,
|
|
492
|
+
item: node.task,
|
|
493
|
+
needs: node.needs,
|
|
459
494
|
agent: params.agent?.toLowerCase() ?? "?",
|
|
460
495
|
status: "queued",
|
|
461
496
|
turns: 0,
|
|
@@ -506,7 +541,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
506
541
|
});
|
|
507
542
|
return {
|
|
508
543
|
content: [
|
|
509
|
-
{ type: "text", text: `Swarm ${run.runId} started (${items.length} items). Poll swarm_status runId="${run.runId}".` },
|
|
544
|
+
{ type: "text", text: `Swarm ${run.runId} started (${run.items.length} items). Poll swarm_status runId="${run.runId}".` },
|
|
510
545
|
],
|
|
511
546
|
details: { runId: run.runId },
|
|
512
547
|
};
|
|
@@ -595,8 +630,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
595
630
|
),
|
|
596
631
|
);
|
|
597
632
|
try {
|
|
598
|
-
|
|
599
|
-
`);
|
|
633
|
+
persistConsent(file, ctx.cwd, "agents", approved);
|
|
600
634
|
} catch {
|
|
601
635
|
// An unwritable consent file costs us the memory of the answer, not the answer.
|
|
602
636
|
}
|
package/package.json
CHANGED
package/src/consent.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* around the first.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
|
|
19
21
|
/** What to do with a project-supplied file this session. */
|
|
20
22
|
export type ConsentVerdict = "allow" | "refuse" | "ask";
|
|
21
23
|
|
|
@@ -80,6 +82,30 @@ export function writeConsent(
|
|
|
80
82
|
return { ...file, [key]: { ...(file[key] ?? {}), [scope]: allowed } };
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Record a consent answer to disk without losing a concurrent writer's scope.
|
|
87
|
+
*
|
|
88
|
+
* Several @pify packages share this one file (keyed by cwd → {scope: bool}), and
|
|
89
|
+
* each asks the user with an `await` between reading the file and writing it
|
|
90
|
+
* back. If two flows both read the old file, then both write, the second write
|
|
91
|
+
* drops the scope the first added. So re-read and re-parse the file HERE, right
|
|
92
|
+
* before writing, with no `await` in between — the merge always starts from the
|
|
93
|
+
* freshest on-disk state, and within a single (single-threaded) process the
|
|
94
|
+
* read-modify-write can no longer interleave. Cross-process races remain
|
|
95
|
+
* theoretically possible but fail safe: the worst case is a re-prompt, never a
|
|
96
|
+
* silently-granted consent.
|
|
97
|
+
*/
|
|
98
|
+
export function persistConsent(file: string, cwd: string, scope: string, allowed: boolean): void {
|
|
99
|
+
let raw: string | null = null;
|
|
100
|
+
try {
|
|
101
|
+
raw = readFileSync(file, "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
raw = null;
|
|
104
|
+
}
|
|
105
|
+
const next = writeConsent(parseConsent(raw), cwd, scope, allowed);
|
|
106
|
+
writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
/** Tolerate anything on disk: a corrupt consent file means "never asked". */
|
|
84
110
|
export function parseConsent(raw: string | null): ConsentFile {
|
|
85
111
|
if (!raw) return {};
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a list of items — some depending on others — into a runnable graph.
|
|
3
|
+
*
|
|
4
|
+
* swarm's flat mode fans N independent items out in parallel. But real work is
|
|
5
|
+
* rarely that flat: "write the migration, THEN update the callers, THEN run the
|
|
6
|
+
* tests" is a chain, and "gather these three, then summarize" is a join. Left
|
|
7
|
+
* to the coordinator to sequence by hand, the dependency is a thing it can
|
|
8
|
+
* forget — and the classic failure is forgetting to pass the upstream's output
|
|
9
|
+
* to the downstream item at all.
|
|
10
|
+
*
|
|
11
|
+
* So an item may declare `needs: [id, …]`. This module is the pure half: it
|
|
12
|
+
* normalizes items (string or object) into nodes with stable ids, and rejects a
|
|
13
|
+
* bad graph — a self-edge, a reference to an unknown id, a duplicate id, or a
|
|
14
|
+
* cycle — BEFORE anything is spawned, because a bad graph should cost nothing.
|
|
15
|
+
* The scheduler (in the extension) then runs each item as soon as its needs are
|
|
16
|
+
* done and prepends every upstream's output to it.
|
|
17
|
+
*
|
|
18
|
+
* A flat list (no `needs` anywhere) validates trivially and every node is ready
|
|
19
|
+
* at once — identical to the old parallel fan-out. Zero dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface RawItem {
|
|
23
|
+
task: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
needs?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphNode {
|
|
29
|
+
id: string;
|
|
30
|
+
task: string;
|
|
31
|
+
needs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NormalizeResult {
|
|
35
|
+
nodes: GraphNode[];
|
|
36
|
+
error: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accept a plain string or an object; give every item a stable id and clean needs. */
|
|
40
|
+
export function normalizeItems(items: Array<string | RawItem>): NormalizeResult {
|
|
41
|
+
const nodes: GraphNode[] = [];
|
|
42
|
+
for (let i = 0; i < items.length; i++) {
|
|
43
|
+
const raw = items[i]!;
|
|
44
|
+
const isString = typeof raw === "string";
|
|
45
|
+
const task = (isString ? raw : raw.task ?? "").trim();
|
|
46
|
+
if (!task) return { nodes: [], error: `item ${i + 1} has no task text` };
|
|
47
|
+
const id = !isString && typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `t${i + 1}`;
|
|
48
|
+
const needs =
|
|
49
|
+
!isString && Array.isArray(raw.needs)
|
|
50
|
+
? raw.needs.filter((n): n is string => typeof n === "string" && n.trim() !== "").map((n) => n.trim())
|
|
51
|
+
: [];
|
|
52
|
+
nodes.push({ id, task, needs });
|
|
53
|
+
}
|
|
54
|
+
const error = validateGraph(nodes);
|
|
55
|
+
return { nodes, error };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reject a self-edge, an unknown reference, a duplicate id, or a cycle. */
|
|
59
|
+
export function validateGraph(nodes: GraphNode[]): string | null {
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
for (const n of nodes) {
|
|
62
|
+
if (ids.has(n.id)) return `duplicate item id "${n.id}"`;
|
|
63
|
+
ids.add(n.id);
|
|
64
|
+
}
|
|
65
|
+
for (const n of nodes) {
|
|
66
|
+
for (const dep of n.needs) {
|
|
67
|
+
if (dep === n.id) return `item "${n.id}" cannot depend on itself`;
|
|
68
|
+
if (!ids.has(dep)) return `item "${n.id}" needs unknown item "${dep}"`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
72
|
+
const state = new Map<string, 0 | 1 | 2>(); // 0 unseen, 1 on stack, 2 done
|
|
73
|
+
const hasCycle = (id: string): boolean => {
|
|
74
|
+
const s = state.get(id) ?? 0;
|
|
75
|
+
if (s === 1) return true;
|
|
76
|
+
if (s === 2) return false;
|
|
77
|
+
state.set(id, 1);
|
|
78
|
+
for (const dep of byId.get(id)!.needs) if (hasCycle(dep)) return true;
|
|
79
|
+
state.set(id, 2);
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
for (const n of nodes) if (hasCycle(n.id)) return `dependency cycle involving "${n.id}"`;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Does any node declare a dependency? If not, the run is a plain flat fan-out. */
|
|
87
|
+
export function hasEdges(nodes: GraphNode[]): boolean {
|
|
88
|
+
return nodes.some((n) => n.needs.length > 0);
|
|
89
|
+
}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readiness scheduler, pure and testable.
|
|
3
|
+
*
|
|
4
|
+
* Given nodes that may depend on one another, run each one as soon as its
|
|
5
|
+
* `needs` have finished, never more than `concurrency` at a time. The caller's
|
|
6
|
+
* `run(node, upstream)` does the actual work and is handed the finished
|
|
7
|
+
* upstream nodes (in `needs` order) so it can thread their output into the
|
|
8
|
+
* dependent. A node whose run rejects still counts as done, so a failure
|
|
9
|
+
* unblocks its dependents rather than wedging the whole graph. `cancelled()` is
|
|
10
|
+
* polled between launches to stop taking on new work.
|
|
11
|
+
*
|
|
12
|
+
* A flat set (no `needs`) makes every node ready at once, so this collapses to
|
|
13
|
+
* a plain concurrency-capped fan-out. Zero dependencies; the graph itself must
|
|
14
|
+
* already be validated (see graph.ts) — this assumes no cycle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Schedulable {
|
|
18
|
+
id: string;
|
|
19
|
+
needs: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runGraph<T extends Schedulable>(
|
|
23
|
+
nodes: readonly T[],
|
|
24
|
+
concurrency: number,
|
|
25
|
+
run: (node: T, upstream: T[]) => void | Promise<void>,
|
|
26
|
+
cancelled: () => boolean = () => false,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
29
|
+
const done = new Set<string>();
|
|
30
|
+
const started = new Set<string>();
|
|
31
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
32
|
+
const cap = Math.max(1, concurrency);
|
|
33
|
+
|
|
34
|
+
const isReady = (n: T) => !started.has(n.id) && n.needs.every((d) => done.has(d));
|
|
35
|
+
|
|
36
|
+
while (!cancelled()) {
|
|
37
|
+
for (const n of nodes) {
|
|
38
|
+
if (inFlight.size >= cap) break;
|
|
39
|
+
if (!isReady(n)) continue;
|
|
40
|
+
started.add(n.id);
|
|
41
|
+
const upstream = n.needs.map((id) => byId.get(id)).filter((x): x is T => x !== undefined);
|
|
42
|
+
const p = Promise.resolve()
|
|
43
|
+
.then(() => run(n, upstream))
|
|
44
|
+
.catch(() => {
|
|
45
|
+
// The caller records its own failure; here a rejected node must still
|
|
46
|
+
// settle so dependents unblock instead of the graph hanging.
|
|
47
|
+
})
|
|
48
|
+
.then(() => {
|
|
49
|
+
done.add(n.id);
|
|
50
|
+
inFlight.delete(n.id);
|
|
51
|
+
});
|
|
52
|
+
inFlight.set(n.id, p);
|
|
53
|
+
}
|
|
54
|
+
if (inFlight.size === 0) break; // nothing running and nothing newly ready
|
|
55
|
+
await Promise.race(inFlight.values());
|
|
56
|
+
}
|
|
57
|
+
// A cancel can leave work in flight; let it settle so the caller's record is
|
|
58
|
+
// complete rather than half-written.
|
|
59
|
+
await Promise.all(inFlight.values());
|
|
60
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -52,7 +52,11 @@ export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted";
|
|
|
52
52
|
|
|
53
53
|
export interface ItemState {
|
|
54
54
|
index: number;
|
|
55
|
+
/** Stable id used for dependency references, e.g. "t1" or a caller-given id. */
|
|
56
|
+
id: string;
|
|
55
57
|
item: string;
|
|
58
|
+
/** Ids of items that must finish before this one starts (empty = flat). */
|
|
59
|
+
needs: string[];
|
|
56
60
|
agent: string;
|
|
57
61
|
status: ItemStatus;
|
|
58
62
|
turns: number;
|