@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 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
@@ -0,0 +1,3 @@
1
+ export { TaskDag } from "./dag.js";
2
+ export type { TaskNodeInput } from "./dag.js";
3
+ export type { AgentMessage, AgentStatus, SerializedDag, TaskNode } from "./types.js";
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>;
@@ -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
+ });