@kolisachint/hooteams-dag 0.1.14
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/CHANGELOG.md +7 -0
- package/package.json +16 -0
- package/src/dag.ts +228 -0
- package/src/index.ts +3 -0
- package/src/types.ts +29 -0
- package/test/dag.test.ts +224 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.14] - 2026-06-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- New package: the `TaskDag` — topological order (Kahn), ready/blocked tracking, retry/rework helpers, JSON persistence and crash-recovery (`toJSON`/`fromJSON`/`resetTransient`) — extracted from `@kolisachint/hooteams-orchestrator` into this dependency-free foundation layer (its only import is the `AgentMessage` type from `@kolisachint/hoocode-agent-core`). Re-exported from the orchestrator barrel, so existing consumers are unaffected.
|
|
7
|
+
- Immutable node snapshots: accessors (`get`/`all`/`ready`/`blocked`/`resetToIdle`/`add`) return frozen copies with their `deps`/`results` arrays sliced, so callers can never mutate internal node state through a live reference (a write throws in strict mode, and array splices don't leak back). All node mutation goes through the dag's own methods, including the new `setOutput(id, output)` and `incrementAttempts(id)`. Optional fields are stored canonically — never as explicit `undefined` keys — so a node's serialized shape is stable across `toJSON`/`fromJSON` round trips (`add` omits an unset `retries`; `setOutput(undefined)`/`markDone()`/`resetToIdle` remove `output`/`results` rather than blanking them). `ready`/`blocked`/`isComplete` read internal state directly instead of snapshotting the whole graph on every call.
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kolisachint/hooteams-dag",
|
|
3
|
+
"version": "0.1.14",
|
|
4
|
+
"description": "Dependency-free task DAG for hooteams: topological order, ready/blocked tracking, immutable node snapshots, JSON persistence",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "bun test"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@kolisachint/hoocode-agent-core": "^0.4.49"
|
|
13
|
+
},
|
|
14
|
+
"author": "Sachin Koli",
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|
package/src/dag.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { AgentMessage, AgentStatus, TaskNode } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface TaskNodeInput {
|
|
4
|
+
id: string;
|
|
5
|
+
role: string;
|
|
6
|
+
deps?: string[];
|
|
7
|
+
/** Extra dispatch attempts the node gets after a failed run. Default 0. */
|
|
8
|
+
retries?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dependency graph of team tasks. Nodes start "idle", become dispatchable when
|
|
13
|
+
* every dependency is "done", and markDone() reports which nodes that
|
|
14
|
+
* completion unblocked so the orchestrator can dispatch them next.
|
|
15
|
+
*/
|
|
16
|
+
export class TaskDag {
|
|
17
|
+
private readonly nodes = new Map<string, TaskNode>();
|
|
18
|
+
|
|
19
|
+
add(input: TaskNodeInput): TaskNode {
|
|
20
|
+
if (this.nodes.has(input.id)) {
|
|
21
|
+
throw new Error(`Task "${input.id}" already exists`);
|
|
22
|
+
}
|
|
23
|
+
const node: TaskNode = { id: input.id, role: input.role, deps: input.deps?.slice() ?? [], status: "idle" };
|
|
24
|
+
if (input.retries !== undefined) node.retries = input.retries;
|
|
25
|
+
this.nodes.set(input.id, node);
|
|
26
|
+
return this.snapshot(node);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(id: string): TaskNode | undefined {
|
|
30
|
+
const node = this.nodes.get(id);
|
|
31
|
+
return node ? this.snapshot(node) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
all(): TaskNode[] {
|
|
35
|
+
return [...this.nodes.values()].map((node) => this.snapshot(node));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Frozen copy handed to external callers, so the dag's internal node state is
|
|
40
|
+
* never held as a live mutable reference: writing to a returned node throws
|
|
41
|
+
* (strict mode), and its `deps`/`results` arrays are sliced so they can't be
|
|
42
|
+
* spliced into either. (The AgentMessage objects inside `results` are shared
|
|
43
|
+
* by reference — they are treated as immutable agent output.) All mutation
|
|
44
|
+
* goes through the dag's own methods (markRunning, markDone, setOutput,
|
|
45
|
+
* incrementAttempts, …).
|
|
46
|
+
*/
|
|
47
|
+
private snapshot(node: TaskNode): TaskNode {
|
|
48
|
+
const copy: TaskNode = { ...node, deps: node.deps.slice() };
|
|
49
|
+
if (node.results) copy.results = node.results.slice();
|
|
50
|
+
return Object.freeze(copy);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a settled node's final assistant text, injected into dependents'
|
|
55
|
+
* prompts. Clearing it (undefined) removes the field rather than storing an
|
|
56
|
+
* explicit `undefined`, keeping the node's serialized shape stable.
|
|
57
|
+
*/
|
|
58
|
+
setOutput(id: string, output: string | undefined): void {
|
|
59
|
+
const node = this.require(id);
|
|
60
|
+
if (output === undefined) delete node.output;
|
|
61
|
+
else node.output = output;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Count one more failed/reworked attempt against a node and return the new total. */
|
|
65
|
+
incrementAttempts(id: string): number {
|
|
66
|
+
const node = this.require(id);
|
|
67
|
+
node.attempts = (node.attempts ?? 0) + 1;
|
|
68
|
+
return node.attempts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Kahn's algorithm. Throws on unknown deps or cycles. */
|
|
72
|
+
topologicalOrder(): string[] {
|
|
73
|
+
const inDegree = new Map<string, number>();
|
|
74
|
+
const dependents = new Map<string, string[]>();
|
|
75
|
+
for (const node of this.nodes.values()) {
|
|
76
|
+
inDegree.set(node.id, node.deps.length);
|
|
77
|
+
for (const dep of node.deps) {
|
|
78
|
+
if (!this.nodes.has(dep)) {
|
|
79
|
+
throw new Error(`Task "${node.id}" depends on unknown task "${dep}"`);
|
|
80
|
+
}
|
|
81
|
+
const list = dependents.get(dep) ?? [];
|
|
82
|
+
list.push(node.id);
|
|
83
|
+
dependents.set(dep, list);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const queue = [...inDegree.entries()].filter(([, degree]) => degree === 0).map(([id]) => id);
|
|
87
|
+
const order: string[] = [];
|
|
88
|
+
while (queue.length > 0) {
|
|
89
|
+
const id = queue.shift()!;
|
|
90
|
+
order.push(id);
|
|
91
|
+
for (const dependent of dependents.get(id) ?? []) {
|
|
92
|
+
const remaining = inDegree.get(dependent)! - 1;
|
|
93
|
+
inDegree.set(dependent, remaining);
|
|
94
|
+
if (remaining === 0) queue.push(dependent);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (order.length !== this.nodes.size) {
|
|
98
|
+
const stuck = [...inDegree.entries()]
|
|
99
|
+
.filter(([, degree]) => degree > 0)
|
|
100
|
+
.map(([id]) => id)
|
|
101
|
+
.join(", ");
|
|
102
|
+
throw new Error(`Task graph has a cycle involving: ${stuck}`);
|
|
103
|
+
}
|
|
104
|
+
return order;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Nodes that are idle and whose dependencies are all done. */
|
|
108
|
+
ready(): TaskNode[] {
|
|
109
|
+
return [...this.nodes.values()]
|
|
110
|
+
.filter((node) => node.status === "idle" && node.deps.every((dep) => this.nodes.get(dep)?.status === "done"))
|
|
111
|
+
.map((node) => this.snapshot(node));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Mark a node as dispatched so ready() stops returning it. */
|
|
115
|
+
markRunning(id: string, status: AgentStatus = "streaming"): void {
|
|
116
|
+
const node = this.require(id);
|
|
117
|
+
node.status = status;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record a node's completion and the messages its agent produced.
|
|
122
|
+
* Returns the nodes this completion made ready, in insertion order.
|
|
123
|
+
*/
|
|
124
|
+
markDone(id: string, results?: AgentMessage[]): TaskNode[] {
|
|
125
|
+
const node = this.require(id);
|
|
126
|
+
const readyBefore = new Set(this.ready().map((other) => other.id));
|
|
127
|
+
node.status = "done";
|
|
128
|
+
if (results === undefined) delete node.results;
|
|
129
|
+
else node.results = results;
|
|
130
|
+
return this.ready().filter((other) => !readyBefore.has(other.id));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
markFailed(id: string): void {
|
|
134
|
+
this.require(id).status = "error";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mark a running node as waiting on a human approval. Paused nodes are
|
|
139
|
+
* neither ready nor complete, so the dag stays open until they resume
|
|
140
|
+
* (markRunning) and eventually settle.
|
|
141
|
+
*/
|
|
142
|
+
markPaused(id: string): void {
|
|
143
|
+
this.require(id).status = "paused";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Send a settled node back to "idle" so ready() re-dispatches it, clearing
|
|
148
|
+
* the previous attempt's results and output (but not its attempt count).
|
|
149
|
+
* Used for retries and validator-triggered reruns.
|
|
150
|
+
*/
|
|
151
|
+
resetToIdle(id: string): TaskNode {
|
|
152
|
+
const node = this.require(id);
|
|
153
|
+
node.status = "idle";
|
|
154
|
+
delete node.results;
|
|
155
|
+
delete node.output;
|
|
156
|
+
return this.snapshot(node);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Nodes that can never run because a transitive dependency failed. */
|
|
160
|
+
blocked(): TaskNode[] {
|
|
161
|
+
const failed = new Set([...this.nodes.values()].filter((node) => node.status === "error").map((node) => node.id));
|
|
162
|
+
if (failed.size === 0) return [];
|
|
163
|
+
const blocked: TaskNode[] = [];
|
|
164
|
+
for (const id of this.topologicalOrder()) {
|
|
165
|
+
const node = this.nodes.get(id)!;
|
|
166
|
+
if (node.status === "error") continue;
|
|
167
|
+
if (node.deps.some((dep) => failed.has(dep))) {
|
|
168
|
+
failed.add(node.id);
|
|
169
|
+
blocked.push(this.snapshot(node));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return blocked;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when every node is done, failed, or permanently blocked. */
|
|
176
|
+
isComplete(): boolean {
|
|
177
|
+
const blockedIds = new Set(this.blocked().map((node) => node.id));
|
|
178
|
+
return [...this.nodes.values()].every(
|
|
179
|
+
(node) => node.status === "done" || node.status === "error" || blockedIds.has(node.id),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Plain-object snapshot of the graph keyed by node id, safe to
|
|
185
|
+
* JSON.stringify. The caller decides where to persist it.
|
|
186
|
+
*/
|
|
187
|
+
toJSON(): Record<string, TaskNode> {
|
|
188
|
+
const snapshot: Record<string, TaskNode> = {};
|
|
189
|
+
for (const [id, node] of this.nodes) {
|
|
190
|
+
snapshot[id] = { ...node, deps: node.deps.slice() };
|
|
191
|
+
}
|
|
192
|
+
return snapshot;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reset nodes caught mid-run (thinking/streaming/tool) back to idle so a
|
|
197
|
+
* restored dag re-dispatches them. Paused nodes are left paused — their
|
|
198
|
+
* approval gate is re-surfaced instead of re-running them. Returns the
|
|
199
|
+
* nodes that were reset.
|
|
200
|
+
*/
|
|
201
|
+
resetTransient(): TaskNode[] {
|
|
202
|
+
const reset: TaskNode[] = [];
|
|
203
|
+
for (const node of this.nodes.values()) {
|
|
204
|
+
if (node.status === "thinking" || node.status === "streaming" || node.status === "tool") {
|
|
205
|
+
node.status = "idle";
|
|
206
|
+
reset.push(this.snapshot(node));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return reset;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Rebuild a dag from a toJSON() snapshot, preserving statuses and results. */
|
|
213
|
+
static fromJSON(snapshot: Record<string, TaskNode>): TaskDag {
|
|
214
|
+
const dag = new TaskDag();
|
|
215
|
+
for (const node of Object.values(snapshot)) {
|
|
216
|
+
dag.nodes.set(node.id, { ...node, deps: node.deps?.slice() ?? [] });
|
|
217
|
+
}
|
|
218
|
+
return dag;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private require(id: string): TaskNode {
|
|
222
|
+
const node = this.nodes.get(id);
|
|
223
|
+
if (!node) {
|
|
224
|
+
throw new Error(`Unknown task "${id}"`);
|
|
225
|
+
}
|
|
226
|
+
return node;
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AgentMessage } from "@kolisachint/hoocode-agent-core";
|
|
2
|
+
|
|
3
|
+
export type { AgentMessage };
|
|
4
|
+
|
|
5
|
+
/** Coarse per-agent status derived from the event stream. */
|
|
6
|
+
export type AgentStatus = "idle" | "thinking" | "streaming" | "tool" | "done" | "error" | "paused";
|
|
7
|
+
|
|
8
|
+
/** One unit of work in the team DAG, executed by the agent registered under `role`. */
|
|
9
|
+
export interface TaskNode {
|
|
10
|
+
id: string;
|
|
11
|
+
role: string;
|
|
12
|
+
/** Ids of nodes that must reach "done" before this node becomes ready. */
|
|
13
|
+
deps: string[];
|
|
14
|
+
status: AgentStatus;
|
|
15
|
+
/** Messages produced by the node's run, recorded by markDone. */
|
|
16
|
+
results?: AgentMessage[];
|
|
17
|
+
/**
|
|
18
|
+
* Final assistant text of the node's run, recorded on completion and
|
|
19
|
+
* injected into the prompts of nodes that depend on this one.
|
|
20
|
+
*/
|
|
21
|
+
output?: string;
|
|
22
|
+
/** Extra dispatch attempts the node gets after a failed run. Default 0. */
|
|
23
|
+
retries?: number;
|
|
24
|
+
/** Failed attempts consumed so far; set by the orchestrator. */
|
|
25
|
+
attempts?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shape of TaskDag.toJSON(), as persisted in "dag_state" session entries. */
|
|
29
|
+
export type SerializedDag = Record<string, TaskNode>;
|
package/test/dag.test.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { TaskDag } from "../src/dag.js";
|
|
3
|
+
|
|
4
|
+
function diamond(): TaskDag {
|
|
5
|
+
// plan → (code, docs) → test
|
|
6
|
+
const dag = new TaskDag();
|
|
7
|
+
dag.add({ id: "plan", role: "planner" });
|
|
8
|
+
dag.add({ id: "code", role: "coder", deps: ["plan"] });
|
|
9
|
+
dag.add({ id: "docs", role: "writer", deps: ["plan"] });
|
|
10
|
+
dag.add({ id: "test", role: "tester", deps: ["code", "docs"] });
|
|
11
|
+
return dag;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("topologicalOrder", () => {
|
|
15
|
+
test("orders dependencies before dependents", () => {
|
|
16
|
+
const order = diamond().topologicalOrder();
|
|
17
|
+
expect(order.indexOf("plan")).toBeLessThan(order.indexOf("code"));
|
|
18
|
+
expect(order.indexOf("plan")).toBeLessThan(order.indexOf("docs"));
|
|
19
|
+
expect(order.indexOf("code")).toBeLessThan(order.indexOf("test"));
|
|
20
|
+
expect(order.indexOf("docs")).toBeLessThan(order.indexOf("test"));
|
|
21
|
+
expect(order).toHaveLength(4);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("throws on cycles", () => {
|
|
25
|
+
const dag = new TaskDag();
|
|
26
|
+
dag.add({ id: "a", role: "x", deps: ["b"] });
|
|
27
|
+
dag.add({ id: "b", role: "y", deps: ["a"] });
|
|
28
|
+
expect(() => dag.topologicalOrder()).toThrow(/cycle/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("throws on unknown dependency", () => {
|
|
32
|
+
const dag = new TaskDag();
|
|
33
|
+
dag.add({ id: "a", role: "x", deps: ["ghost"] });
|
|
34
|
+
expect(() => dag.topologicalOrder()).toThrow(/unknown task "ghost"/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects duplicate ids", () => {
|
|
38
|
+
const dag = new TaskDag();
|
|
39
|
+
dag.add({ id: "a", role: "x" });
|
|
40
|
+
expect(() => dag.add({ id: "a", role: "y" })).toThrow(/already exists/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("markDone propagation", () => {
|
|
45
|
+
test("only roots are ready initially", () => {
|
|
46
|
+
expect(diamond().ready().map((node) => node.id)).toEqual(["plan"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("completing a node returns newly ready nodes only", () => {
|
|
50
|
+
const dag = diamond();
|
|
51
|
+
const unblocked = dag.markDone("plan");
|
|
52
|
+
expect(unblocked.map((node) => node.id).sort()).toEqual(["code", "docs"]);
|
|
53
|
+
|
|
54
|
+
// test needs both code and docs — finishing just code unblocks nothing
|
|
55
|
+
expect(dag.markDone("code")).toEqual([]);
|
|
56
|
+
expect(dag.markDone("docs").map((node) => node.id)).toEqual(["test"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("running nodes are not reported ready", () => {
|
|
60
|
+
const dag = diamond();
|
|
61
|
+
dag.markDone("plan");
|
|
62
|
+
dag.markRunning("code");
|
|
63
|
+
expect(dag.ready().map((node) => node.id)).toEqual(["docs"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stores results on the node", () => {
|
|
67
|
+
const dag = diamond();
|
|
68
|
+
const messages = [{ role: "user" as const, content: "done", timestamp: Date.now() }];
|
|
69
|
+
dag.markDone("plan", messages);
|
|
70
|
+
expect(dag.get("plan")?.results).toEqual(messages);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("failed nodes block their descendants", () => {
|
|
74
|
+
const dag = diamond();
|
|
75
|
+
dag.markDone("plan");
|
|
76
|
+
dag.markFailed("code");
|
|
77
|
+
expect(dag.blocked().map((node) => node.id)).toEqual(["test"]);
|
|
78
|
+
// docs is unaffected and still ready
|
|
79
|
+
expect(dag.ready().map((node) => node.id)).toEqual(["docs"]);
|
|
80
|
+
expect(dag.isComplete()).toBe(false);
|
|
81
|
+
dag.markDone("docs");
|
|
82
|
+
expect(dag.isComplete()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("paused nodes", () => {
|
|
87
|
+
test("paused nodes are neither ready nor complete", () => {
|
|
88
|
+
const dag = diamond();
|
|
89
|
+
dag.markDone("plan");
|
|
90
|
+
dag.markRunning("code");
|
|
91
|
+
dag.markPaused("code");
|
|
92
|
+
expect(dag.get("code")?.status).toBe("paused");
|
|
93
|
+
expect(dag.ready().map((node) => node.id)).toEqual(["docs"]);
|
|
94
|
+
dag.markDone("docs");
|
|
95
|
+
// code is paused and test depends on it — the dag stays open
|
|
96
|
+
expect(dag.isComplete()).toBe(false);
|
|
97
|
+
dag.markRunning("code");
|
|
98
|
+
dag.markDone("code");
|
|
99
|
+
dag.markDone("test");
|
|
100
|
+
expect(dag.isComplete()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("paused status round-trips through toJSON/fromJSON", () => {
|
|
104
|
+
const dag = diamond();
|
|
105
|
+
dag.markDone("plan");
|
|
106
|
+
dag.markPaused("code");
|
|
107
|
+
const restored = TaskDag.fromJSON(JSON.parse(JSON.stringify(dag.toJSON())));
|
|
108
|
+
expect(restored.get("code")?.status).toBe("paused");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("resetTransient reverts mid-run nodes to idle but keeps paused and done", () => {
|
|
112
|
+
const dag = diamond();
|
|
113
|
+
dag.markDone("plan");
|
|
114
|
+
dag.markRunning("code", "streaming");
|
|
115
|
+
dag.markPaused("docs");
|
|
116
|
+
const reset = dag.resetTransient();
|
|
117
|
+
expect(reset.map((node) => node.id)).toEqual(["code"]);
|
|
118
|
+
expect(dag.get("code")?.status).toBe("idle");
|
|
119
|
+
expect(dag.get("docs")?.status).toBe("paused");
|
|
120
|
+
expect(dag.get("plan")?.status).toBe("done");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("retries and rework", () => {
|
|
125
|
+
test("add() records the retry budget on the node", () => {
|
|
126
|
+
const dag = new TaskDag();
|
|
127
|
+
dag.add({ id: "a", role: "x", retries: 2 });
|
|
128
|
+
expect(dag.get("a")?.retries).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("resetToIdle re-arms a settled node, clearing results and output but not attempts", () => {
|
|
132
|
+
const dag = diamond();
|
|
133
|
+
dag.markDone("plan", [{ role: "user", content: "done", timestamp: 1 } as any]);
|
|
134
|
+
dag.setOutput("plan", "the plan");
|
|
135
|
+
dag.incrementAttempts("plan");
|
|
136
|
+
expect(dag.get("plan")?.output).toBe("the plan");
|
|
137
|
+
|
|
138
|
+
const node = dag.resetToIdle("plan");
|
|
139
|
+
|
|
140
|
+
expect(node.status).toBe("idle");
|
|
141
|
+
expect(node.results).toBeUndefined();
|
|
142
|
+
expect(node.output).toBeUndefined();
|
|
143
|
+
expect(node.attempts).toBe(1);
|
|
144
|
+
expect(dag.ready().map((other) => other.id)).toEqual(["plan"]);
|
|
145
|
+
expect(dag.isComplete()).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("incrementAttempts counts up and returns the running total", () => {
|
|
149
|
+
const dag = new TaskDag();
|
|
150
|
+
dag.add({ id: "a", role: "x" });
|
|
151
|
+
expect(dag.incrementAttempts("a")).toBe(1);
|
|
152
|
+
expect(dag.incrementAttempts("a")).toBe(2);
|
|
153
|
+
expect(dag.get("a")?.attempts).toBe(2);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("immutable snapshots", () => {
|
|
158
|
+
test("accessors hand back frozen copies, so external writes never leak into the dag", () => {
|
|
159
|
+
const dag = new TaskDag();
|
|
160
|
+
dag.add({ id: "a", role: "x" });
|
|
161
|
+
const node = dag.get("a")!;
|
|
162
|
+
expect(Object.isFrozen(node)).toBe(true);
|
|
163
|
+
expect(() => {
|
|
164
|
+
(node as any).output = "leak";
|
|
165
|
+
}).toThrow();
|
|
166
|
+
// the dag's own state is untouched by the rejected write
|
|
167
|
+
expect(dag.get("a")?.output).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("mutating a returned node's deps array does not change the dag", () => {
|
|
171
|
+
const dag = new TaskDag();
|
|
172
|
+
dag.add({ id: "a", role: "x", deps: [] });
|
|
173
|
+
const node = dag.get("a")!;
|
|
174
|
+
node.deps.push("ghost");
|
|
175
|
+
expect(dag.get("a")?.deps).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("mutating a returned node's results array does not change the dag", () => {
|
|
179
|
+
const dag = new TaskDag();
|
|
180
|
+
dag.add({ id: "a", role: "x" });
|
|
181
|
+
dag.markDone("a", [{ role: "assistant", content: "one", timestamp: 1 } as any]);
|
|
182
|
+
const node = dag.get("a")!;
|
|
183
|
+
node.results!.push({ role: "user", content: "leak", timestamp: 2 } as any);
|
|
184
|
+
expect(dag.get("a")?.results).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("persistence", () => {
|
|
189
|
+
test("toJSON/fromJSON round-trips nodes, statuses, and results", () => {
|
|
190
|
+
const dag = diamond();
|
|
191
|
+
dag.markDone("plan", [{ role: "user", content: [{ type: "text", text: "done" }], timestamp: 1 } as any]);
|
|
192
|
+
dag.markRunning("code");
|
|
193
|
+
|
|
194
|
+
const restored = TaskDag.fromJSON(JSON.parse(JSON.stringify(dag.toJSON())));
|
|
195
|
+
|
|
196
|
+
expect(restored.all()).toEqual(dag.all());
|
|
197
|
+
expect(restored.get("plan")?.results).toEqual(dag.get("plan")?.results);
|
|
198
|
+
// derived state survives the round trip: code is running, docs is ready
|
|
199
|
+
expect(restored.ready().map((node) => node.id)).toEqual(["docs"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a node reset after producing output round-trips cleanly", () => {
|
|
203
|
+
const dag = diamond();
|
|
204
|
+
dag.markDone("plan");
|
|
205
|
+
dag.setOutput("plan", "the plan");
|
|
206
|
+
dag.resetToIdle("plan");
|
|
207
|
+
|
|
208
|
+
const restored = TaskDag.fromJSON(JSON.parse(JSON.stringify(dag.toJSON())));
|
|
209
|
+
|
|
210
|
+
// resetToIdle clears output/results by removing them, so the serialized
|
|
211
|
+
// shape is stable: no explicit-undefined keys to drop on the round trip.
|
|
212
|
+
expect(restored.all()).toEqual(dag.all());
|
|
213
|
+
expect(dag.get("plan")?.output).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the restored dag is independent of the source", () => {
|
|
217
|
+
const dag = diamond();
|
|
218
|
+
const restored = TaskDag.fromJSON(dag.toJSON());
|
|
219
|
+
restored.markDone("plan");
|
|
220
|
+
expect(restored.get("plan")?.status).toBe("done");
|
|
221
|
+
expect(dag.get("plan")?.status).toBe("idle");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
});
|