@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.
@@ -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
- /** Writable workers share a snapshot; explicit read-only workers inspect the live cwd. */
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
  }
@@ -140,7 +141,7 @@ export class GitWorkspaces {
140
141
  }
141
142
  return realpath(sourceRoot);
142
143
  }
143
- async snapshot() {
144
+ async snapshot(extraFiles = []) {
144
145
  const sourceRoot = await this.sourceRoot();
145
146
  if (!sourceRoot)
146
147
  return undefined;
@@ -179,6 +180,21 @@ export class GitWorkspaces {
179
180
  // A temporary index captures tracked edits/deletions and non-ignored new files
180
181
  // without changing the parent's real index, branch, or working files.
181
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);
182
198
  const tree = await git(sourceRoot, ["write-tree"], options);
183
199
  const baseTree = baseCommit ? await git(sourceRoot, ["rev-parse", `${baseCommit}^{tree}`]) : undefined;
184
200
  const snapshotCommit = tree === baseTree ? baseCommit : await git(sourceRoot, [
@@ -203,59 +219,78 @@ export class GitWorkspaces {
203
219
  await rm(`${index}.lock`, { force: true });
204
220
  }
205
221
  }
206
- async prepare(request) {
207
- request.signal.throwIfAborted();
208
- if (request.node.type !== "merge" && request.node.workspace === "read-only") {
209
- const sourceRoot = await this.sourceRoot();
210
- request.signal.throwIfAborted();
211
- const workspace = {
212
- nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready",
213
- ...(sourceRoot ? { sourceRoot } : {}),
214
- };
215
- this.report(workspace);
216
- return workspace;
217
- }
218
- let pending = this.snapshots.get(request.execution.runId);
219
- if (!pending) {
220
- // Do not bind the shared snapshot to one node's cancellation signal.
221
- pending = this.snapshot();
222
- 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;
223
253
  }
224
- const snapshot = await pending;
254
+ throw new WorkspaceInputError("Multiple independent predecessor snapshots require an explicit merge node");
255
+ }
256
+ async prepare(request, merge = false) {
225
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);
226
261
  if (!snapshot) {
227
262
  const workspace = {
228
- 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",
229
264
  };
230
265
  this.report(workspace);
231
266
  return workspace;
232
267
  }
268
+ const commit = merge ? snapshot.snapshotCommit : await this.inputCommit(request, snapshot);
269
+ request.signal.throwIfAborted();
233
270
  const worktreeRoot = join(snapshot.directory, crypto.randomUUID());
271
+ const readOnly = request.node.type !== "merge" && request.node.type !== "integrate" && request.node.workspace === "read-only";
234
272
  const workspace = {
235
- nodeId: request.node.id, mode: "worktree", state: "preparing",
273
+ nodeId: request.node.id, executionId, mode: readOnly ? "read-only" : "worktree", state: "preparing",
236
274
  sourceRoot: snapshot.sourceRoot, worktreeRoot,
237
275
  workingDirectory: resolve(worktreeRoot, snapshot.cwdSuffix),
238
- snapshotCommit: snapshot.snapshotCommit,
276
+ snapshotCommit: commit,
239
277
  ...(snapshot.baseCommit ? { baseCommit: snapshot.baseCommit } : {}),
240
278
  };
241
- this.locations.set(request.node.id, snapshot);
279
+ this.locations.set(executionId, snapshot);
242
280
  this.report(workspace);
243
281
  try {
244
- // Finish registration even if cancellation arrives during creation. Report
245
- // the retained path before observing cancellation, so it can be recovered.
246
282
  await withWorktreeLock(snapshot.commonDirectory, async () => {
247
283
  request.signal.throwIfAborted();
248
- await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, snapshot.snapshotCommit], {
284
+ await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, commit], {
249
285
  hooksDirectory: snapshot.hooksDirectory,
250
286
  });
251
287
  });
252
- // The original cwd may be an empty or ignored directory absent from Git.
253
288
  await mkdir(workspace.workingDirectory, { recursive: true });
254
289
  workspace.state = "ready";
255
290
  }
256
291
  catch (error) {
257
292
  workspace.state = "failed";
258
- 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 });
259
294
  }
260
295
  finally {
261
296
  this.report(workspace);
@@ -263,19 +298,58 @@ export class GitWorkspaces {
263
298
  request.signal.throwIfAborted();
264
299
  return workspace;
265
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
+ }
266
340
  all() {
267
- return Object.fromEntries([...this.records].map(([id, workspace]) => [id, { ...workspace }]));
341
+ return structuredClone(Object.fromEntries(this.records));
268
342
  }
269
343
  pending() {
270
344
  return [...this.records.values()]
271
- .filter(workspace => workspace.mode === "worktree" && ["preparing", "ready", "failed"].includes(workspace.state))
272
- .map(workspace => workspace.nodeId);
345
+ .filter(workspace => workspace.worktreeRoot && ["preparing", "ready", "failed"].includes(workspace.state))
346
+ .map(workspace => workspace.executionId ?? workspace.nodeId);
273
347
  }
274
348
  /** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
275
349
  async checkpoint(workspace) {
276
350
  if (workspace.checkpointRef)
277
351
  return;
278
- const location = this.locations.get(workspace.nodeId);
352
+ const location = this.locations.get(workspace.executionId ?? workspace.nodeId);
279
353
  const cwd = workspace.worktreeRoot;
280
354
  const options = { hooksDirectory: location.hooksDirectory };
281
355
  // A Git tree stores only a submodule commit, never files written inside it.
@@ -294,9 +368,11 @@ export class GitWorkspaces {
294
368
  }
295
369
  await git(cwd, ["add", "--force", "--all", "--", "."], options);
296
370
  const tree = await git(cwd, ["write-tree"], options);
297
- const commit = tree === location.snapshotTree ? workspace.snapshotCommit : 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, [
298
374
  "-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
299
- "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}`,
300
376
  ], options);
301
377
  const suffix = createHash("sha256").update(cwd).digest("hex");
302
378
  const ref = `refs/braid/checkpoints/${suffix}`;
@@ -305,27 +381,6 @@ export class GitWorkspaces {
305
381
  workspace.checkpointRef = ref;
306
382
  this.report(workspace);
307
383
  }
308
- /** Run only after declared nodes settle, so consumers retain their source paths. */
309
- async discardUnchanged() {
310
- const errors = [];
311
- for (const id of this.pending()) {
312
- const workspace = this.records.get(id);
313
- // Incomplete preparation must follow the existing failure/recovery path.
314
- // A failed invocation with a successfully prepared workspace is still ready.
315
- if (workspace.state !== "ready")
316
- continue;
317
- try {
318
- await this.checkpoint(workspace);
319
- if (workspace.checkpointCommit === workspace.snapshotCommit)
320
- await this.release(id, "discarded", "No changes from snapshot");
321
- }
322
- catch (error) {
323
- errors.push(error);
324
- }
325
- }
326
- if (errors.length)
327
- throw new AggregateError(errors, "Some workspaces could not be checked or removed; their paths are retained in workspaces");
328
- }
329
384
  async release(id, disposition, reason) {
330
385
  const workspace = this.records.get(id);
331
386
  const location = this.locations.get(id);
@@ -376,7 +431,7 @@ export class GitWorkspaces {
376
431
  for (const snapshot of this.allocated) {
377
432
  // Ownership is explicit; a POSIX path prefix would miss retained Windows
378
433
  // worktrees and recursively delete data after checkpoint/cleanup failure.
379
- 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 &&
380
435
  ["ready", "preparing", "failed"].includes(workspace.state));
381
436
  if (!live) {
382
437
  try {
@@ -398,8 +453,8 @@ export class GitWorkspaces {
398
453
  throw new Error("Git input must be a string");
399
454
  const mutate = ["add", "commit", "merge", "cherry-pick", "apply", "restore"];
400
455
  const command = args[0];
401
- if (!gitCommands(request.node.type === "merge").includes(command))
402
- 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");
403
458
  const blockedOptions = ["--output", "--ext-diff", "--textconv", "--unsafe-paths", "--directory", "--strategy", "--gpg-sign", "--work-tree", "--git-dir"];
404
459
  // Git accepts abbreviated long options (e.g. --out) and attached short
405
460
  // values (-scustom). Apply the same boundary to those spellings.
@@ -411,8 +466,8 @@ export class GitWorkspaces {
411
466
  throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
412
467
  const workspace = request.workspace;
413
468
  const cwd = workspace.mode === "read-only" ? workspace.workingDirectory
414
- : workspace.mode === "merge" ? workspace.sourceRoot : workspace.worktreeRoot;
415
- const location = this.locations.get(request.node.id);
469
+ : workspace.mode === "integrate" ? workspace.sourceRoot : workspace.worktreeRoot;
470
+ const location = this.locations.get(this.id(request));
416
471
  const actualArgs = [command,
417
472
  ...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
418
473
  ...args.slice(1),
@@ -437,51 +492,44 @@ export class GitWorkspaces {
437
492
  return mutate.includes(command) ? request.withWorkspaceWrite(execute) : execute();
438
493
  }
439
494
  async beginMerge(request, sourceIds) {
440
- // Discover the source without allocating a merge worktree: agents integrate
441
- // directly in the caller's checkout, under a repository-scoped mutex.
442
- let pending = this.snapshots.get(request.execution.runId);
443
- if (!pending) {
444
- pending = this.snapshot();
445
- this.snapshots.set(request.execution.runId, pending);
446
- }
447
- let snapshot = await pending;
448
- 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) : () => { };
449
499
  let finished = false;
450
- let resolutions;
451
- 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);
452
502
  try {
453
503
  request.signal.throwIfAborted();
454
- if (snapshot) {
455
- snapshot = await this.snapshot();
456
- 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;
457
516
  }
458
- for (const id of ids)
459
- await this.checkpoint(this.records.get(id));
460
- const workspace = snapshot ? {
461
- nodeId: request.node.id, mode: "merge", workingDirectory: snapshot.sourceRoot,
462
- sourceRoot: snapshot.sourceRoot, snapshotCommit: snapshot.snapshotCommit, state: "ready",
463
- } : { nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready" };
464
- if (snapshot) {
465
- workspace.backupRef = `refs/braid/merge-backups/${crypto.randomUUID()}`;
466
- await git(snapshot.sourceRoot, ["update-ref", workspace.backupRef, snapshot.snapshotCommit], { hooksDirectory: snapshot.hooksDirectory });
517
+ else {
518
+ request.workspace = await this.prepare(request, true);
467
519
  }
468
- request.workspace = workspace;
469
- if (snapshot)
470
- this.locations.set(request.node.id, snapshot);
471
- this.report(workspace);
472
- 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;
473
521
  const sources = [];
474
522
  for (const id of ids) {
475
523
  const source = this.records.get(id);
476
- 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, "--"];
477
525
  const count = Math.max(1, ids.length);
478
526
  const files = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--name-only", "-z", "--"], Math.floor(8_000 / count));
479
527
  const stat = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--stat", "--"], Math.floor(4_000 / count));
480
528
  const diff = await gitPreview(source.sourceRoot, diffArgs, Math.min(6_000, Math.floor(24_000 / count)));
481
- // Truncated NUL output must not invent a partial filename.
482
529
  const names = files.text.slice(0, files.text.lastIndexOf("\0") + 1).split("\0").filter(Boolean);
483
530
  sources.push({ ...source, changes: { files: names, filesTruncated: files.truncated, stat, diff } });
484
531
  }
532
+ const sourceStatus = integrating && target ? await gitPreview(target, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
485
533
  return {
486
534
  sources,
487
535
  ...(sourceStatus ? { sourceStatus: { ...sourceStatus, dirty: sourceStatus.text.length > 0 || sourceStatus.truncated } } : {}),
@@ -490,37 +538,30 @@ export class GitWorkspaces {
490
538
  if (finished)
491
539
  throw new Error("finish_merge must be called exactly once");
492
540
  validateMergeDispositions(ids, decisions);
493
- if (snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"]))
494
- 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");
495
543
  resolutions = structuredClone(decisions);
544
+ const workspace = this.records.get(this.id(request));
545
+ workspace.dispositions = structuredClone(decisions);
546
+ this.report(workspace);
496
547
  finished = true;
497
548
  },
498
549
  complete: async (success) => {
499
550
  try {
500
- const errors = [];
501
- // Check again after all tracked writes have drained: an adapter can
502
- // invoke more tools after finish_merge in the same model response.
503
- if (success && snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"])) {
504
- success = false;
505
- errors.push(new Error("Unresolved Git conflicts remain in the source checkout"));
506
- }
507
- for (const id of ids) {
508
- const decision = success && finished ? resolutions.find(value => value.nodeId === id) : undefined;
509
- try {
510
- await this.release(id, decision?.disposition ?? "archived", decision?.reason ?? "Merge agent did not complete; changes preserved in checkpointRef");
511
- }
512
- catch (error) {
513
- errors.push(error);
514
- }
515
- }
516
- this.snapshots.delete(request.execution.runId);
517
- if (errors.length)
518
- 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");
519
553
  if (success && (!finished || resolutions.some(value => value.disposition === "archived")))
520
- 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));
521
557
  }
522
558
  finally {
523
- unlock();
559
+ try {
560
+ await this.seal(request);
561
+ }
562
+ finally {
563
+ unlock();
564
+ }
524
565
  }
525
566
  },
526
567
  };
@@ -531,3 +572,7 @@ export class GitWorkspaces {
531
572
  }
532
573
  }
533
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
 
@@ -3,10 +3,10 @@
3
3
  | Surface | Supported / tested contract |
4
4
  | --- | --- |
5
5
  | Core runtime | Node.js 22+, ESM imports, TypeScript declarations, no runtime dependencies |
6
- | Pi package | Node.js 22.19+, Pi 0.87.1 is the pinned validation target |
6
+ | Pi package | Node.js 22.19+, Pi 1.0.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
@@ -14,26 +14,62 @@ that every provider advertising OpenAI compatibility supports its tool schema.
14
14
  Run a small opt-in live test with your chosen provider before relying on it.
15
15
 
16
16
  Pi's upstream packaging guide requires `*` peer dependencies for packages the
17
- host provides. Braid follows that convention and pins dev dependencies to 0.87.1.
17
+ host provides. Braid follows that convention and pins dev dependencies to 1.0.1.
18
18
  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
- Pi 0.85.1; the current checkout's pinned validation target is 0.87.1.
26
+ Pi 0.85.1; the current checkout's pinned validation target is 1.0.1.
26
27
  The Pi npm package declares an exact dependency on the matching `@chrok/braid`
27
28
  release. npm installs the core automatically; Pi does not bundle another copy of
28
29
  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.
32
- Execute/decision nodes can opt into `workspace: "read-only"`; omitted or
33
- `"worktree"` values preserve the existing allocation behavior. Merge nodes reject
34
- this field. Explicit read-only nodes report workspace metadata/events even when
35
- the entire run is read-only; implicit non-Git runs keep their existing shapes.
36
- Older versions reject the new field during validation.
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.
37
73
 
38
74
  ## Versioning
39
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