@chrok/braid 0.1.3 → 0.2.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/dist/types.d.ts CHANGED
@@ -3,7 +3,13 @@ export interface ExecuteNode {
3
3
  id: string;
4
4
  prompt: string;
5
5
  model?: string;
6
- /** Read the live cwd without a worktree; defaults to worktree in Git, read-only elsewhere. */
6
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
7
+ notifyOnCompletion?: boolean;
8
+ /** Optional failures remain in history; true aborts the entire run on failure. */
9
+ requireSuccess?: boolean;
10
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
11
+ pauseAfter?: boolean;
12
+ /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
7
13
  workspace?: "read-only" | "worktree";
8
14
  }
9
15
  export interface DecisionNode {
@@ -12,20 +18,52 @@ export interface DecisionNode {
12
18
  prompt: string;
13
19
  choices: readonly string[];
14
20
  model?: string;
15
- /** Read the live cwd without a worktree; defaults to worktree in Git, read-only elsewhere. */
21
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
22
+ notifyOnCompletion?: boolean;
23
+ /** Optional failures remain in history; true aborts the entire run on failure. */
24
+ requireSuccess?: boolean;
25
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
26
+ pauseAfter?: boolean;
27
+ /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
16
28
  workspace?: "read-only" | "worktree";
17
29
  }
18
30
  export interface MergeNode {
19
31
  type: "merge";
20
32
  id: string;
21
- /** Defaults to reviewing and integrating all predecessor workspaces. */
33
+ /** Defaults to combining selected predecessor checkpoints in a new worktree. */
22
34
  prompt?: string;
23
35
  model?: string;
36
+ /** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
37
+ notifyOnCompletion?: boolean;
38
+ /** Optional failures remain in history; true aborts the entire run on failure. */
39
+ requireSuccess?: boolean;
40
+ /** Hold this execution's outgoing dependencies until explicitly resumed. */
41
+ pauseAfter?: boolean;
24
42
  }
25
- export type BraidNode = ExecuteNode | DecisionNode | MergeNode;
43
+ export interface IntegrateNode extends Omit<MergeNode, "type"> {
44
+ type: "integrate";
45
+ }
46
+ export type BraidNode = ExecuteNode | DecisionNode | MergeNode | IntegrateNode;
47
+ /** A reference to a template in this graph submission; values are inserted literally. */
48
+ export interface PromptTemplateReference {
49
+ template: string;
50
+ variables: Readonly<Record<string, string>>;
51
+ }
52
+ export type NodePrompt = string | PromptTemplateReference;
53
+ /** Submission nodes may use templates; ModelRequest.node always has a string prompt. */
54
+ export type BraidInputNode = (Omit<ExecuteNode, "prompt"> & {
55
+ prompt: NodePrompt;
56
+ }) | (Omit<DecisionNode, "prompt"> & {
57
+ prompt: NodePrompt;
58
+ }) | (Omit<MergeNode, "prompt"> & {
59
+ prompt?: NodePrompt;
60
+ }) | (Omit<IntegrateNode, "prompt"> & {
61
+ prompt?: NodePrompt;
62
+ });
26
63
  export interface NodeWorkspace {
27
64
  nodeId: string;
28
- mode: "read-only" | "worktree" | "merge";
65
+ executionId?: string;
66
+ mode: "read-only" | "worktree" | "integrate";
29
67
  workingDirectory: string;
30
68
  worktreeRoot?: string;
31
69
  sourceRoot?: string;
@@ -35,11 +73,13 @@ export interface NodeWorkspace {
35
73
  checkpointCommit?: string;
36
74
  /** Source checkout snapshot captured before a merge agent receives write access. */
37
75
  backupRef?: string;
76
+ /** Agent decisions for this merge/integrate invocation; sources remain reusable. */
77
+ dispositions?: MergeDisposition[];
38
78
  state: "preparing" | "ready" | "integrated" | "discarded" | "archived" | "failed";
39
79
  reason?: string;
40
80
  }
41
81
  export interface MergeDisposition {
42
- nodeId: string;
82
+ executionId: string;
43
83
  disposition: "integrated" | "discarded" | "archived";
44
84
  reason: string;
45
85
  }
@@ -48,7 +88,7 @@ export interface GitPreview {
48
88
  truncated: boolean;
49
89
  }
50
90
  export interface MergeSource extends NodeWorkspace {
51
- /** Inspection-only summaries relative to snapshotCommit; omitted by custom runners. */
91
+ /** Inspection-only summaries relative to the job’s initial snapshot; omitted by custom runners. */
52
92
  changes?: {
53
93
  files: string[];
54
94
  filesTruncated: boolean;
@@ -70,11 +110,23 @@ export interface Edge {
70
110
  to: string;
71
111
  /** Omit for an unconditional edge, including from a decision node. */
72
112
  choice?: string;
113
+ /** Explicit feedback edge into the named loop's entry. */
114
+ feedback?: string;
115
+ /** Pin a dependency to a historical execution rather than the current round. */
116
+ executionId?: string;
117
+ }
118
+ export interface LoopDefinition {
119
+ id: string;
120
+ entry: string;
121
+ maxIterations: number;
73
122
  }
74
123
  export interface BraidInput {
75
124
  goal: string;
76
- nodes: readonly BraidNode[];
125
+ nodes: readonly BraidInputNode[];
77
126
  edges: readonly Edge[];
127
+ /** Named {{variable}} templates, expanded and validated before execution. */
128
+ promptTemplates?: Readonly<Record<string, string>>;
129
+ loops?: readonly LoopDefinition[];
78
130
  }
79
131
  export type NodeStatus = "pending" | "runnable" | "running" | "completed" | "skipped" | "failed";
80
132
  export interface TokenUsage {
@@ -88,14 +140,19 @@ export interface NodeOutput {
88
140
  }
89
141
  export interface PredecessorOutput extends NodeOutput {
90
142
  nodeId: string;
143
+ executionId?: string;
91
144
  /** Failed predecessors remain available on unconditional edges. */
92
145
  error?: ExecutionError;
93
146
  workspace?: NodeWorkspace;
94
147
  }
95
148
  export interface ExecutionContext {
96
149
  runId: string;
97
- /** Equal to runId in v0.1; reserved identity for future shared-root accounting. */
150
+ /** Equal to runId; reserved identity for future shared-root accounting. */
98
151
  rootRunId: string;
152
+ executionId?: string;
153
+ revision?: number;
154
+ loopId?: string;
155
+ iteration?: number;
99
156
  }
100
157
  export interface ModelRequest {
101
158
  goal: string;
@@ -114,7 +171,7 @@ export interface ModelRequest {
114
171
  workspace?: NodeWorkspace;
115
172
  /** Rejects read-only writes; adapters must wrap mutating file tools so cleanup waits for in-flight writes. */
116
173
  withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
117
- /** Local Git operations: inspection for workers, integration commands for merge nodes. */
174
+ /** Local Git operations: inspection for workers, integration commands for merge/integrate nodes. */
118
175
  git?: (args: string[], input?: string) => Promise<GitResult>;
119
176
  /** Merge nodes must account for every source before returning their final answer. */
120
177
  merge?: {
@@ -133,10 +190,14 @@ export interface ModelResponse {
133
190
  }
134
191
  /** Each call starts a fresh conversation and exposes only the adapter’s declared capabilities. */
135
192
  export type ModelRunner = (request: ModelRequest) => Promise<ModelResponse>;
136
- /** Frozen snapshots in emission order. Creation means admission of the submitted DAG, not mutation. */
193
+ /** Frozen snapshots in emission order, including graph revisions and execution identities. */
137
194
  export type ExecutionEvent = Readonly<{
138
195
  sequence: number;
139
196
  timestamp: number;
197
+ executionId?: string;
198
+ revision?: number;
199
+ loopId?: string;
200
+ iteration?: number;
140
201
  } & ({
141
202
  type: "graph_created";
142
203
  nodeCount: number;
@@ -146,11 +207,23 @@ export type ExecutionEvent = Readonly<{
146
207
  nodeId: string;
147
208
  nodeType: BraidNode["type"];
148
209
  model?: string;
149
- } | {
210
+ } | ({
150
211
  type: "edge_created";
151
- from: string;
152
- to: string;
153
- choice?: string;
212
+ } & Edge) | {
213
+ type: "graph_updated";
214
+ graph: BraidInput;
215
+ } | {
216
+ type: "execution_paused";
217
+ nodeId: string;
218
+ } | {
219
+ type: "execution_resumed";
220
+ nodeId: string;
221
+ } | {
222
+ type: "loop_started";
223
+ entry: string;
224
+ } | {
225
+ type: "loop_completed";
226
+ entry: string;
154
227
  } | {
155
228
  type: "workspace_updated";
156
229
  workspace: Readonly<NodeWorkspace>;
@@ -163,6 +236,7 @@ export type ExecutionEvent = Readonly<{
163
236
  to: string;
164
237
  output: string;
165
238
  decision?: string;
239
+ fromExecutionId?: string;
166
240
  } | {
167
241
  type: "node_started";
168
242
  nodeId: string;
@@ -193,7 +267,7 @@ export type ExecutionEvent = Readonly<{
193
267
  export interface BraidOptions {
194
268
  runner: ModelRunner;
195
269
  defaultModel?: string;
196
- /** Source checkout. Nodes may opt into live read-only access; outside Git, all nodes are read-only. */
270
+ /** Source checkout for the initial snapshot and explicit integrate nodes. */
197
271
  cwd?: string;
198
272
  /** Positive integer. Defaults to 4. */
199
273
  maxConcurrency?: number;
@@ -205,30 +279,36 @@ export interface BraidOptions {
205
279
  signal?: AbortSignal;
206
280
  /** Live observer. Throws/rejections are ignored; returned work is not awaited. */
207
281
  onEvent?: (event: ExecutionEvent) => void;
282
+ /** Total admitted executions across loops and graph updates. Default: 1000. */
283
+ maxExecutions?: number;
208
284
  }
209
285
  export interface ExecutionError {
210
- code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED";
286
+ code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED" | "CHECKPOINT_FAILED" | "WORKSPACE_MERGE_REQUIRED" | "EXECUTION_LIMIT" | "LOOP_LIMIT" | "REQUIRED_NODE_FAILED" | "SCHEDULING_ERROR";
211
287
  message: string;
212
288
  }
213
289
  /** Output is absent if no valid response was received; status determines success. */
214
290
  export interface NodeResult extends Partial<NodeOutput> {
215
291
  id: string;
292
+ executionId?: string;
216
293
  status: NodeStatus;
217
294
  usage?: TokenUsage;
218
295
  startedAt?: number;
219
296
  finishedAt?: number;
220
297
  latencyMs?: number;
221
298
  error?: ExecutionError;
222
- skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled";
299
+ skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled" | "run_failed";
223
300
  workspace?: NodeWorkspace;
224
301
  }
225
302
  export interface BraidResult {
226
303
  status: "completed" | "failed";
227
- /** Completed nodes with no active outgoing edges in this execution. */
304
+ /** Latest output per node among completed, unconsumed executions; exact IDs are in terminalExecutionIds. */
228
305
  terminalOutputs: Record<string, NodeOutput>;
229
- /** Immutable execution log, including graph construction and runtime handoffs. */
306
+ /** Immutable execution log, including graph revisions and runtime handoffs. */
230
307
  events: readonly ExecutionEvent[];
231
308
  nodes: Record<string, NodeResult>;
309
+ executions: Record<string, NodeExecution>;
310
+ terminalExecutionIds: string[];
311
+ revision: number;
232
312
  workspaces?: Record<string, NodeWorkspace>;
233
313
  error?: ExecutionError;
234
314
  metadata: ExecutionContext & {
@@ -240,3 +320,42 @@ export interface BraidResult {
240
320
  usageReportedNodes: number;
241
321
  };
242
322
  }
323
+ /** One immutable invocation definition, with a result updated only by that invocation. */
324
+ export interface NodeExecution extends NodeResult {
325
+ executionId: string;
326
+ node: BraidNode;
327
+ revision: number;
328
+ predecessorExecutionIds: string[];
329
+ loopId?: string;
330
+ iteration?: number;
331
+ }
332
+ export interface GraphUpdate {
333
+ expectedRevision: number;
334
+ upsertNodes?: readonly BraidInputNode[];
335
+ removeNodeIds?: readonly string[];
336
+ addEdges?: readonly Edge[];
337
+ removeEdges?: readonly Edge[];
338
+ promptTemplates?: Readonly<Record<string, string>>;
339
+ loops?: readonly LoopDefinition[];
340
+ /** Resume these completed executions atomically with this update. */
341
+ resume?: readonly string[];
342
+ }
343
+ export interface BraidSnapshot {
344
+ runId: string;
345
+ status: "running" | "waiting" | "finalizing" | "completed" | "failed";
346
+ revision: number;
347
+ graph: BraidInput;
348
+ nodes: Record<string, NodeResult>;
349
+ executions: Record<string, NodeExecution>;
350
+ pausedExecutionIds: string[];
351
+ /** Present as soon as a run-wide failure or cancellation starts draining work. */
352
+ error?: ExecutionError;
353
+ }
354
+ export interface BraidRun {
355
+ runId: string;
356
+ result: Promise<BraidResult>;
357
+ snapshot(): BraidSnapshot;
358
+ update(update: GraphUpdate): BraidSnapshot;
359
+ resume(executionIds: readonly string[], expectedRevision: number): BraidSnapshot;
360
+ cancel(): void;
361
+ }
@@ -1,4 +1,4 @@
1
- import type { BraidInput, BraidNode, Edge } from "./types.js";
1
+ import type { BraidInput, BraidNode, Edge, LoopDefinition } from "./types.js";
2
2
  export declare class GraphValidationError extends Error {
3
3
  constructor(message: string);
4
4
  }
@@ -10,6 +10,12 @@ export interface Graph {
10
10
  edges: Edge[];
11
11
  incoming: Map<string, Edge[]>;
12
12
  outgoing: Map<string, Edge[]>;
13
+ loops: Map<string, CompiledLoop>;
14
+ membership: Map<string, string>;
15
+ }
16
+ export interface CompiledLoop extends LoopDefinition {
17
+ feedback: Edge;
18
+ members: Set<string>;
13
19
  }
14
20
  /** Throws before execution for malformed graphs, references, choices, or cycles. */
15
21
  export declare function validateGraph(input: BraidInput): void;
package/dist/validate.js CHANGED
@@ -19,35 +19,103 @@ function fields(value, allowed, label) {
19
19
  requireValid(allowed.includes(key), `${label}: unsupported field '${key}'`);
20
20
  }
21
21
  }
22
+ function compileTemplates(value) {
23
+ const templates = new Map();
24
+ if (value === undefined)
25
+ return templates;
26
+ requireValid(isRecord(value), "Graph promptTemplates must be an object");
27
+ for (const [name, source] of Object.entries(value)) {
28
+ requireValid(text(name), "Prompt template name must be a non-empty string");
29
+ const label = `Prompt template '${name}'`;
30
+ requireValid(text(source), `${label} must be a non-empty string`);
31
+ const parts = [];
32
+ const variables = new Set();
33
+ let offset = 0;
34
+ while (offset < source.length) {
35
+ const open = source.indexOf("{{", offset);
36
+ const close = source.indexOf("}}", offset);
37
+ requireValid(close === -1 || (open !== -1 && close > open), `${label} has an unmatched '}}'`);
38
+ if (open === -1) {
39
+ parts.push(source.slice(offset));
40
+ break;
41
+ }
42
+ requireValid(close !== -1, `${label} has an unclosed '{{'`);
43
+ const variable = source.slice(open + 2, close).trim();
44
+ requireValid(/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable), `${label} has invalid variable '${variable}'`);
45
+ parts.push(source.slice(offset, open), { variable });
46
+ variables.add(variable);
47
+ offset = close + 2;
48
+ }
49
+ templates.set(name, { parts, variables });
50
+ }
51
+ return templates;
52
+ }
53
+ function renderPrompt(value, nodeId, templates) {
54
+ const nodeLabel = `Node '${nodeId}'`;
55
+ if (typeof value === "string") {
56
+ requireValid(text(value), `${nodeLabel} needs a non-empty prompt`);
57
+ return value;
58
+ }
59
+ requireValid(isRecord(value), `${nodeLabel} needs a non-empty prompt or template reference`);
60
+ fields(value, ["template", "variables"], `${nodeLabel} prompt`);
61
+ requireValid(text(value.template), `${nodeLabel} prompt needs a non-empty template reference`);
62
+ const label = `${nodeLabel} prompt template '${value.template}'`;
63
+ const template = templates.get(value.template);
64
+ requireValid(template, `${label} is unknown`);
65
+ requireValid(isRecord(value.variables), `${label} variables must be an object`);
66
+ const variables = new Map();
67
+ for (const [name, variable] of Object.entries(value.variables)) {
68
+ requireValid(typeof variable === "string", `${label} variable '${name}' must be a string`);
69
+ requireValid(template.variables.has(name), `${label} has unused variable '${name}'`);
70
+ variables.set(name, variable);
71
+ }
72
+ for (const name of template.variables) {
73
+ requireValid(variables.has(name), `${label} is missing variable '${name}'`);
74
+ }
75
+ // One pass over the parsed template: inserted values are never parsed or evaluated.
76
+ const rendered = template.parts.map(part => typeof part === "string" ? part : variables.get(part.variable)).join("");
77
+ requireValid(text(rendered), `${label} renders an empty prompt`);
78
+ return rendered;
79
+ }
22
80
  /** Throws before execution for malformed graphs, references, choices, or cycles. */
23
81
  export function validateGraph(input) {
24
82
  compileGraph(input);
25
83
  }
26
84
  export function compileGraph(input) {
27
85
  requireValid(isRecord(input), "Graph must be an object");
28
- fields(input, ["goal", "nodes", "edges"], "Graph");
86
+ fields(input, ["goal", "nodes", "edges", "promptTemplates", "loops"], "Graph");
29
87
  requireValid(text(input.goal), "Graph goal must be a non-empty string");
30
88
  requireValid(Array.isArray(input.nodes) && input.nodes.length > 0, "Graph needs at least one node");
31
89
  requireValid(Array.isArray(input.edges), "Graph edges must be an array");
90
+ const templates = compileTemplates(input.promptTemplates);
32
91
  const byId = new Map();
33
92
  for (const node of input.nodes) {
34
93
  requireValid(isRecord(node), "Node must be an object");
35
- requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
94
+ requireValid(node.type === "execute" || node.type === "decision" || (node.type === "merge" || node.type === "integrate"), "Unknown node type");
36
95
  fields(node, node.type === "decision"
37
- ? ["type", "id", "prompt", "model", "choices", "workspace"]
96
+ ? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
38
97
  : node.type === "execute"
39
- ? ["type", "id", "prompt", "model", "workspace"]
40
- : ["type", "id", "prompt", "model"], "Node");
98
+ ? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
99
+ : ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter"], "Node");
41
100
  requireValid(text(node.id), "Node id must be a non-empty string");
42
101
  requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
43
- requireValid(text(node.prompt) || (node.type === "merge" && node.prompt === undefined), `Node '${node.id}' needs a non-empty prompt`);
102
+ const prompt = (node.type === "merge" || node.type === "integrate") && node.prompt === undefined
103
+ ? (node.type === "merge" ? "Merge selected predecessor results into this new isolated worktree. Resolve conflicts and account for every source with finish_merge." : "Integrate selected predecessor results into the invoking checkout. Preserve user changes and account for every source with finish_merge.")
104
+ : renderPrompt(node.prompt, node.id, templates);
44
105
  requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
45
- const workspace = node.type === "merge" ? undefined : node.workspace;
106
+ const workspace = (node.type === "merge" || node.type === "integrate") ? undefined : node.workspace;
107
+ requireValid(node.notifyOnCompletion === undefined || typeof node.notifyOnCompletion === "boolean", `Invalid notifyOnCompletion on '${node.id}': expected a boolean`);
46
108
  requireValid(workspace === undefined || workspace === "read-only" || workspace === "worktree", `Invalid workspace on '${node.id}'`);
109
+ for (const field of ["requireSuccess", "pauseAfter"]) {
110
+ requireValid(node[field] === undefined || typeof node[field] === "boolean", `Invalid ${field} on '${node.id}': expected a boolean`);
111
+ }
47
112
  const common = {
48
113
  id: node.id,
49
- prompt: node.prompt ?? "Review all predecessor changes, decide how to integrate them into the source repository, and account for every source with finish_merge.",
114
+ prompt,
50
115
  ...(node.model !== undefined ? { model: node.model } : {}),
116
+ ...(node.notifyOnCompletion !== undefined ? { notifyOnCompletion: node.notifyOnCompletion } : {}),
117
+ ...(node.requireSuccess !== undefined ? { requireSuccess: node.requireSuccess } : {}),
118
+ ...(node.pauseAfter !== undefined ? { pauseAfter: node.pauseAfter } : {}),
51
119
  ...(workspace !== undefined ? { workspace } : {}),
52
120
  };
53
121
  if (node.type === "decision") {
@@ -71,39 +139,94 @@ export function compileGraph(input) {
71
139
  }
72
140
  for (const edge of input.edges) {
73
141
  requireValid(isRecord(edge), "Edge must be an object");
74
- fields(edge, ["from", "to", "choice"], "Edge");
75
- requireValid(text(edge.from) && byId.has(edge.from), `Missing source node '${edge.from}'`);
142
+ fields(edge, ["from", "to", "choice", "feedback", "executionId"], "Edge");
143
+ requireValid(edge.executionId === undefined || text(edge.executionId), "Invalid edge executionId");
144
+ requireValid(edge.feedback === undefined || text(edge.feedback), "Invalid edge feedback");
145
+ requireValid(!edge.feedback || !edge.executionId, "Feedback cannot pin an execution");
146
+ requireValid(text(edge.from) && (byId.has(edge.from) || edge.executionId !== undefined), `Missing source node '${edge.from}'`);
76
147
  requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
77
- if (edge.choice !== undefined) {
148
+ requireValid(edge.choice === undefined || text(edge.choice), "Invalid edge choice");
149
+ if (edge.choice !== undefined && edge.executionId === undefined) {
78
150
  const source = byId.get(edge.from);
79
151
  requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
80
152
  requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
81
153
  }
82
- const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null]);
154
+ const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null, edge.feedback ?? null, edge.executionId ?? null]);
83
155
  requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
84
156
  seen.add(key);
85
157
  const snapshot = {
86
158
  from: edge.from,
87
159
  to: edge.to,
88
160
  ...(edge.choice !== undefined ? { choice: edge.choice } : {}),
161
+ ...(edge.feedback !== undefined ? { feedback: edge.feedback } : {}),
162
+ ...(edge.executionId !== undefined ? { executionId: edge.executionId } : {}),
89
163
  };
90
164
  edges.push(snapshot);
91
165
  incoming.get(edge.to).push(snapshot);
92
- outgoing.get(edge.from).push(snapshot);
166
+ outgoing.get(edge.from)?.push(snapshot);
93
167
  }
94
168
  // Kahn's algorithm is iterative, including for very deep graphs.
95
169
  const nodes = [...byId.values()];
96
- const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).length]));
170
+ const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).filter(edge => !edge.feedback && !edge.executionId).length]));
97
171
  const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
98
172
  for (let i = 0; i < topologicalOrder.length; i++) {
99
173
  for (const edge of outgoing.get(topologicalOrder[i].id)) {
174
+ if (edge.feedback || edge.executionId)
175
+ continue;
100
176
  const count = remaining.get(edge.to) - 1;
101
177
  remaining.set(edge.to, count);
102
178
  if (count === 0)
103
179
  topologicalOrder.push(byId.get(edge.to));
104
180
  }
105
181
  }
106
- requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle");
182
+ requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle without a declared feedback edge");
183
+ const loops = new Map();
184
+ const membership = new Map();
185
+ requireValid(input.loops === undefined || Array.isArray(input.loops), "Graph loops must be an array");
186
+ const reachable = (start, reverse = false) => {
187
+ const visited = new Set();
188
+ const queue = [start];
189
+ while (queue.length) {
190
+ const id = queue.pop();
191
+ if (visited.has(id))
192
+ continue;
193
+ visited.add(id);
194
+ for (const edge of (reverse ? incoming : outgoing).get(id) ?? []) {
195
+ if (!edge.feedback && !edge.executionId)
196
+ queue.push(reverse ? edge.from : edge.to);
197
+ }
198
+ }
199
+ return visited;
200
+ };
201
+ for (const loop of input.loops ?? []) {
202
+ requireValid(isRecord(loop), "Loop must be an object");
203
+ fields(loop, ["id", "entry", "maxIterations"], "Loop");
204
+ requireValid(text(loop.id) && !loops.has(loop.id), "Loop ids must be non-empty and unique");
205
+ requireValid(text(loop.entry) && byId.has(loop.entry), `Unknown loop entry '${loop.entry}'`);
206
+ requireValid(typeof loop.maxIterations === "number" && Number.isSafeInteger(loop.maxIterations) && loop.maxIterations > 0, `Loop '${loop.id}' needs a positive maxIterations`);
207
+ const feedback = edges.filter(edge => edge.feedback === loop.id);
208
+ requireValid(feedback.length === 1, `Loop '${loop.id}' needs exactly one feedback edge`);
209
+ const back = feedback[0];
210
+ const decision = byId.get(back.from);
211
+ requireValid(back.to === loop.entry && decision.type === "decision" && back.choice !== undefined, `Loop '${loop.id}' feedback must route a decision choice to its entry`);
212
+ requireValid(decision.choices.some(choice => choice !== back.choice), `Loop '${loop.id}' needs an exit choice`);
213
+ const ancestors = reachable(back.from, true);
214
+ const members = new Set([...reachable(loop.entry)].filter(id => ancestors.has(id)));
215
+ requireValid(members.has(loop.entry) && members.has(back.from), `Loop '${loop.id}' entry must reach its feedback decision`);
216
+ for (const id of members) {
217
+ requireValid(!membership.has(id), "Nested or overlapping loops are unsupported");
218
+ membership.set(id, loop.id);
219
+ }
220
+ for (const edge of edges) {
221
+ if (edge.feedback || edge.executionId)
222
+ continue;
223
+ requireValid(!(!members.has(edge.from) && members.has(edge.to) && edge.to !== loop.entry), `Loop '${loop.id}' has multiple entries`);
224
+ requireValid(!(members.has(edge.from) && !members.has(edge.to) && edge.from !== back.from), `Loop '${loop.id}' must exit through its feedback decision`);
225
+ }
226
+ loops.set(loop.id, { id: loop.id, entry: loop.entry, maxIterations: loop.maxIterations, feedback: back, members });
227
+ }
228
+ for (const edge of edges)
229
+ requireValid(!edge.feedback || loops.has(edge.feedback), `Unknown feedback loop '${edge.feedback}'`);
107
230
  return {
108
231
  goal: input.goal,
109
232
  nodes,
@@ -111,5 +234,7 @@ export function compileGraph(input) {
111
234
  edges,
112
235
  incoming,
113
236
  outgoing,
237
+ loops,
238
+ membership,
114
239
  };
115
240
  }
@@ -1,5 +1,5 @@
1
1
  import type { GitResult, MergeDisposition, MergeSource, ModelRequest, NodeWorkspace, SourceCheckoutStatus } from "./types.js";
2
- /** Writable workers share a snapshot; explicit read-only workers inspect the live cwd. */
2
+ /** Each execution owns a fresh worktree derived from immutable predecessor checkpoints. */
3
3
  export declare class GitWorkspaces {
4
4
  private readonly cwd;
5
5
  private readonly onWorkspace?;
@@ -7,17 +7,23 @@ export declare class GitWorkspaces {
7
7
  private records;
8
8
  private locations;
9
9
  private allocated;
10
+ private mergeParents;
10
11
  constructor(cwd: string, onWorkspace?: ((workspace: NodeWorkspace) => void) | undefined);
11
12
  private report;
12
13
  private sourceRoot;
13
14
  private snapshot;
14
- prepare(request: ModelRequest): Promise<NodeWorkspace>;
15
+ /** Freeze the job's root snapshot before any invocation can write to the checkout. */
16
+ initialize(runId: string): Promise<void>;
17
+ private id;
18
+ private inputCommit;
19
+ prepare(request: ModelRequest, merge?: boolean): Promise<NodeWorkspace>;
20
+ /** Seal before releasing any downstream execution, including failures with partial writes. */
21
+ seal(request: ModelRequest): Promise<void>;
22
+ private sealCheckpoint;
15
23
  all(): Record<string, NodeWorkspace>;
16
24
  pending(): string[];
17
25
  /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
18
26
  private checkpoint;
19
- /** Run only after declared nodes settle, so consumers retain their source paths. */
20
- discardUnchanged(): Promise<void>;
21
27
  private release;
22
28
  /** Only archive/remove here. Choosing merge/cherry-pick/apply always belongs to the agent. */
23
29
  archivePending(reason: string): Promise<void>;
@@ -30,3 +36,7 @@ export declare class GitWorkspaces {
30
36
  complete: (success: boolean) => Promise<void>;
31
37
  }>;
32
38
  }
39
+ export declare class WorkspaceInputError extends Error {
40
+ }
41
+ export declare class WorkspaceCheckpointError extends Error {
42
+ }