@flyingrobots/graft 0.12.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 (103) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist/adapters/fake-echo-kernel-transport.d.ts.map +1 -1
  3. package/dist/adapters/fake-echo-kernel-transport.js +12 -0
  4. package/dist/adapters/fake-echo-kernel-transport.js.map +1 -1
  5. package/dist/cli/daemon-status-model.d.ts +1 -0
  6. package/dist/cli/daemon-status-model.d.ts.map +1 -1
  7. package/dist/cli/daemon-status-model.js +1 -0
  8. package/dist/cli/daemon-status-model.js.map +1 -1
  9. package/dist/cli/daemon-status-render.d.ts.map +1 -1
  10. package/dist/cli/daemon-status-render.js +1 -0
  11. package/dist/cli/daemon-status-render.js.map +1 -1
  12. package/dist/contracts/capabilities.d.ts +7 -0
  13. package/dist/contracts/capabilities.d.ts.map +1 -1
  14. package/dist/contracts/capabilities.js +28 -0
  15. package/dist/contracts/capabilities.js.map +1 -1
  16. package/dist/contracts/output-schema-fragments.d.ts +2 -0
  17. package/dist/contracts/output-schema-fragments.d.ts.map +1 -1
  18. package/dist/contracts/output-schema-fragments.js +1 -0
  19. package/dist/contracts/output-schema-fragments.js.map +1 -1
  20. package/dist/contracts/output-schema-mcp.d.ts +1 -0
  21. package/dist/contracts/output-schema-mcp.d.ts.map +1 -1
  22. package/dist/contracts/output-schema-meta.d.ts +12 -1
  23. package/dist/contracts/output-schema-meta.d.ts.map +1 -1
  24. package/dist/contracts/output-schema-meta.js +33 -9
  25. package/dist/contracts/output-schema-meta.js.map +1 -1
  26. package/dist/contracts/output-schemas.d.ts.map +1 -1
  27. package/dist/contracts/output-schemas.js +14 -4
  28. package/dist/contracts/output-schemas.js.map +1 -1
  29. package/dist/echo/structural-history-client.d.ts.map +1 -1
  30. package/dist/echo/structural-history-client.js +12 -0
  31. package/dist/echo/structural-history-client.js.map +1 -1
  32. package/dist/echo/structural-reading-adapter.d.ts.map +1 -1
  33. package/dist/echo/structural-reading-adapter.js +13 -2
  34. package/dist/echo/structural-reading-adapter.js.map +1 -1
  35. package/dist/mcp/control-plane/types.d.ts +1 -0
  36. package/dist/mcp/control-plane/types.d.ts.map +1 -1
  37. package/dist/mcp/daemon-control-plane.d.ts.map +1 -1
  38. package/dist/mcp/daemon-control-plane.js +6 -1
  39. package/dist/mcp/daemon-control-plane.js.map +1 -1
  40. package/dist/mcp/daemon-scheduler-config.d.ts +53 -0
  41. package/dist/mcp/daemon-scheduler-config.d.ts.map +1 -0
  42. package/dist/mcp/daemon-scheduler-config.js +33 -0
  43. package/dist/mcp/daemon-scheduler-config.js.map +1 -0
  44. package/dist/mcp/daemon-server.d.ts +4 -0
  45. package/dist/mcp/daemon-server.d.ts.map +1 -1
  46. package/dist/mcp/daemon-server.js +39 -13
  47. package/dist/mcp/daemon-server.js.map +1 -1
  48. package/dist/mcp/daemon-session-host.d.ts +2 -2
  49. package/dist/mcp/daemon-session-host.d.ts.map +1 -1
  50. package/dist/mcp/daemon-session-host.js +130 -59
  51. package/dist/mcp/daemon-session-host.js.map +1 -1
  52. package/dist/mcp/persisted-local-history.d.ts +5 -0
  53. package/dist/mcp/persisted-local-history.d.ts.map +1 -1
  54. package/dist/mcp/persisted-local-history.js +5 -1
  55. package/dist/mcp/persisted-local-history.js.map +1 -1
  56. package/dist/mcp/receipt.d.ts +3 -0
  57. package/dist/mcp/receipt.d.ts.map +1 -1
  58. package/dist/mcp/receipt.js +4 -0
  59. package/dist/mcp/receipt.js.map +1 -1
  60. package/dist/mcp/repo-tool-job.d.ts +2 -1
  61. package/dist/mcp/repo-tool-job.d.ts.map +1 -1
  62. package/dist/mcp/repo-tool-job.js.map +1 -1
  63. package/dist/mcp/repo-tool-worker-context.d.ts.map +1 -1
  64. package/dist/mcp/repo-tool-worker-context.js +1 -0
  65. package/dist/mcp/repo-tool-worker-context.js.map +1 -1
  66. package/dist/mcp/server-context.d.ts.map +1 -1
  67. package/dist/mcp/server-context.js +28 -11
  68. package/dist/mcp/server-context.js.map +1 -1
  69. package/dist/mcp/server-invocation.d.ts.map +1 -1
  70. package/dist/mcp/server-invocation.js +21 -7
  71. package/dist/mcp/server-invocation.js.map +1 -1
  72. package/dist/mcp/server-tool-access.d.ts.map +1 -1
  73. package/dist/mcp/server-tool-access.js +2 -12
  74. package/dist/mcp/server-tool-access.js.map +1 -1
  75. package/dist/mcp/server.d.ts +3 -2
  76. package/dist/mcp/server.d.ts.map +1 -1
  77. package/dist/mcp/server.js +25 -11
  78. package/dist/mcp/server.js.map +1 -1
  79. package/dist/mcp/warp-pool.d.ts +45 -6
  80. package/dist/mcp/warp-pool.d.ts.map +1 -1
  81. package/dist/mcp/warp-pool.js +128 -19
  82. package/dist/mcp/warp-pool.js.map +1 -1
  83. package/dist/mcp/workspace-router-model.d.ts +16 -1
  84. package/dist/mcp/workspace-router-model.d.ts.map +1 -1
  85. package/dist/mcp/workspace-router-model.js +8 -0
  86. package/dist/mcp/workspace-router-model.js.map +1 -1
  87. package/dist/mcp/workspace-router-resolution.d.ts.map +1 -1
  88. package/dist/mcp/workspace-router-resolution.js +19 -1
  89. package/dist/mcp/workspace-router-resolution.js.map +1 -1
  90. package/dist/mcp/workspace-router-runtime.d.ts +15 -5
  91. package/dist/mcp/workspace-router-runtime.d.ts.map +1 -1
  92. package/dist/mcp/workspace-router-runtime.js +47 -7
  93. package/dist/mcp/workspace-router-runtime.js.map +1 -1
  94. package/dist/mcp/workspace-router.d.ts +21 -11
  95. package/dist/mcp/workspace-router.d.ts.map +1 -1
  96. package/dist/mcp/workspace-router.js +273 -136
  97. package/dist/mcp/workspace-router.js.map +1 -1
  98. package/dist/ports/echo-kernel-transport.d.ts.map +1 -1
  99. package/dist/ports/echo-kernel-transport.js +12 -0
  100. package/dist/ports/echo-kernel-transport.js.map +1 -1
  101. package/docs/MCP.md +68 -0
  102. package/docs/SETUP.md +27 -0
  103. package/package.json +5 -2
@@ -7,12 +7,12 @@ import { RepoStateTracker } from "./repo-state.js";
7
7
  import { buildRuntimeStagedTarget } from "./runtime-staged-target.js";
8
8
  import { buildRuntimeWorkspaceOverlayFooting, } from "./runtime-workspace-overlay.js";
9
9
  import { buildWorkspaceReadObservation } from "./workspace-read-observation.js";
10
- import { DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, WorkspaceRouteUnauthorizedError, } from "./workspace-router-model.js";
10
+ import { DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, WorkspaceResolutionError, WorkspaceRouteUnauthorizedError, } from "./workspace-router-model.js";
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";
15
- export { DEFAULT_DAEMON_CAPABILITY_PROFILE, DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, WorkspaceCapabilityDeniedError, WorkspaceRouteUnauthorizedError, } from "./workspace-router-model.js";
14
+ import { boundWorkspaceStatus, buildPersistedLocalHistoryContext, buildPersistedLocalHistoryContextFromExecution, buildPersistedLocalHistoryGraphContext, buildWorkspaceCausalContext, createBoundWorkspace, createWorkspaceWarpLease, createWorkspaceSlice, nextBindingSliceDir, resolveCheckoutBoundaryHookEvent, unboundWorkspaceStatus, } from "./workspace-router-runtime.js";
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;
18
18
  function workspaceCapabilityProfilesEqual(left, right) {
@@ -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
+ }
123
131
  }
124
- async observeRepoState() {
125
- const binding = this.requireBinding();
126
- const repoState = this.requireRepoState();
127
- const previousObservation = repoState.getState();
128
- const nextObservation = await repoState.observe();
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;
140
+ }
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
+ });
379
+ }
380
+ finally {
381
+ await ownedExecution?.releaseWarpLease();
351
382
  }
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
- });
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();
@@ -364,31 +396,50 @@ export class WorkspaceRouter {
364
396
  async captureExecutionContextForWorkspace(request) {
365
397
  const resolved = await resolveWorkspaceRequest(this.options.git, request);
366
398
  if ("code" in resolved) {
367
- throw new Error(resolved.message);
368
- }
399
+ throw new WorkspaceResolutionError(resolved.code, resolved.message);
400
+ }
401
+ const workspaceRoute = Object.freeze({
402
+ route: "explicit_cwd",
403
+ requestedRoot: path.resolve(request.cwd),
404
+ resolvedRoot: resolved.worktreeRoot,
405
+ repoId: resolved.repoId,
406
+ worktreeId: resolved.worktreeId,
407
+ });
369
408
  const capabilityProfile = this.options.mode === "repo_local"
370
409
  ? DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE
371
410
  : (await this.options.authorizationPolicy?.getCapabilityProfile(resolved)) ?? null;
372
411
  if (capabilityProfile === null) {
373
- this.routedBindings.delete(resolved.worktreeId);
374
- 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
+ }
375
426
  throw new WorkspaceRouteUnauthorizedError(resolved.worktreeRoot);
376
427
  }
377
- if (this.currentBinding?.worktreeId === resolved.worktreeId
378
- && workspaceCapabilityProfilesEqual(this.currentBinding.capabilityProfile, capabilityProfile)) {
379
- return this.captureExecutionContext();
428
+ if (this.currentBinding !== null
429
+ && this.routedBindingMatches(this.currentBinding, resolved, capabilityProfile)) {
430
+ return this.buildExecutionContext(this.currentBinding, workspaceRoute);
380
431
  }
381
432
  const existing = this.routedBindings.get(resolved.worktreeId);
382
433
  if (existing !== undefined && this.routedBindingMatches(existing, resolved, capabilityProfile)) {
383
434
  this.noteRoutedBinding(existing);
384
- return this.buildExecutionContext(existing);
435
+ return this.buildExecutionContext(existing, workspaceRoute);
385
436
  }
386
437
  const initializing = this.routedBindingInitializations.get(resolved.worktreeId);
387
438
  if (initializing !== undefined) {
388
439
  const binding = await initializing;
389
440
  if (this.routedBindingMatches(binding, resolved, capabilityProfile)) {
390
441
  this.noteRoutedBinding(binding);
391
- return this.buildExecutionContext(binding);
442
+ return this.buildExecutionContext(binding, workspaceRoute);
392
443
  }
393
444
  }
394
445
  const bindingInitialization = this.createRoutedBinding(resolved, capabilityProfile);
@@ -404,16 +455,15 @@ export class WorkspaceRouter {
404
455
  }
405
456
  this.noteRoutedBinding(binding);
406
457
  this.noteOpenedWorkspace(resolved, capabilityProfile, this.options.mode === "daemon" ? "daemon_authorized" : "session_opened", false);
407
- return this.buildExecutionContext(binding);
458
+ return this.buildExecutionContext(binding, workspaceRoute);
408
459
  }
409
460
  async createRoutedBinding(resolved, capabilityProfile) {
410
461
  const routeDir = path.join(this.options.graftDir, "routes", `slice-${String(++this.bindingCounter).padStart(4, "0")}`);
411
462
  await this.options.fs.mkdir(routeDir, { recursive: true });
412
463
  const binding = await this.createBoundWorkspace(resolved, routeDir, capabilityProfile, undefined);
413
464
  const repoState = binding.slice.repoState;
414
- if (repoState === null) {
465
+ if (repoState === null)
415
466
  throw new WorkspaceBindingRequiredError("workspace");
416
- }
417
467
  await repoState.initialize();
418
468
  return binding;
419
469
  }
@@ -424,21 +474,50 @@ export class WorkspaceRouter {
424
474
  && workspaceCapabilityProfilesEqual(binding.capabilityProfile, capabilityProfile);
425
475
  }
426
476
  noteRoutedBinding(binding) {
477
+ const replaced = this.routedBindings.get(binding.worktreeId);
427
478
  this.routedBindings.delete(binding.worktreeId);
428
479
  this.routedBindings.set(binding.worktreeId, binding);
480
+ if (replaced !== undefined && replaced !== binding) {
481
+ this.disposeRoutedBinding(replaced);
482
+ }
429
483
  while (this.routedBindings.size > MAX_ROUTED_BINDINGS) {
430
- const oldest = this.routedBindings.keys().next().value;
431
- if (oldest === undefined) {
484
+ const oldestKey = this.routedBindings.keys().next().value;
485
+ if (oldestKey === undefined) {
432
486
  return;
433
487
  }
434
- 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);
435
500
  }
436
501
  }
437
- buildExecutionContext(binding) {
502
+ buildExecutionContext(binding, workspaceRoute = null) {
503
+ if (this.warpLeaseRelease !== null) {
504
+ throw new Error("workspace WARP leases have already been released");
505
+ }
438
506
  const repoState = binding.slice.repoState;
439
507
  if (repoState === null) {
440
508
  throw new WorkspaceBindingRequiredError("workspace");
441
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
+ });
442
521
  return {
443
522
  sliceId: binding.slice.sliceId,
444
523
  repoId: binding.repoId,
@@ -446,18 +525,22 @@ export class WorkspaceRouter {
446
525
  projectRoot: binding.worktreeRoot,
447
526
  worktreeRoot: binding.worktreeRoot,
448
527
  gitCommonDir: binding.gitCommonDir,
528
+ workspaceRoute,
449
529
  graftignorePatterns: binding.graftignorePatterns,
450
530
  resolvePath: binding.resolvePath,
451
531
  capabilityProfile: binding.capabilityProfile,
452
532
  warpWriterId: binding.warpWriterId,
453
- getCausalContext: () => this.buildCausalContext(binding, repoState.getState()),
533
+ getCausalContext: (observation) => {
534
+ return this.buildCausalContext(binding, observation ?? repoState.getState());
535
+ },
454
536
  status: boundWorkspaceStatus(this.options.mode, binding),
455
537
  governor: binding.slice.governor,
456
538
  cache: binding.slice.cache,
457
539
  metrics: binding.slice.metrics,
458
540
  graftDir: binding.slice.graftDir,
459
541
  repoState,
460
- getWarp: binding.getWarp,
542
+ getWarp: () => warpLease.getWarp(),
543
+ releaseWarpLease: () => warpLease.release(),
461
544
  };
462
545
  }
463
546
  async bind(request, actionName) {
@@ -476,51 +559,51 @@ export class WorkspaceRouter {
476
559
  }
477
560
  return this.bindInternal("rebind", request, actionName);
478
561
  }
479
- async declareAttach(declaration) {
480
- const binding = this.currentBinding;
481
- if (binding?.slice.repoState === null || binding === null) {
562
+ async declareAttach(declaration, execution = null) {
563
+ const scope = this.resolveWorkspaceHistoryScope(execution);
564
+ if (scope === null) {
482
565
  return {
483
566
  ok: false,
484
567
  action: "attach",
485
568
  ...this.getStatus(),
486
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
569
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
487
570
  errorCode: "UNBOUND_SESSION",
488
571
  error: "causal_attach requires an active workspace binding.",
489
572
  };
490
573
  }
491
574
  try {
492
- await this.options.persistedLocalHistory.declareAttach({
493
- current: this.buildPersistedLocalHistoryContext(binding, binding.slice.repoState.getState()),
575
+ await scope.withGraph((graph) => this.options.persistedLocalHistory.declareAttach({
576
+ current: scope.buildHistoryContext(scope.repoState.getState()),
494
577
  declaration,
495
- graph: await this.buildPersistedLocalHistoryGraphContext(binding),
496
- });
578
+ graph,
579
+ }));
497
580
  }
498
581
  catch (error) {
499
582
  if (error instanceof PersistedLocalHistoryAttachUnavailableError) {
500
583
  const sharedAttachSource = this.options.sharedAttachPolicy?.resolveSharedAttachSource({
501
584
  sessionId: this.options.transportSessionId,
502
- repoId: binding.repoId,
503
- worktreeId: binding.worktreeId,
585
+ repoId: scope.repoId,
586
+ worktreeId: scope.worktreeId,
504
587
  }) ?? null;
505
588
  if (sharedAttachSource !== null) {
506
- await this.options.persistedLocalHistory.declareSharedAttach({
507
- current: this.buildPersistedLocalHistoryContext(binding, binding.slice.repoState.getState()),
589
+ await scope.withGraph((graph) => this.options.persistedLocalHistory.declareSharedAttach({
590
+ current: scope.buildHistoryContext(scope.repoState.getState()),
508
591
  declaration,
509
592
  source: sharedAttachSource,
510
- graph: await this.buildPersistedLocalHistoryGraphContext(binding),
511
- });
593
+ graph,
594
+ }));
512
595
  return {
513
596
  ok: true,
514
597
  action: "attach",
515
- ...this.getStatus(),
516
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
598
+ ...scope.status,
599
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
517
600
  };
518
601
  }
519
602
  return {
520
603
  ok: false,
521
604
  action: "attach",
522
- ...this.getStatus(),
523
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
605
+ ...scope.status,
606
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
524
607
  errorCode: error.code,
525
608
  error: error.message,
526
609
  };
@@ -530,8 +613,8 @@ export class WorkspaceRouter {
530
613
  return {
531
614
  ok: true,
532
615
  action: "attach",
533
- ...this.getStatus(),
534
- persistedLocalHistory: await this.getPersistedLocalHistorySummary(),
616
+ ...scope.status,
617
+ persistedLocalHistory: await this.getPersistedLocalHistorySummary(execution),
535
618
  };
536
619
  }
537
620
  async bindInternal(action, request, actionName, options = {}) {
@@ -561,27 +644,31 @@ export class WorkspaceRouter {
561
644
  error: `Workspace ${resolved.worktreeRoot} is not authorized for daemon binding. Call workspace_authorize first.`,
562
645
  };
563
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;
564
656
  const nextBinding = await this.createBoundWorkspace(resolved, sliceDir, capabilityProfile, actionName);
565
657
  const nextRepoState = nextBinding.slice.repoState;
566
- if (nextRepoState === null) {
658
+ if (nextRepoState === null)
567
659
  throw new WorkspaceBindingRequiredError("workspace");
568
- }
569
660
  await nextRepoState.initialize();
570
- const previousBinding = this.currentBinding;
571
661
  const previousRepoState = previousBinding?.slice.repoState;
572
- await this.options.persistedLocalHistory.noteBinding({
573
- current: this.buildPersistedLocalHistoryContext(nextBinding, nextRepoState.getState()),
574
- previous: previousBinding === null || previousRepoState == null
575
- ? null
576
- : this.buildPersistedLocalHistoryContext(previousBinding, previousRepoState.getState()),
577
- currentGraph: await this.buildPersistedLocalHistoryGraphContext(nextBinding),
578
- previousGraph: previousBinding === null
579
- ? null
580
- : await this.buildPersistedLocalHistoryGraphContext(previousBinding),
581
- });
582
- 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")
583
671
  await this.options.authorizationPolicy?.noteBound(resolved);
584
- }
585
672
  this.currentBinding = nextBinding;
586
673
  this.currentSlice = nextBinding.slice;
587
674
  this.noteOpenedWorkspace(resolved, capabilityProfile, options.openedSource ?? (this.options.mode === "daemon" ? "daemon_authorized" : "session_opened"), true);
@@ -600,6 +687,8 @@ export class WorkspaceRouter {
600
687
  git: this.options.git,
601
688
  nextSliceId: `slice-${String(++this.sliceIdCounter).padStart(4, "0")}`,
602
689
  });
690
+ if (this.warpLeaseRelease !== null)
691
+ throw new Error("workspace graph admission has closed");
603
692
  return createBoundWorkspace({
604
693
  resolved,
605
694
  graftDir,
@@ -609,7 +698,6 @@ export class WorkspaceRouter {
609
698
  fs: this.options.fs,
610
699
  transportSessionId: this.options.transportSessionId,
611
700
  warpWriterId: this.options.warpWriterId ?? DEFAULT_WARP_WRITER_ID,
612
- warpPool: this.options.warpPool,
613
701
  });
614
702
  }
615
703
  requireBinding() {
@@ -685,11 +773,12 @@ export class WorkspaceRouter {
685
773
  hookEvent,
686
774
  });
687
775
  }
688
- buildPersistedLocalHistoryContextFromExecution(execution, observation) {
776
+ buildPersistedLocalHistoryContextFromExecution(execution, observation, hookEvent = null) {
689
777
  return buildPersistedLocalHistoryContextFromExecution({
690
778
  persistedLocalHistory: this.options.persistedLocalHistory,
691
779
  execution,
692
780
  observation,
781
+ hookEvent,
693
782
  });
694
783
  }
695
784
  async resolveCheckoutBoundaryHookEvent(binding, previousObservedAt, observation) {
@@ -707,11 +796,59 @@ export class WorkspaceRouter {
707
796
  }
708
797
  return this.captureExecutionContext();
709
798
  }
710
- async buildPersistedLocalHistoryGraphContext(binding) {
711
- 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) {
712
818
  return null;
713
819
  }
714
- 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
+ }
715
852
  }
716
853
  async buildPersistedLocalHistoryGraphContextFromExecution(execution) {
717
854
  if (this.options.persistedLocalHistoryGraph === false) {