|
9 | 9 | //! // Signal 1 — reported root (deprecated MCP primitive, SEP-2577) |
10 | 10 | //! if session reported roots: |
11 | 11 | //! if roots.len() > 1 (no pinned X-Mcpmux-Workspace header): |
12 | | -//! return ([], default_space, PendingRoots) // ambiguous — never guess |
| 12 | +//! if exactly one reported root still exists on disk: |
| 13 | +//! narrow to that root and continue // disambiguated |
| 14 | +//! else: |
| 15 | +//! return ([], default_space, PendingRoots) // ambiguous — never guess |
13 | 16 | //! if a binding matches the (single) root: |
14 | 17 | //! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding) |
15 | 18 | //! |
|
73 | 76 | //! Multi-root ambiguity is a separate, non-timed hold: when |
74 | 77 | //! [`SessionRootsRegistry::get`](crate::services::session_roots::SessionRootsRegistry::get) |
75 | 78 | //! returns more than one root (no pinned `X-Mcpmux-Workspace` header), the |
76 | | -//! resolver stays at `PendingRoots` indefinitely until the client pins a |
| 79 | +//! resolver first checks whether the ambiguity is only apparent — clients can |
| 80 | +//! report roots from unrelated or stale sources alongside the caller's real |
| 81 | +//! workspace (e.g. an orphaned background-agent worker still pointed at a |
| 82 | +//! folder that was since moved or deleted). If exactly one reported root |
| 83 | +//! still exists on disk, the resolver narrows to that root and proceeds |
| 84 | +//! normally — filesystem existence, not binding presence, is the signal, |
| 85 | +//! since discarding a root just because it lacks a binding would silently |
| 86 | +//! route the request to a *different*, unrelated-but-bound root's |
| 87 | +//! FeatureSet. Otherwise — zero surviving roots, or more than one that still |
| 88 | +//! exists — it stays at `PendingRoots` indefinitely until the client pins a |
77 | 89 | //! single root (header or `mcpmux_set_workspace_root`). Unlike the in-flight |
78 | 90 | //! grace window, time alone cannot resolve which open folder the request |
79 | 91 | //! belongs to — guessing would silently route to the wrong FeatureSet. |
@@ -515,26 +527,57 @@ impl FeatureSetResolverService { |
515 | 527 | // Tier 1: session reported roots — try an EXACT binding match |
516 | 528 | // (no ancestor inheritance). |
517 | 529 | if has_roots { |
518 | | - let reported_roots = roots.expect("has_roots implies Some"); |
| 530 | + let mut reported_roots = roots.expect("has_roots implies Some"); |
519 | 531 |
|
520 | 532 | // Ambiguous multi-root session: SessionRootsRegistry::get() only |
521 | 533 | // returns more than one entry when there's no pinned |
522 | 534 | // X-Mcpmux-Workspace header collapsing it to a single root (see |
523 | | - // SessionRootsRegistry::get). Never guess which open folder this |
524 | | - // request belongs to — hold at PendingRoots (meta tools, incl. |
525 | | - // mcpmux_set_workspace_root, remain reachable) until the client |
526 | | - // pins one explicitly. |
| 535 | + // SessionRootsRegistry::get). Before giving up, check whether the |
| 536 | + // ambiguity is only apparent: clients can report roots from |
| 537 | + // unrelated or stale sources alongside the caller's real |
| 538 | + // workspace — e.g. an orphaned background-agent worker still |
| 539 | + // pointed at a folder that was since moved or deleted. If |
| 540 | + // exactly one reported root still exists on disk, narrow to it |
| 541 | + // and fall through to the normal Tier 1 lookup below. |
| 542 | + // |
| 543 | + // Filesystem existence — NOT binding presence — is the only |
| 544 | + // safe disambiguation signal here. Discarding a root just |
| 545 | + // because it lacks a binding would silently route the request |
| 546 | + // to a *different*, unrelated-but-bound root's FeatureSet |
| 547 | + // whenever two genuinely distinct open folders both got |
| 548 | + // reported together — exactly the cross-workspace bleed this |
| 549 | + // gate exists to prevent. A phantom root, by contrast, can |
| 550 | + // never itself hold or acquire a binding, so dropping it loses |
| 551 | + // no real ambiguity. Bounded to a handful of cheap local |
| 552 | + // `stat()` calls on this already-cold path. |
527 | 553 | if reported_roots.len() > 1 { |
528 | | - debug!( |
529 | | - session_id = %sid, |
530 | | - root_count = reported_roots.len(), |
531 | | - "[FeatureSetResolver] multiple roots reported, no pinned header — PendingRoots", |
532 | | - ); |
533 | | - return Ok(ResolvedFeatureSet { |
534 | | - feature_set_ids: vec![], |
535 | | - space_id: Some(deny_space_id), |
536 | | - source: ResolutionSource::PendingRoots, |
537 | | - }); |
| 554 | + let existing_roots: Vec<String> = reported_roots |
| 555 | + .iter() |
| 556 | + .filter(|root| std::path::Path::new(root.as_str()).exists()) |
| 557 | + .cloned() |
| 558 | + .collect(); |
| 559 | + |
| 560 | + if existing_roots.len() == 1 { |
| 561 | + debug!( |
| 562 | + session_id = %sid, |
| 563 | + root_count = reported_roots.len(), |
| 564 | + resolved_root = %existing_roots[0], |
| 565 | + "[FeatureSetResolver] disambiguated multi-root session — only one reported root exists on disk", |
| 566 | + ); |
| 567 | + reported_roots = existing_roots; |
| 568 | + } else { |
| 569 | + debug!( |
| 570 | + session_id = %sid, |
| 571 | + root_count = reported_roots.len(), |
| 572 | + existing_count = existing_roots.len(), |
| 573 | + "[FeatureSetResolver] multiple roots reported, no pinned header — PendingRoots", |
| 574 | + ); |
| 575 | + return Ok(ResolvedFeatureSet { |
| 576 | + feature_set_ids: vec![], |
| 577 | + space_id: Some(deny_space_id), |
| 578 | + source: ResolutionSource::PendingRoots, |
| 579 | + }); |
| 580 | + } |
538 | 581 | } |
539 | 582 |
|
540 | 583 | if let Some(binding) = self |
|
0 commit comments