@flyingrobots/graft 0.13.0 → 0.14.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli/daemon-status-model.d.ts +1 -0
  3. package/dist/cli/daemon-status-model.d.ts.map +1 -1
  4. package/dist/cli/daemon-status-model.js +1 -0
  5. package/dist/cli/daemon-status-model.js.map +1 -1
  6. package/dist/cli/daemon-status-render.d.ts.map +1 -1
  7. package/dist/cli/daemon-status-render.js +1 -0
  8. package/dist/cli/daemon-status-render.js.map +1 -1
  9. package/dist/contracts/output-schema-fragments.d.ts +2 -0
  10. package/dist/contracts/output-schema-fragments.d.ts.map +1 -1
  11. package/dist/contracts/output-schema-fragments.js +1 -0
  12. package/dist/contracts/output-schema-fragments.js.map +1 -1
  13. package/dist/contracts/output-schema-mcp.d.ts +1 -0
  14. package/dist/contracts/output-schema-mcp.d.ts.map +1 -1
  15. package/dist/contracts/output-schema-meta.js +1 -1
  16. package/dist/contracts/output-schema-meta.js.map +1 -1
  17. package/dist/contracts/output-schemas.d.ts.map +1 -1
  18. package/dist/contracts/output-schemas.js +1 -0
  19. package/dist/contracts/output-schemas.js.map +1 -1
  20. package/dist/mcp/control-plane/types.d.ts +1 -0
  21. package/dist/mcp/control-plane/types.d.ts.map +1 -1
  22. package/dist/mcp/daemon-server.d.ts +4 -0
  23. package/dist/mcp/daemon-server.d.ts.map +1 -1
  24. package/dist/mcp/daemon-server.js +37 -12
  25. package/dist/mcp/daemon-server.js.map +1 -1
  26. package/dist/mcp/daemon-session-host.d.ts +2 -2
  27. package/dist/mcp/daemon-session-host.d.ts.map +1 -1
  28. package/dist/mcp/daemon-session-host.js +130 -59
  29. package/dist/mcp/daemon-session-host.js.map +1 -1
  30. package/dist/mcp/persisted-local-history.d.ts +5 -0
  31. package/dist/mcp/persisted-local-history.d.ts.map +1 -1
  32. package/dist/mcp/persisted-local-history.js +5 -1
  33. package/dist/mcp/persisted-local-history.js.map +1 -1
  34. package/dist/mcp/server-context.d.ts.map +1 -1
  35. package/dist/mcp/server-context.js +28 -11
  36. package/dist/mcp/server-context.js.map +1 -1
  37. package/dist/mcp/server-invocation.d.ts.map +1 -1
  38. package/dist/mcp/server-invocation.js +15 -7
  39. package/dist/mcp/server-invocation.js.map +1 -1
  40. package/dist/mcp/server.d.ts +3 -2
  41. package/dist/mcp/server.d.ts.map +1 -1
  42. package/dist/mcp/server.js +22 -10
  43. package/dist/mcp/server.js.map +1 -1
  44. package/dist/mcp/warp-pool.d.ts +45 -6
  45. package/dist/mcp/warp-pool.d.ts.map +1 -1
  46. package/dist/mcp/warp-pool.js +128 -19
  47. package/dist/mcp/warp-pool.js.map +1 -1
  48. package/dist/mcp/workspace-router-model.d.ts +4 -1
  49. package/dist/mcp/workspace-router-model.d.ts.map +1 -1
  50. package/dist/mcp/workspace-router-model.js.map +1 -1
  51. package/dist/mcp/workspace-router-runtime.d.ts +15 -5
  52. package/dist/mcp/workspace-router-runtime.d.ts.map +1 -1
  53. package/dist/mcp/workspace-router-runtime.js +47 -7
  54. package/dist/mcp/workspace-router-runtime.js.map +1 -1
  55. package/dist/mcp/workspace-router.d.ts +20 -10
  56. package/dist/mcp/workspace-router.d.ts.map +1 -1
  57. package/dist/mcp/workspace-router.js +254 -125
  58. package/dist/mcp/workspace-router.js.map +1 -1
  59. package/docs/MCP.md +54 -0
  60. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, W
11
11
  import { resolveWorkspaceRequest, stableWorkspaceId } from "./workspace-router-resolution.js";
12
12
  import { DEFAULT_WARP_WRITER_ID } from "../warp/writer-id.js";
13
13
  import { GovernorTracker } from "../session/tracker.js";
14
- import { boundWorkspaceStatus, buildPersistedLocalHistoryContext, buildPersistedLocalHistoryContextFromExecution, buildPersistedLocalHistoryGraphContext, buildWorkspaceCausalContext, createBoundWorkspace, createWorkspaceSlice, nextBindingSliceDir, resolveCheckoutBoundaryHookEvent, unboundWorkspaceStatus, } from "./workspace-router-runtime.js";
14
+ import { boundWorkspaceStatus, buildPersistedLocalHistoryContext, buildPersistedLocalHistoryContextFromExecution, buildPersistedLocalHistoryGraphContext, buildWorkspaceCausalContext, createBoundWorkspace, createWorkspaceWarpLease, createWorkspaceSlice, nextBindingSliceDir, resolveCheckoutBoundaryHookEvent, unboundWorkspaceStatus, } from "./workspace-router-runtime.js";
15
15
  export { DEFAULT_DAEMON_CAPABILITY_PROFILE, DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, WorkspaceCapabilityDeniedError, WorkspaceResolutionError, WorkspaceRouteUnauthorizedError, } from "./workspace-router-model.js";
16
16
  export { resolveWorkspaceRequest } from "./workspace-router-resolution.js";
17
17
  const MAX_ROUTED_BINDINGS = 8;
@@ -28,6 +28,7 @@ function workspaceCapabilityProfilesEqual(left, right) {
28
28
  export class WorkspaceRouter {
29
29
  options;
30
30
  bindingCounter = 0;
31
+ executionCounter = 0;
31
32
  sliceIdCounter = 0;
32
33
  currentSlice;
33
34
  currentBinding = null;
@@ -35,6 +36,8 @@ export class WorkspaceRouter {
35
36
  openedWorkspaces = new Map();
36
37
  routedBindings = new Map();
37
38
  routedBindingInitializations = new Map();
39
+ bindingCommitTail = Promise.resolve();
40
+ warpLeaseRelease = null;
38
41
  constructor(options) {
39
42
  this.options = options;
40
43
  const initialProjectRoot = options.mode === "repo_local" ? options.projectRoot : undefined;
@@ -78,14 +81,13 @@ export class WorkspaceRouter {
78
81
  };
79
82
  const currentBinding = await this.createBoundWorkspace(initialWorkspace, this.options.graftDir, DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, undefined, this.currentSlice);
80
83
  const currentRepoState = currentBinding.slice.repoState;
81
- if (currentRepoState === null) {
84
+ if (currentRepoState === null)
82
85
  throw new WorkspaceBindingRequiredError("workspace");
83
- }
84
86
  await currentRepoState.initialize();
85
- await this.options.persistedLocalHistory.noteBinding({
87
+ await this.withBindingGraph(currentBinding, (currentGraph) => this.options.persistedLocalHistory.noteBinding({
86
88
  current: this.buildPersistedLocalHistoryContext(currentBinding, currentRepoState.getState()),
87
- currentGraph: await this.buildPersistedLocalHistoryGraphContext(currentBinding),
88
- });
89
+ currentGraph,
90
+ }));
89
91
  this.currentBinding = currentBinding;
90
92
  this.noteOpenedWorkspace(initialWorkspace, DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, "startup", true);
91
93
  })();
@@ -118,25 +120,42 @@ export class WorkspaceRouter {
118
120
  getPathResolver() {
119
121
  return this.requireBinding().resolvePath;
120
122
  }
121
- getWarp() {
122
- return this.requireBinding().getWarp();
123
+ async withWarp(operation) {
124
+ const execution = this.captureExecutionContext();
125
+ try {
126
+ return await operation(await execution.getWarp());
127
+ }
128
+ finally {
129
+ await execution.releaseWarpLease();
130
+ }
131
+ }
132
+ releaseWarpLeases() {
133
+ if (this.warpLeaseRelease !== null)
134
+ return this.warpLeaseRelease;
135
+ // Bindings own metadata only. Already admitted operations release their
136
+ // own capabilities; retirement must not revoke those capabilities.
137
+ const pending = [this.initialization ?? Promise.resolve(), this.bindingCommitTail, ...this.routedBindingInitializations.values()];
138
+ this.warpLeaseRelease = Promise.allSettled(pending).then(() => undefined);
139
+ return this.warpLeaseRelease;
123
140
  }
124
- async observeRepoState() {
125
- const binding = this.requireBinding();
126
- const repoState = this.requireRepoState();
127
- const previousObservation = repoState.getState();
128
- const nextObservation = await repoState.observe();
141
+ async observeRepoState(execution = null) {
142
+ const scope = this.resolveWorkspaceHistoryScope(execution);
143
+ if (scope === null) {
144
+ throw new WorkspaceBindingRequiredError("workspace");
145
+ }
146
+ const previousObservation = scope.repoState.getState();
147
+ const nextObservation = await scope.repoState.observe();
129
148
  const checkoutBoundaryHookEvent = previousObservation.checkoutEpoch !== nextObservation.checkoutEpoch
130
- ? await this.resolveCheckoutBoundaryHookEvent(binding, previousObservation.observedAt, nextObservation)
149
+ ? await this.resolveCheckoutBoundaryHookEvent(scope, previousObservation.observedAt, nextObservation)
131
150
  : null;
132
- const previousContext = this.buildPersistedLocalHistoryContext(binding, previousObservation);
133
- const nextContext = this.buildPersistedLocalHistoryContext(binding, nextObservation, checkoutBoundaryHookEvent);
151
+ const previousContext = scope.buildHistoryContext(previousObservation);
152
+ const nextContext = scope.buildHistoryContext(nextObservation, checkoutBoundaryHookEvent);
134
153
  if (previousContext.checkoutEpochId !== nextContext.checkoutEpochId) {
135
- await this.options.persistedLocalHistory.noteCheckoutBoundary({
154
+ await scope.withGraph((graph) => this.options.persistedLocalHistory.noteCheckoutBoundary({
136
155
  previous: previousContext,
137
156
  current: nextContext,
138
- graph: await this.buildPersistedLocalHistoryGraphContext(binding),
139
- });
157
+ graph,
158
+ }));
140
159
  }
141
160
  }
142
161
  getRepoState() {
@@ -231,9 +250,9 @@ export class WorkspaceRouter {
231
250
  ...(result.error !== undefined ? { error: result.error } : {}),
232
251
  };
233
252
  }
234
- async getPersistedLocalHistorySummary() {
235
- const binding = this.currentBinding;
236
- if (binding?.slice.repoState === null || binding === null) {
253
+ async getPersistedLocalHistorySummary(execution = null) {
254
+ const scope = this.resolveWorkspaceHistoryScope(execution);
255
+ if (scope === null) {
237
256
  return {
238
257
  availability: "none",
239
258
  persistence: "persisted_local_history",
@@ -281,43 +300,43 @@ export class WorkspaceRouter {
281
300
  nextAction: "bind_workspace_to_begin_local_history",
282
301
  };
283
302
  }
284
- const status = this.getStatus();
285
- const repoState = binding.slice.repoState.getState();
286
- const causalContext = this.buildCausalContext(binding, repoState);
287
- const graph = await this.buildPersistedLocalHistoryGraphContext(binding);
288
- let summary = await this.options.persistedLocalHistory.summarize(status, causalContext, graph);
289
- if (repoState.semanticTransition !== null) {
290
- await this.options.persistedLocalHistory.noteSemanticTransitionObservation({
291
- current: this.buildPersistedLocalHistoryContext(binding, repoState),
292
- semanticTransition: repoState.semanticTransition,
293
- transition: repoState.lastTransition,
294
- attribution: summary.attribution,
295
- graph,
296
- });
297
- summary = await this.options.persistedLocalHistory.summarize(status, causalContext, graph);
298
- }
299
- const stagedTarget = buildRuntimeStagedTarget(status, causalContext, repoState, summary.attribution);
300
- if (stagedTarget.availability === "full_file") {
301
- await this.options.persistedLocalHistory.noteStageObservation({
302
- current: this.buildPersistedLocalHistoryContext(binding, repoState),
303
- stagedTarget,
304
- attribution: summary.attribution,
305
- graph,
306
- });
307
- return this.options.persistedLocalHistory.summarize(status, causalContext, graph);
308
- }
309
- return summary;
303
+ const repoState = scope.repoState.getState();
304
+ const causalContext = scope.getCausalContext();
305
+ return scope.withGraph(async (graph) => {
306
+ let summary = await this.options.persistedLocalHistory.summarize(scope.status, causalContext, graph);
307
+ if (repoState.semanticTransition !== null) {
308
+ await this.options.persistedLocalHistory.noteSemanticTransitionObservation({
309
+ current: scope.buildHistoryContext(repoState),
310
+ semanticTransition: repoState.semanticTransition,
311
+ transition: repoState.lastTransition,
312
+ attribution: summary.attribution,
313
+ graph,
314
+ });
315
+ summary = await this.options.persistedLocalHistory.summarize(scope.status, causalContext, graph);
316
+ }
317
+ const stagedTarget = buildRuntimeStagedTarget(scope.status, causalContext, repoState, summary.attribution);
318
+ if (stagedTarget.availability === "full_file") {
319
+ await this.options.persistedLocalHistory.noteStageObservation({
320
+ current: scope.buildHistoryContext(repoState),
321
+ stagedTarget,
322
+ attribution: summary.attribution,
323
+ graph,
324
+ });
325
+ return this.options.persistedLocalHistory.summarize(scope.status, causalContext, graph);
326
+ }
327
+ return summary;
328
+ });
310
329
  }
311
- async getRepoConcurrencySummary() {
312
- const binding = this.currentBinding;
313
- if (binding?.slice.repoState === null || binding === null) {
330
+ async getRepoConcurrencySummary(execution = null) {
331
+ const scope = this.resolveWorkspaceHistoryScope(execution);
332
+ if (scope === null) {
314
333
  return null;
315
334
  }
316
- return this.options.persistedLocalHistory.summarizeRepoConcurrency(this.getStatus(), await this.buildPersistedLocalHistoryGraphContext(binding));
335
+ return scope.withGraph((graph) => this.options.persistedLocalHistory.summarizeRepoConcurrency(scope.status, graph));
317
336
  }
318
- async getPersistedLocalActivityWindow(limit) {
319
- const binding = this.currentBinding;
320
- if (binding?.slice.repoState === null || binding === null) {
337
+ async getPersistedLocalActivityWindow(limit, execution = null) {
338
+ const scope = this.resolveWorkspaceHistoryScope(execution);
339
+ if (scope === null) {
321
340
  return {
322
341
  historyPath: null,
323
342
  limit,
@@ -326,36 +345,49 @@ export class WorkspaceRouter {
326
345
  items: [],
327
346
  };
328
347
  }
329
- await this.getPersistedLocalHistorySummary();
330
- const status = this.getStatus();
331
- const repoState = binding.slice.repoState.getState();
332
- const causalContext = this.buildCausalContext(binding, repoState);
333
- const graph = await this.buildPersistedLocalHistoryGraphContext(binding);
334
- return this.options.persistedLocalHistory.listRecentActivity(status, causalContext, limit, graph);
348
+ await this.getPersistedLocalHistorySummary(execution);
349
+ const causalContext = scope.getCausalContext();
350
+ return scope.withGraph((graph) => this.options.persistedLocalHistory.listRecentActivity(scope.status, causalContext, limit, graph));
335
351
  }
336
- getWorkspaceOverlayFooting() {
337
- const binding = this.currentBinding;
338
- if (binding?.slice.repoState === null || binding === null) {
352
+ getWorkspaceOverlayFooting(execution = null) {
353
+ const scope = this.resolveWorkspaceHistoryScope(execution);
354
+ if (scope === null) {
339
355
  return Promise.resolve(null);
340
356
  }
341
- return buildRuntimeWorkspaceOverlayFooting(this.options.fs, this.options.git, binding.worktreeRoot, binding.gitCommonDir, binding.slice.repoState.getState());
357
+ return buildRuntimeWorkspaceOverlayFooting(this.options.fs, this.options.git, scope.worktreeRoot, scope.gitCommonDir, scope.repoState.getState());
342
358
  }
343
359
  async noteReadObservation(toolName, args, result, execution) {
344
- const active = execution ?? this.captureCurrentExecutionContext();
360
+ const ownedExecution = execution === null || execution === undefined
361
+ ? this.captureCurrentExecutionContext()
362
+ : null;
363
+ const active = execution ?? ownedExecution;
345
364
  if (active === null) {
346
365
  return;
347
366
  }
348
- const readObservation = buildWorkspaceReadObservation(active, toolName, args, result);
349
- if (readObservation === null) {
350
- return;
367
+ try {
368
+ const readObservation = buildWorkspaceReadObservation(active, toolName, args, result);
369
+ if (readObservation === null) {
370
+ return;
371
+ }
372
+ const summary = await this.options.persistedLocalHistory.summarize(active.status, active.getCausalContext(), await this.buildPersistedLocalHistoryGraphContextFromExecution(active));
373
+ await this.options.persistedLocalHistory.noteReadObservation({
374
+ current: this.buildPersistedLocalHistoryContextFromExecution(active, active.repoState.getState()),
375
+ attribution: summary.attribution,
376
+ graph: await this.buildPersistedLocalHistoryGraphContextFromExecution(active),
377
+ ...readObservation,
378
+ });
351
379
  }
352
- const summary = await this.options.persistedLocalHistory.summarize(active.status, active.getCausalContext(), await this.buildPersistedLocalHistoryGraphContextFromExecution(active));
353
- await this.options.persistedLocalHistory.noteReadObservation({
354
- current: this.buildPersistedLocalHistoryContextFromExecution(active, active.repoState.getState()),
355
- attribution: summary.attribution,
356
- graph: await this.buildPersistedLocalHistoryGraphContextFromExecution(active),
357
- ...readObservation,
358
- });
380
+ finally {
381
+ await ownedExecution?.releaseWarpLease();
382
+ }
383
+ }
384
+ getRuntimeCausalContext() {
385
+ const binding = this.currentBinding;
386
+ const repoState = binding?.slice.repoState;
387
+ if (binding === null || repoState === null || repoState === undefined) {
388
+ return null;
389
+ }
390
+ return this.buildCausalContext(binding, repoState.getState());
359
391
  }
360
392
  captureExecutionContext() {
361
393
  const binding = this.requireBinding();
@@ -377,8 +409,20 @@ export class WorkspaceRouter {
377
409
  ? DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE
378
410
  : (await this.options.authorizationPolicy?.getCapabilityProfile(resolved)) ?? null;
379
411
  if (capabilityProfile === null) {
380
- this.routedBindings.delete(resolved.worktreeId);
381
- this.routedBindingInitializations.delete(resolved.worktreeId);
412
+ const cached = this.routedBindings.get(resolved.worktreeId);
413
+ if (cached !== undefined) {
414
+ this.disposeRoutedBinding(cached);
415
+ }
416
+ const initializing = this.routedBindingInitializations.get(resolved.worktreeId);
417
+ if (initializing !== undefined) {
418
+ if (this.routedBindingInitializations.get(resolved.worktreeId) === initializing) {
419
+ this.routedBindingInitializations.delete(resolved.worktreeId);
420
+ }
421
+ const initialized = await initializing.catch(() => null);
422
+ if (initialized !== null && initialized !== cached) {
423
+ this.disposeRoutedBinding(initialized);
424
+ }
425
+ }
382
426
  throw new WorkspaceRouteUnauthorizedError(resolved.worktreeRoot);
383
427
  }
384
428
  if (this.currentBinding !== null
@@ -418,9 +462,8 @@ export class WorkspaceRouter {
418
462
  await this.options.fs.mkdir(routeDir, { recursive: true });
419
463
  const binding = await this.createBoundWorkspace(resolved, routeDir, capabilityProfile, undefined);
420
464
  const repoState = binding.slice.repoState;
421
- if (repoState === null) {
465
+ if (repoState === null)
422
466
  throw new WorkspaceBindingRequiredError("workspace");
423
- }
424
467
  await repoState.initialize();
425
468
  return binding;
426
469
  }
@@ -431,21 +474,50 @@ export class WorkspaceRouter {
431
474
  && workspaceCapabilityProfilesEqual(binding.capabilityProfile, capabilityProfile);
432
475
  }
433
476
  noteRoutedBinding(binding) {
477
+ const replaced = this.routedBindings.get(binding.worktreeId);
434
478
  this.routedBindings.delete(binding.worktreeId);
435
479
  this.routedBindings.set(binding.worktreeId, binding);
480
+ if (replaced !== undefined && replaced !== binding) {
481
+ this.disposeRoutedBinding(replaced);
482
+ }
436
483
  while (this.routedBindings.size > MAX_ROUTED_BINDINGS) {
437
- const oldest = this.routedBindings.keys().next().value;
438
- if (oldest === undefined) {
484
+ const oldestKey = this.routedBindings.keys().next().value;
485
+ if (oldestKey === undefined) {
439
486
  return;
440
487
  }
441
- this.routedBindings.delete(oldest);
488
+ const evicted = this.routedBindings.get(oldestKey);
489
+ if (evicted !== undefined) {
490
+ this.disposeRoutedBinding(evicted);
491
+ }
492
+ else {
493
+ this.routedBindings.delete(oldestKey);
494
+ }
495
+ }
496
+ }
497
+ disposeRoutedBinding(binding) {
498
+ if (this.routedBindings.get(binding.worktreeId) === binding) {
499
+ this.routedBindings.delete(binding.worktreeId);
442
500
  }
443
501
  }
444
502
  buildExecutionContext(binding, workspaceRoute = null) {
503
+ if (this.warpLeaseRelease !== null) {
504
+ throw new Error("workspace WARP leases have already been released");
505
+ }
445
506
  const repoState = binding.slice.repoState;
446
507
  if (repoState === null) {
447
508
  throw new WorkspaceBindingRequiredError("workspace");
448
509
  }
510
+ const warpLease = createWorkspaceWarpLease({
511
+ repoId: binding.repoId,
512
+ worktreeRoot: binding.worktreeRoot,
513
+ writerId: binding.warpWriterId,
514
+ ownerId: [
515
+ binding.transportSessionId,
516
+ "execution",
517
+ String(++this.executionCounter).padStart(6, "0"),
518
+ ].join(":"),
519
+ warpPool: this.options.warpPool,
520
+ });
449
521
  return {
450
522
  sliceId: binding.slice.sliceId,
451
523
  repoId: binding.repoId,
@@ -458,14 +530,17 @@ export class WorkspaceRouter {
458
530
  resolvePath: binding.resolvePath,
459
531
  capabilityProfile: binding.capabilityProfile,
460
532
  warpWriterId: binding.warpWriterId,
461
- getCausalContext: () => this.buildCausalContext(binding, repoState.getState()),
533
+ getCausalContext: (observation) => {
534
+ return this.buildCausalContext(binding, observation ?? repoState.getState());
535
+ },
462
536
  status: boundWorkspaceStatus(this.options.mode, binding),
463
537
  governor: binding.slice.governor,
464
538
  cache: binding.slice.cache,
465
539
  metrics: binding.slice.metrics,
466
540
  graftDir: binding.slice.graftDir,
467
541
  repoState,
468
- getWarp: binding.getWarp,
542
+ getWarp: () => warpLease.getWarp(),
543
+ releaseWarpLease: () => warpLease.release(),
469
544
  };
470
545
  }
471
546
  async bind(request, actionName) {
@@ -484,51 +559,51 @@ export class WorkspaceRouter {
484
559
  }
485
560
  return this.bindInternal("rebind", request, actionName);
486
561
  }
487
- async declareAttach(declaration) {
488
- const binding = this.currentBinding;
489
- if (binding?.slice.repoState === null || binding === null) {
562
+ async declareAttach(declaration, execution = null) {
563
+ const scope = this.resolveWorkspaceHistoryScope(execution);
564
+ if (scope === null) {
490
565
  return {
491
566
  ok: false,
492
567
  action: "attach",
493
568
  ...this.getStatus(),
494
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
569
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
495
570
  errorCode: "UNBOUND_SESSION",
496
571
  error: "causal_attach requires an active workspace binding.",
497
572
  };
498
573
  }
499
574
  try {
500
- await this.options.persistedLocalHistory.declareAttach({
501
- current: this.buildPersistedLocalHistoryContext(binding, binding.slice.repoState.getState()),
575
+ await scope.withGraph((graph) => this.options.persistedLocalHistory.declareAttach({
576
+ current: scope.buildHistoryContext(scope.repoState.getState()),
502
577
  declaration,
503
- graph: await this.buildPersistedLocalHistoryGraphContext(binding),
504
- });
578
+ graph,
579
+ }));
505
580
  }
506
581
  catch (error) {
507
582
  if (error instanceof PersistedLocalHistoryAttachUnavailableError) {
508
583
  const sharedAttachSource = this.options.sharedAttachPolicy?.resolveSharedAttachSource({
509
584
  sessionId: this.options.transportSessionId,
510
- repoId: binding.repoId,
511
- worktreeId: binding.worktreeId,
585
+ repoId: scope.repoId,
586
+ worktreeId: scope.worktreeId,
512
587
  }) ?? null;
513
588
  if (sharedAttachSource !== null) {
514
- await this.options.persistedLocalHistory.declareSharedAttach({
515
- current: this.buildPersistedLocalHistoryContext(binding, binding.slice.repoState.getState()),
589
+ await scope.withGraph((graph) => this.options.persistedLocalHistory.declareSharedAttach({
590
+ current: scope.buildHistoryContext(scope.repoState.getState()),
516
591
  declaration,
517
592
  source: sharedAttachSource,
518
- graph: await this.buildPersistedLocalHistoryGraphContext(binding),
519
- });
593
+ graph,
594
+ }));
520
595
  return {
521
596
  ok: true,
522
597
  action: "attach",
523
- ...this.getStatus(),
524
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
598
+ ...scope.status,
599
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
525
600
  };
526
601
  }
527
602
  return {
528
603
  ok: false,
529
604
  action: "attach",
530
- ...this.getStatus(),
531
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
605
+ ...scope.status,
606
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
532
607
  errorCode: error.code,
533
608
  error: error.message,
534
609
  };
@@ -538,8 +613,8 @@ export class WorkspaceRouter {
538
613
  return {
539
614
  ok: true,
540
615
  action: "attach",
541
- ...this.getStatus(),
542
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
616
+ ...scope.status,
617
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
543
618
  };
544
619
  }
545
620
  async bindInternal(action, request, actionName, options = {}) {
@@ -569,27 +644,31 @@ export class WorkspaceRouter {
569
644
  error: `Workspace ${resolved.worktreeRoot} is not authorized for daemon binding. Call workspace_authorize first.`,
570
645
  };
571
646
  }
647
+ return this.enqueueBindingCommit(() => this.commitBinding(action, resolved, sliceDir, capabilityProfile, actionName, options));
648
+ }
649
+ enqueueBindingCommit(operation) {
650
+ const result = this.bindingCommitTail.then(operation);
651
+ this.bindingCommitTail = result.then(() => undefined, () => undefined);
652
+ return result;
653
+ }
654
+ async commitBinding(action, resolved, sliceDir, capabilityProfile, actionName, options) {
655
+ const previousBinding = this.currentBinding;
572
656
  const nextBinding = await this.createBoundWorkspace(resolved, sliceDir, capabilityProfile, actionName);
573
657
  const nextRepoState = nextBinding.slice.repoState;
574
- if (nextRepoState === null) {
658
+ if (nextRepoState === null)
575
659
  throw new WorkspaceBindingRequiredError("workspace");
576
- }
577
660
  await nextRepoState.initialize();
578
- const previousBinding = this.currentBinding;
579
661
  const previousRepoState = previousBinding?.slice.repoState;
580
- await this.options.persistedLocalHistory.noteBinding({
581
- current: this.buildPersistedLocalHistoryContext(nextBinding, nextRepoState.getState()),
582
- previous: previousBinding === null || previousRepoState == null
583
- ? null
584
- : this.buildPersistedLocalHistoryContext(previousBinding, previousRepoState.getState()),
585
- currentGraph: await this.buildPersistedLocalHistoryGraphContext(nextBinding),
586
- previousGraph: previousBinding === null
587
- ? null
588
- : await this.buildPersistedLocalHistoryGraphContext(previousBinding),
589
- });
590
- if (this.options.mode === "daemon") {
662
+ const current = this.buildPersistedLocalHistoryContext(nextBinding, nextRepoState.getState());
663
+ const previous = previousBinding === null || previousRepoState == null
664
+ ? null
665
+ : this.buildPersistedLocalHistoryContext(previousBinding, previousRepoState.getState());
666
+ if (previous !== null && (previous.repoId !== current.repoId || previous.worktreeId !== current.worktreeId)) {
667
+ await this.withBindingGraph(previousBinding, (previousGraph) => this.options.persistedLocalHistory.noteBindingDeparture({ current, previous, previousGraph }));
668
+ }
669
+ await this.withBindingGraph(nextBinding, (currentGraph) => this.options.persistedLocalHistory.noteBinding({ current, previous, currentGraph }));
670
+ if (this.options.mode === "daemon")
591
671
  await this.options.authorizationPolicy?.noteBound(resolved);
592
- }
593
672
  this.currentBinding = nextBinding;
594
673
  this.currentSlice = nextBinding.slice;
595
674
  this.noteOpenedWorkspace(resolved, capabilityProfile, options.openedSource ?? (this.options.mode === "daemon" ? "daemon_authorized" : "session_opened"), true);
@@ -608,6 +687,8 @@ export class WorkspaceRouter {
608
687
  git: this.options.git,
609
688
  nextSliceId: `slice-${String(++this.sliceIdCounter).padStart(4, "0")}`,
610
689
  });
690
+ if (this.warpLeaseRelease !== null)
691
+ throw new Error("workspace graph admission has closed");
611
692
  return createBoundWorkspace({
612
693
  resolved,
613
694
  graftDir,
@@ -617,7 +698,6 @@ export class WorkspaceRouter {
617
698
  fs: this.options.fs,
618
699
  transportSessionId: this.options.transportSessionId,
619
700
  warpWriterId: this.options.warpWriterId ?? DEFAULT_WARP_WRITER_ID,
620
- warpPool: this.options.warpPool,
621
701
  });
622
702
  }
623
703
  requireBinding() {
@@ -693,11 +773,12 @@ export class WorkspaceRouter {
693
773
  hookEvent,
694
774
  });
695
775
  }
696
- buildPersistedLocalHistoryContextFromExecution(execution, observation) {
776
+ buildPersistedLocalHistoryContextFromExecution(execution, observation, hookEvent = null) {
697
777
  return buildPersistedLocalHistoryContextFromExecution({
698
778
  persistedLocalHistory: this.options.persistedLocalHistory,
699
779
  execution,
700
780
  observation,
781
+ hookEvent,
701
782
  });
702
783
  }
703
784
  async resolveCheckoutBoundaryHookEvent(binding, previousObservedAt, observation) {
@@ -715,11 +796,59 @@ export class WorkspaceRouter {
715
796
  }
716
797
  return this.captureExecutionContext();
717
798
  }
718
- async buildPersistedLocalHistoryGraphContext(binding) {
719
- if (this.options.persistedLocalHistoryGraph === false) {
799
+ resolveWorkspaceHistoryScope(execution) {
800
+ if (execution !== null) {
801
+ return {
802
+ status: execution.status,
803
+ repoId: execution.repoId,
804
+ worktreeId: execution.worktreeId,
805
+ worktreeRoot: execution.worktreeRoot,
806
+ gitCommonDir: execution.gitCommonDir,
807
+ repoState: execution.repoState,
808
+ getCausalContext: () => execution.getCausalContext(),
809
+ buildHistoryContext: (observation, hookEvent) => {
810
+ return this.buildPersistedLocalHistoryContextFromExecution(execution, observation, hookEvent);
811
+ },
812
+ withGraph: async (operation) => operation(await this.buildPersistedLocalHistoryGraphContextFromExecution(execution)),
813
+ };
814
+ }
815
+ const binding = this.currentBinding;
816
+ const repoState = binding?.slice.repoState;
817
+ if (binding === null || repoState === null || repoState === undefined) {
720
818
  return null;
721
819
  }
722
- return buildPersistedLocalHistoryGraphContext(binding.worktreeRoot, binding.getWarp);
820
+ return {
821
+ status: boundWorkspaceStatus(this.options.mode, binding),
822
+ repoId: binding.repoId,
823
+ worktreeId: binding.worktreeId,
824
+ worktreeRoot: binding.worktreeRoot,
825
+ gitCommonDir: binding.gitCommonDir,
826
+ repoState,
827
+ getCausalContext: () => this.buildCausalContext(binding, repoState.getState()),
828
+ buildHistoryContext: (observation, hookEvent) => {
829
+ return this.buildPersistedLocalHistoryContext(binding, observation, hookEvent);
830
+ },
831
+ withGraph: (operation) => this.withBindingGraph(binding, operation),
832
+ };
833
+ }
834
+ async withBindingGraph(binding, operation) {
835
+ if (this.warpLeaseRelease !== null)
836
+ throw new Error("workspace graph admission has closed");
837
+ if (binding === null || this.options.persistedLocalHistoryGraph === false)
838
+ return operation(null);
839
+ const lease = createWorkspaceWarpLease({
840
+ repoId: binding.repoId,
841
+ worktreeRoot: binding.worktreeRoot,
842
+ writerId: binding.warpWriterId,
843
+ ownerId: binding.transportSessionId + ":history:" + String(++this.executionCounter),
844
+ warpPool: this.options.warpPool,
845
+ });
846
+ try {
847
+ return await operation(await buildPersistedLocalHistoryGraphContext(binding.worktreeRoot, () => lease.getWarp()));
848
+ }
849
+ finally {
850
+ await lease.release();
851
+ }
723
852
  }
724
853
  async buildPersistedLocalHistoryGraphContextFromExecution(execution) {
725
854
  if (this.options.persistedLocalHistoryGraph === false) {