@chrok/braid 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,7 +70,7 @@ async function git(cwd, args, options = {}) {
70
70
  ...(options.signal ? { signal: options.signal } : {}),
71
71
  maxBuffer: 8 * 1024 * 1024,
72
72
  });
73
- return stdout.trim();
73
+ return args.includes("-z") ? stdout : stdout.trim();
74
74
  }
75
75
  /** Read-only, bounded output. A large diff cannot fail workspace preparation or flood the model. */
76
76
  async function gitPreview(cwd, args, limit) {
@@ -91,7 +91,7 @@ async function gitPreview(cwd, args, limit) {
91
91
  }
92
92
  return { text: output.slice(0, limit), truncated: overflow || output.length > limit };
93
93
  }
94
- /** One source snapshot per graph; every invoked node gets its own detached worktree. */
94
+ /** Each execution owns a fresh worktree derived from immutable predecessor checkpoints. */
95
95
  export class GitWorkspaces {
96
96
  cwd;
97
97
  onWorkspace;
@@ -99,12 +99,13 @@ export class GitWorkspaces {
99
99
  records = new Map();
100
100
  locations = new Map();
101
101
  allocated = new Set();
102
+ mergeParents = new Map();
102
103
  constructor(cwd, onWorkspace) {
103
104
  this.cwd = cwd;
104
105
  this.onWorkspace = onWorkspace;
105
106
  }
106
107
  report(workspace) {
107
- this.records.set(workspace.nodeId, workspace);
108
+ this.records.set(workspace.executionId ?? workspace.nodeId, workspace);
108
109
  try {
109
110
  this.onWorkspace?.({ ...workspace });
110
111
  }
@@ -112,7 +113,7 @@ export class GitWorkspaces {
112
113
  // Observers cannot change permissions or fail node execution.
113
114
  }
114
115
  }
115
- async snapshot() {
116
+ async sourceRoot() {
116
117
  let ancestor = resolve(this.cwd);
117
118
  while (true) {
118
119
  try {
@@ -138,7 +139,12 @@ export class GitWorkspaces {
138
139
  return undefined;
139
140
  throw error;
140
141
  }
141
- sourceRoot = await realpath(sourceRoot);
142
+ return realpath(sourceRoot);
143
+ }
144
+ async snapshot(extraFiles = []) {
145
+ const sourceRoot = await this.sourceRoot();
146
+ if (!sourceRoot)
147
+ return undefined;
142
148
  const commonDirectory = await realpath(resolve(sourceRoot, await git(sourceRoot, ["rev-parse", "--git-common-dir"])));
143
149
  const cwdSuffix = relative(sourceRoot, await realpath(this.cwd));
144
150
  // Git records canonical worktree paths. In particular, Windows tmpdir()
@@ -174,6 +180,21 @@ export class GitWorkspaces {
174
180
  // A temporary index captures tracked edits/deletions and non-ignored new files
175
181
  // without changing the parent's real index, branch, or working files.
176
182
  await git(sourceRoot, ["add", "--all", "--", "."], options);
183
+ // Include ignored files contributed by selected sources, without capturing the
184
+ // caller's unrelated ignored build products or dependencies.
185
+ const existing = [];
186
+ for (const file of extraFiles) {
187
+ try {
188
+ await access(join(sourceRoot, file));
189
+ existing.push(file);
190
+ }
191
+ catch (error) {
192
+ if (error.code !== "ENOENT")
193
+ throw error;
194
+ }
195
+ }
196
+ for (let index = 0; index < existing.length; index += 256)
197
+ await git(sourceRoot, ["add", "--force", "--all", "--", ...existing.slice(index, index + 256)], options);
177
198
  const tree = await git(sourceRoot, ["write-tree"], options);
178
199
  const baseTree = baseCommit ? await git(sourceRoot, ["rev-parse", `${baseCommit}^{tree}`]) : undefined;
179
200
  const snapshotCommit = tree === baseTree ? baseCommit : await git(sourceRoot, [
@@ -183,7 +204,7 @@ export class GitWorkspaces {
183
204
  "-m", "Braid isolated workspace snapshot",
184
205
  ], options);
185
206
  const snapshot = {
186
- directory, commonDirectory, sourceRoot, cwdSuffix, snapshotCommit, hooksDirectory,
207
+ directory, commonDirectory, sourceRoot, cwdSuffix, snapshotTree: tree, snapshotCommit, hooksDirectory,
187
208
  ...(baseCommit ? { baseCommit } : {}),
188
209
  };
189
210
  this.allocated.add(snapshot);
@@ -198,49 +219,78 @@ export class GitWorkspaces {
198
219
  await rm(`${index}.lock`, { force: true });
199
220
  }
200
221
  }
201
- async prepare(request) {
202
- request.signal.throwIfAborted();
203
- let pending = this.snapshots.get(request.execution.runId);
204
- if (!pending) {
205
- // Do not bind the shared snapshot to one node's cancellation signal.
206
- pending = this.snapshot();
207
- this.snapshots.set(request.execution.runId, pending);
222
+ /** Freeze the job's root snapshot before any invocation can write to the checkout. */
223
+ async initialize(runId) {
224
+ if (!this.snapshots.has(runId))
225
+ this.snapshots.set(runId, this.snapshot());
226
+ await this.snapshots.get(runId);
227
+ }
228
+ id(request) {
229
+ return request.execution.executionId ?? request.node.id;
230
+ }
231
+ async inputCommit(request, snapshot) {
232
+ const commits = [...new Set(request.predecessors.map(value => value.workspace?.checkpointCommit).filter((value) => !!value))];
233
+ if (!commits.length)
234
+ return snapshot.snapshotCommit;
235
+ const trees = await Promise.all(commits.map(commit => git(snapshot.sourceRoot, ["rev-parse", `${commit}^{tree}`])));
236
+ if (new Set(trees).size === 1)
237
+ return commits[0];
238
+ for (const candidate of commits) {
239
+ let containsAll = true;
240
+ for (const ancestor of commits) {
241
+ try {
242
+ await git(snapshot.sourceRoot, ["merge-base", "--is-ancestor", ancestor, candidate]);
243
+ }
244
+ catch (error) {
245
+ if (error.code !== 1)
246
+ throw error;
247
+ containsAll = false;
248
+ break;
249
+ }
250
+ }
251
+ if (containsAll)
252
+ return candidate;
208
253
  }
209
- const snapshot = await pending;
254
+ throw new WorkspaceInputError("Multiple independent predecessor snapshots require an explicit merge node");
255
+ }
256
+ async prepare(request, merge = false) {
210
257
  request.signal.throwIfAborted();
258
+ await this.initialize(request.execution.runId);
259
+ const snapshot = await this.snapshots.get(request.execution.runId);
260
+ const executionId = this.id(request);
211
261
  if (!snapshot) {
212
262
  const workspace = {
213
- nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready",
263
+ nodeId: request.node.id, executionId, mode: "read-only", workingDirectory: this.cwd, state: "ready",
214
264
  };
215
265
  this.report(workspace);
216
266
  return workspace;
217
267
  }
268
+ const commit = merge ? snapshot.snapshotCommit : await this.inputCommit(request, snapshot);
269
+ request.signal.throwIfAborted();
218
270
  const worktreeRoot = join(snapshot.directory, crypto.randomUUID());
271
+ const readOnly = request.node.type !== "merge" && request.node.type !== "integrate" && request.node.workspace === "read-only";
219
272
  const workspace = {
220
- nodeId: request.node.id, mode: "worktree", state: "preparing",
273
+ nodeId: request.node.id, executionId, mode: readOnly ? "read-only" : "worktree", state: "preparing",
221
274
  sourceRoot: snapshot.sourceRoot, worktreeRoot,
222
275
  workingDirectory: resolve(worktreeRoot, snapshot.cwdSuffix),
223
- snapshotCommit: snapshot.snapshotCommit,
276
+ snapshotCommit: commit,
224
277
  ...(snapshot.baseCommit ? { baseCommit: snapshot.baseCommit } : {}),
225
278
  };
226
- this.locations.set(request.node.id, snapshot);
279
+ this.locations.set(executionId, snapshot);
227
280
  this.report(workspace);
228
281
  try {
229
- // Finish registration even if cancellation arrives during creation. Report
230
- // the retained path before observing cancellation, so it can be recovered.
231
282
  await withWorktreeLock(snapshot.commonDirectory, async () => {
232
283
  request.signal.throwIfAborted();
233
- await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, snapshot.snapshotCommit], {
284
+ await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, commit], {
234
285
  hooksDirectory: snapshot.hooksDirectory,
235
286
  });
236
287
  });
237
- // The original cwd may be an empty or ignored directory absent from Git.
238
288
  await mkdir(workspace.workingDirectory, { recursive: true });
239
289
  workspace.state = "ready";
240
290
  }
241
291
  catch (error) {
242
292
  workspace.state = "failed";
243
- throw new Error(`Cannot prepare isolated node worktree at ${worktreeRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
293
+ throw new Error(`Cannot prepare isolated execution worktree at ${worktreeRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
244
294
  }
245
295
  finally {
246
296
  this.report(workspace);
@@ -248,19 +298,58 @@ export class GitWorkspaces {
248
298
  request.signal.throwIfAborted();
249
299
  return workspace;
250
300
  }
301
+ /** Seal before releasing any downstream execution, including failures with partial writes. */
302
+ async seal(request) {
303
+ try {
304
+ await this.sealCheckpoint(request);
305
+ }
306
+ catch (error) {
307
+ throw new WorkspaceCheckpointError(error instanceof Error ? error.message : String(error), { cause: error });
308
+ }
309
+ }
310
+ async sealCheckpoint(request) {
311
+ const workspace = this.records.get(this.id(request));
312
+ if (!workspace || workspace.checkpointRef || workspace.state === "failed")
313
+ return;
314
+ if (workspace.mode === "integrate") {
315
+ const baseline = (await this.snapshots.get(request.execution.runId));
316
+ const selected = this.mergeParents.get(this.id(request)) ?? [];
317
+ const files = new Set();
318
+ for (const commit of selected) {
319
+ const paths = await git(baseline.sourceRoot, ["diff", "--name-only", "-z", baseline.snapshotCommit, commit, "--"]);
320
+ for (const path of paths.split("\0").filter(Boolean))
321
+ files.add(path);
322
+ }
323
+ const after = await this.snapshot([...files]);
324
+ if (!after)
325
+ throw new Error("Integration checkout disappeared");
326
+ const parents = [...new Set([after.snapshotCommit, ...selected])];
327
+ const commit = parents.length === 1 ? after.snapshotCommit : await git(after.sourceRoot, [
328
+ "-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
329
+ "commit-tree", after.snapshotTree, ...parents.flatMap(parent => ["-p", parent]), "-m", "Braid integration checkpoint",
330
+ ], { hooksDirectory: after.hooksDirectory });
331
+ workspace.checkpointCommit = commit;
332
+ workspace.checkpointRef = `refs/braid/checkpoints/${crypto.randomUUID()}`;
333
+ await git(after.sourceRoot, ["update-ref", workspace.checkpointRef, commit]);
334
+ this.report(workspace);
335
+ }
336
+ else if (workspace.worktreeRoot) {
337
+ await this.checkpoint(workspace);
338
+ }
339
+ }
251
340
  all() {
252
- return Object.fromEntries([...this.records].map(([id, workspace]) => [id, { ...workspace }]));
341
+ return structuredClone(Object.fromEntries(this.records));
253
342
  }
254
343
  pending() {
255
344
  return [...this.records.values()]
256
- .filter(workspace => workspace.mode === "worktree" && ["preparing", "ready", "failed"].includes(workspace.state))
257
- .map(workspace => workspace.nodeId);
345
+ .filter(workspace => workspace.worktreeRoot && ["preparing", "ready", "failed"].includes(workspace.state))
346
+ .map(workspace => workspace.executionId ?? workspace.nodeId);
258
347
  }
259
348
  /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
260
349
  async checkpoint(workspace) {
261
350
  if (workspace.checkpointRef)
262
351
  return;
263
- const location = this.locations.get(workspace.nodeId);
352
+ const location = this.locations.get(workspace.executionId ?? workspace.nodeId);
264
353
  const cwd = workspace.worktreeRoot;
265
354
  const options = { hooksDirectory: location.hooksDirectory };
266
355
  // A Git tree stores only a submodule commit, never files written inside it.
@@ -279,9 +368,11 @@ export class GitWorkspaces {
279
368
  }
280
369
  await git(cwd, ["add", "--force", "--all", "--", "."], options);
281
370
  const tree = await git(cwd, ["write-tree"], options);
282
- const commit = await git(cwd, [
371
+ const baseTree = await git(cwd, ["rev-parse", `${workspace.snapshotCommit}^{tree}`]);
372
+ const parents = [...new Set([workspace.snapshotCommit, ...(this.mergeParents.get(workspace.executionId ?? workspace.nodeId) ?? [])])];
373
+ const commit = tree === baseTree && parents.length === 1 ? workspace.snapshotCommit : await git(cwd, [
283
374
  "-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
284
- "commit-tree", tree, "-p", workspace.snapshotCommit, "-m", `Braid node checkpoint: ${workspace.nodeId}`,
375
+ "commit-tree", tree, ...parents.flatMap(parent => ["-p", parent]), "-m", `Braid node checkpoint: ${workspace.nodeId}`,
285
376
  ], options);
286
377
  const suffix = createHash("sha256").update(cwd).digest("hex");
287
378
  const ref = `refs/braid/checkpoints/${suffix}`;
@@ -340,7 +431,7 @@ export class GitWorkspaces {
340
431
  for (const snapshot of this.allocated) {
341
432
  // Ownership is explicit; a POSIX path prefix would miss retained Windows
342
433
  // worktrees and recursively delete data after checkpoint/cleanup failure.
343
- const live = [...this.records.values()].some(workspace => workspace.mode === "worktree" && this.locations.get(workspace.nodeId) === snapshot &&
434
+ const live = [...this.records.values()].some(workspace => !!workspace.worktreeRoot && this.locations.get(workspace.executionId ?? workspace.nodeId) === snapshot &&
344
435
  ["ready", "preparing", "failed"].includes(workspace.state));
345
436
  if (!live) {
346
437
  try {
@@ -362,8 +453,8 @@ export class GitWorkspaces {
362
453
  throw new Error("Git input must be a string");
363
454
  const mutate = ["add", "commit", "merge", "cherry-pick", "apply", "restore"];
364
455
  const command = args[0];
365
- if (!gitCommands(request.node.type === "merge").includes(command))
366
- throw unavailableGitCommand(command, request.node.type === "merge");
456
+ if (!gitCommands(request.node.type === "merge" || request.node.type === "integrate").includes(command))
457
+ throw unavailableGitCommand(command, request.node.type === "merge" || request.node.type === "integrate");
367
458
  const blockedOptions = ["--output", "--ext-diff", "--textconv", "--unsafe-paths", "--directory", "--strategy", "--gpg-sign", "--work-tree", "--git-dir"];
368
459
  // Git accepts abbreviated long options (e.g. --out) and attached short
369
460
  // values (-scustom). Apply the same boundary to those spellings.
@@ -374,15 +465,18 @@ export class GitWorkspaces {
374
465
  }))
375
466
  throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
376
467
  const workspace = request.workspace;
377
- const cwd = workspace.mode === "merge" ? workspace.sourceRoot : workspace.worktreeRoot;
378
- const location = this.locations.get(request.node.id);
468
+ const cwd = workspace.mode === "read-only" ? workspace.workingDirectory
469
+ : workspace.mode === "integrate" ? workspace.sourceRoot : workspace.worktreeRoot;
470
+ const location = this.locations.get(this.id(request));
379
471
  const actualArgs = [command,
380
472
  ...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
381
473
  ...args.slice(1),
382
474
  ];
383
475
  const execute = () => new Promise((resolve, reject) => {
384
476
  const child = execFile("git", [
385
- "-c", `core.hooksPath=${location.hooksDirectory}`, "-c", "commit.gpgsign=false",
477
+ ...(location ? ["-c", `core.hooksPath=${location.hooksDirectory}`] : []),
478
+ ...(workspace.mode === "read-only" ? ["--no-optional-locks"] : []),
479
+ "-c", "commit.gpgsign=false",
386
480
  "-c", "core.fsmonitor=false", "--no-pager", "-C", cwd, ...actualArgs,
387
481
  ], {
388
482
  env: { ...gitEnvironment(), GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true", GIT_MERGE_AUTOEDIT: "no" },
@@ -398,51 +492,44 @@ export class GitWorkspaces {
398
492
  return mutate.includes(command) ? request.withWorkspaceWrite(execute) : execute();
399
493
  }
400
494
  async beginMerge(request, sourceIds) {
401
- // Discover the source without allocating a merge worktree: agents integrate
402
- // directly in the caller's checkout, under a repository-scoped mutex.
403
- let pending = this.snapshots.get(request.execution.runId);
404
- if (!pending) {
405
- pending = this.snapshot();
406
- this.snapshots.set(request.execution.runId, pending);
407
- }
408
- let snapshot = await pending;
409
- const unlock = snapshot ? await lock(snapshot.sourceRoot, request.signal) : () => { };
495
+ await this.initialize(request.execution.runId);
496
+ const baseline = await this.snapshots.get(request.execution.runId);
497
+ const integrating = request.node.type === "integrate";
498
+ const unlock = integrating && baseline ? await lock(baseline.sourceRoot, request.signal) : () => { };
410
499
  let finished = false;
411
- let resolutions;
412
- const ids = sourceIds.filter(id => this.pending().includes(id));
500
+ let resolutions = [];
501
+ const ids = [...new Set(sourceIds)].filter(id => this.records.get(id)?.checkpointRef);
413
502
  try {
414
503
  request.signal.throwIfAborted();
415
- if (snapshot) {
416
- snapshot = await this.snapshot();
417
- this.snapshots.set(request.execution.runId, Promise.resolve(snapshot));
504
+ if (integrating && baseline) {
505
+ const before = (await this.snapshot());
506
+ const workspace = {
507
+ nodeId: request.node.id, executionId: this.id(request), mode: "integrate",
508
+ workingDirectory: resolve(before.sourceRoot, before.cwdSuffix), sourceRoot: before.sourceRoot,
509
+ snapshotCommit: before.snapshotCommit, state: "ready",
510
+ backupRef: `refs/braid/merge-backups/${crypto.randomUUID()}`,
511
+ };
512
+ await git(before.sourceRoot, ["update-ref", workspace.backupRef, before.snapshotCommit]);
513
+ this.locations.set(this.id(request), before);
514
+ this.report(workspace);
515
+ request.workspace = workspace;
418
516
  }
419
- for (const id of ids)
420
- await this.checkpoint(this.records.get(id));
421
- const workspace = snapshot ? {
422
- nodeId: request.node.id, mode: "merge", workingDirectory: snapshot.sourceRoot,
423
- sourceRoot: snapshot.sourceRoot, snapshotCommit: snapshot.snapshotCommit, state: "ready",
424
- } : { nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready" };
425
- if (snapshot) {
426
- workspace.backupRef = `refs/braid/merge-backups/${crypto.randomUUID()}`;
427
- await git(snapshot.sourceRoot, ["update-ref", workspace.backupRef, snapshot.snapshotCommit], { hooksDirectory: snapshot.hooksDirectory });
517
+ else {
518
+ request.workspace = await this.prepare(request, true);
428
519
  }
429
- request.workspace = workspace;
430
- if (snapshot)
431
- this.locations.set(request.node.id, snapshot);
432
- this.report(workspace);
433
- const sourceStatus = snapshot ? await gitPreview(snapshot.sourceRoot, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
520
+ const target = request.workspace.worktreeRoot ?? request.workspace.sourceRoot;
434
521
  const sources = [];
435
522
  for (const id of ids) {
436
523
  const source = this.records.get(id);
437
- const diffArgs = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", source.snapshotCommit, source.checkpointRef, "--"];
524
+ const diffArgs = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", baseline.snapshotCommit, source.checkpointRef, "--"];
438
525
  const count = Math.max(1, ids.length);
439
526
  const files = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--name-only", "-z", "--"], Math.floor(8_000 / count));
440
527
  const stat = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--stat", "--"], Math.floor(4_000 / count));
441
528
  const diff = await gitPreview(source.sourceRoot, diffArgs, Math.min(6_000, Math.floor(24_000 / count)));
442
- // Truncated NUL output must not invent a partial filename.
443
529
  const names = files.text.slice(0, files.text.lastIndexOf("\0") + 1).split("\0").filter(Boolean);
444
530
  sources.push({ ...source, changes: { files: names, filesTruncated: files.truncated, stat, diff } });
445
531
  }
532
+ const sourceStatus = integrating && target ? await gitPreview(target, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
446
533
  return {
447
534
  sources,
448
535
  ...(sourceStatus ? { sourceStatus: { ...sourceStatus, dirty: sourceStatus.text.length > 0 || sourceStatus.truncated } } : {}),
@@ -451,37 +538,30 @@ export class GitWorkspaces {
451
538
  if (finished)
452
539
  throw new Error("finish_merge must be called exactly once");
453
540
  validateMergeDispositions(ids, decisions);
454
- if (snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"]))
455
- throw new Error("Unresolved Git conflicts remain in the source checkout");
541
+ if (target && await git(target, ["ls-files", "--unmerged"]))
542
+ throw new Error("Unresolved Git conflicts remain in the target workspace");
456
543
  resolutions = structuredClone(decisions);
544
+ const workspace = this.records.get(this.id(request));
545
+ workspace.dispositions = structuredClone(decisions);
546
+ this.report(workspace);
457
547
  finished = true;
458
548
  },
459
549
  complete: async (success) => {
460
550
  try {
461
- const errors = [];
462
- // Check again after all tracked writes have drained: an adapter can
463
- // invoke more tools after finish_merge in the same model response.
464
- if (success && snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"])) {
465
- success = false;
466
- errors.push(new Error("Unresolved Git conflicts remain in the source checkout"));
467
- }
468
- for (const id of ids) {
469
- const decision = success && finished ? resolutions.find(value => value.nodeId === id) : undefined;
470
- try {
471
- await this.release(id, decision?.disposition ?? "archived", decision?.reason ?? "Merge agent did not complete; changes preserved in checkpointRef");
472
- }
473
- catch (error) {
474
- errors.push(error);
475
- }
476
- }
477
- this.snapshots.delete(request.execution.runId);
478
- if (errors.length)
479
- throw new AggregateError(errors, "Some merge sources could not be cleaned up; their paths are retained in workspaces");
551
+ if (target && await git(target, ["ls-files", "--unmerged"]))
552
+ throw new Error("Unresolved Git conflicts remain in the target workspace");
480
553
  if (success && (!finished || resolutions.some(value => value.disposition === "archived")))
481
- throw new Error("Merge agent did not integrate or explicitly discard every source; remaining changes were archived");
554
+ throw new Error("Merge agent did not integrate or explicitly discard every source");
555
+ if (success)
556
+ this.mergeParents.set(this.id(request), resolutions.filter(value => value.disposition === "integrated").map(value => this.records.get(value.executionId).checkpointCommit));
482
557
  }
483
558
  finally {
484
- unlock();
559
+ try {
560
+ await this.seal(request);
561
+ }
562
+ finally {
563
+ unlock();
564
+ }
485
565
  }
486
566
  },
487
567
  };
@@ -492,3 +572,7 @@ export class GitWorkspaces {
492
572
  }
493
573
  }
494
574
  }
575
+ export class WorkspaceInputError extends Error {
576
+ }
577
+ export class WorkspaceCheckpointError extends Error {
578
+ }
package/docs/benchmark.md CHANGED
@@ -13,6 +13,9 @@ garbage collection is requested before each run. Deadlines are disabled.
13
13
 
14
14
  ## Sample run — 2026-09-27
15
15
 
16
+ These measurements predate 0.2 execution history and live controls; rerun before
17
+ using them to estimate current overhead.
18
+
16
19
  Environment: Apple M5, darwin/arm64, Node v25.9.0.
17
20
  The following subset uses concurrency 4:
18
21
 
@@ -5,8 +5,8 @@
5
5
  | Core runtime | Node.js 22+, ESM imports, TypeScript declarations, no runtime dependencies |
6
6
  | Pi package | Node.js 22.19+, Pi 0.87.1 is the pinned validation target |
7
7
  | CI | Core minimum Node 22.0; both packages on Node 22.19 and 24 on Linux, macOS, Windows |
8
- | OpenAI-compatible runner | Chat Completions text and function-tool calls; decisions and merge nodes require tool calling |
9
- | Browsers / CommonJS | No supported browser build or CommonJS entry point in 0.1 |
8
+ | OpenAI-compatible runner | Chat Completions text and function-tool calls; decisions and merge/integrate nodes require tool calling |
9
+ | Browsers / CommonJS | No supported browser build or CommonJS entry point in 0.2 |
10
10
 
11
11
  The CI matrix describes configured checks; see actual workflow results for each
12
12
  commit. Offline HTTP fixtures validate the adapter contract. They do not prove
@@ -19,7 +19,8 @@ The wildcard is a loader/distribution convention, **not a claim that every Pi
19
19
  version works**. Test the whole Pi suite before updating the supported target.
20
20
  The offline host compatibility test loads the extension into a real Pi session
21
21
  with an in-memory model provider. It checks worker prompt/tool normalization and
22
- exactly one automatic continuation when a job finishes during `agent_settled`.
22
+ exactly one automatic continuation per node/job reminder delivered during
23
+ `agent_settled`, including retrieval of a node output while its job still runs.
23
24
  This covers the Pi 0.86/0.87 transcript and settling changes without provider
24
25
  credentials or network model calls. Version 0.1.0 was originally validated with
25
26
  Pi 0.85.1; the current checkout's pinned validation target is 0.87.1.
@@ -29,6 +30,46 @@ its runtime and does not need a source checkout.
29
30
 
30
31
  Git must be installed for workspace execution inside a Git checkout. Non-Git
31
32
  text-only runs do not require Git workspace management.
33
+ ## Migrating from 0.1 to 0.2
34
+
35
+ This release deliberately changes the execution and workspace contracts:
36
+
37
+ - Replace old source-checkout `merge` nodes with `integrate`. The new `merge`
38
+ combines inputs in a fresh isolated worktree.
39
+ - Add explicit integrate nodes where a graph previously relied on automatic
40
+ final integration. Runs no longer append `__braid_merge__`.
41
+ - Treat node IDs as definition identities. Read exact historical instances from
42
+ `executions[executionId]`; `nodes[nodeId]` returns the latest instance.
43
+ - Key workspace lookups and `finish_merge` dispositions by `executionId`.
44
+ Source workspaces remain reusable; target `dispositions` records selections.
45
+ - Read-only nodes now use isolated predecessor snapshots in Git. They receive
46
+ checkpoints and cleanup events. All non-Git allocations also report metadata.
47
+ - Set `requireSuccess: true` on nodes whose failures must fail the whole job.
48
+ Optional failures now retain their error and artifacts while allowing recovery.
49
+ - Handle repeated instance events and the new revision/loop/gate events. Use
50
+ `braid_status({jobId, executionId})` for precise Pi result retrieval.
51
+ - Set finite `maxExecutions` (default 1000) and per-loop `maxIterations`. Deadlines
52
+ and execution limits span live graph updates and paused gates.
53
+
54
+ Use `startBraid` for live updates or pause/resume. Existing `braid` callers can
55
+ still await a static graph result. See [execution control](execution-control.md)
56
+ for the full scheduling and update contract; there is no old-behavior mode.
57
+
58
+ Graph submissions may include `promptTemplates` and template-reference prompts.
59
+ `BraidInput.nodes` uses `BraidInputNode`, whose `NodePrompt` accepts a string or
60
+ `PromptTemplateReference`. Code inspecting unrendered input prompts must narrow
61
+ that union; use `satisfies BraidInput` to preserve the inferred types of a literal
62
+ graph. Runner-facing `BraidNode` and `ModelRequest.node` retain string prompts,
63
+ so existing runners need no template support. Rendering happens in core before
64
+ execution, including for Pi submissions. Older versions reject template inputs.
65
+
66
+ All node types accept optional `notifyOnCompletion: boolean` (default `false`).
67
+ Core captures this preference per execution; Pi implements parent reminders for successful and failed executions.
68
+ Skipped nodes do not notify. Pi's optional `braid_status` `nodeId` parameter
69
+ requires `jobId` and retrieves full intermediate node results. Whole-job queries and reminders remain available. Paused executions send a
70
+ separate gate reminder; when both preferences are enabled it supplies the single
71
+ completion reminder for that instance. Cancellation still sends failure reminders
72
+ for opted-in running instances.
32
73
 
33
74
  ## Versioning
34
75
 
package/docs/examples.md CHANGED
@@ -8,7 +8,8 @@ Replace a runner with the documented provider adapter for live use.
8
8
  | --- | --- | --- |
9
9
  | [Design comparison](../examples/basic.ts) | `npm run demo` | Decision chooses comparison, benefits/risks run concurrently, answer synthesizes them; brief branch skips |
10
10
  | [Code review](../examples/code-review.ts) | `npx tsx examples/code-review.ts` | Correctness and test reviews run independently; final review lists both findings |
11
- | [Failure handling](../examples/failure-handling.ts) | `npx tsx examples/failure-handling.ts` | `failed`; join completes with local findings and explicit `remote` error context |
11
+ | [Failure handling](../examples/failure-handling.ts) | `npx tsx examples/failure-handling.ts` | `completed`; optional failure stays in history and join completes with local findings and explicit `remote` error context |
12
+ | [Loop and live update](../examples/execution-control.ts) | `npx tsx examples/execution-control.ts` | Two refinement rounds, a pause, and an atomic graph update/resume |
12
13
  | [Custom runner](../examples/custom-runner.ts) | `npx tsx examples/custom-runner.ts` | `Received direct predecessors: route` |
13
14
 
14
15
  These text-only examples use temporary non-Git directories. The code-review