1pub mod capability;
2
3use crate::prompt::QualifiedModelId;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{BTreeMap, BTreeSet, VecDeque};
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum EnforcementMode {
11 Off,
12 Advisory,
13 Enforced,
14}
15
16#[derive(Clone, Debug, Serialize)]
18pub struct CatalogEntry {
19 pub model: QualifiedModelId,
20 pub available: bool,
21 pub worker_eligible: bool,
22}
23
24#[derive(Clone, Debug, Serialize)]
26pub struct CatalogSnapshot {
27 id: String,
28 digest_sha256: String,
29 entries: BTreeMap<QualifiedModelId, CatalogEntry>,
30}
31impl CatalogSnapshot {
32 pub fn new(entries: impl IntoIterator<Item = QualifiedModelId>) -> Self {
34 Self::from_entries(entries.into_iter().map(|model| CatalogEntry {
35 model,
36 available: true,
37 worker_eligible: true,
38 }))
39 }
40 pub fn from_entries(entries: impl IntoIterator<Item = CatalogEntry>) -> Self {
41 let entries: BTreeMap<_, _> = entries
42 .into_iter()
43 .map(|entry| (entry.model.clone(), entry))
44 .collect();
45 let encoded = serde_json::to_vec(&entries).expect("catalog is serializable");
46 let digest_sha256 = format!("{:x}", Sha256::digest(encoded));
47 Self {
48 id: format!("catalog-{}", &digest_sha256[..16]),
49 digest_sha256,
50 entries,
51 }
52 }
53 pub fn id(&self) -> &str {
54 &self.id
55 }
56 pub fn digest_sha256(&self) -> &str {
57 &self.digest_sha256
58 }
59 pub fn contains(&self, model: &QualifiedModelId) -> bool {
60 self.entries
61 .get(model)
62 .is_some_and(|entry| entry.available && entry.worker_eligible)
63 }
64 pub fn with_model(&self, model: QualifiedModelId) -> Self {
68 let mut entries: Vec<CatalogEntry> = self.entries.values().cloned().collect();
69 entries.push(CatalogEntry {
70 model,
71 available: true,
72 worker_eligible: true,
73 });
74 Self::from_entries(entries)
75 }
76}
77
78#[derive(Clone, Debug, Serialize)]
79pub struct CrossProviderGrant {
80 pub grant_id: String,
81 pub from_provider: String,
82 pub to_provider: String,
83 pub allowed_models: BTreeSet<QualifiedModelId>,
84 pub expires_at_unix: Option<u64>,
86}
87impl CrossProviderGrant {
88 pub fn new(
89 grant_id: impl Into<String>,
90 from_provider: impl Into<String>,
91 to_provider: impl Into<String>,
92 models: impl IntoIterator<Item = QualifiedModelId>,
93 ) -> Result<Self, &'static str> {
94 let grant_id = grant_id.into();
95 let from_provider = from_provider.into();
96 let to_provider = to_provider.into();
97 let allowed_models: BTreeSet<_> = models.into_iter().collect();
98 if grant_id.is_empty()
99 || from_provider.is_empty()
100 || to_provider.is_empty()
101 || allowed_models.is_empty()
102 || allowed_models.iter().any(|m| m.provider() != to_provider)
103 {
104 return Err("invalid exact cross-provider grant");
105 }
106 Ok(Self {
107 grant_id,
108 from_provider,
109 to_provider,
110 allowed_models,
111 expires_at_unix: None,
112 })
113 }
114 pub fn expiring_at(mut self, expires_at_unix: u64) -> Result<Self, &'static str> {
115 if expires_at_unix == 0 {
116 return Err("invalid cross-provider grant expiry");
117 }
118 self.expires_at_unix = Some(expires_at_unix);
119 Ok(self)
120 }
121}
122
123#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
124#[serde(rename_all = "snake_case")]
125pub enum DispatchFailureCode {
126 InvalidQualifiedModel,
127 CatalogModelUnknown,
128 ProviderNotAllowed,
129 CrossProviderGrantExpired,
130 ModelNotAllowed,
131 ConcurrencyLimit,
132 TotalWorkerLimit,
133 PolicyUnavailable,
134}
135impl DispatchFailureCode {
136 pub fn as_str(self) -> &'static str {
137 match self {
138 Self::InvalidQualifiedModel => "invalid_qualified_model",
139 Self::CatalogModelUnknown => "catalog_model_unknown",
140 Self::ProviderNotAllowed => "provider_not_allowed",
141 Self::CrossProviderGrantExpired => "cross_provider_grant_expired",
142 Self::ModelNotAllowed => "model_not_allowed",
143 Self::ConcurrencyLimit => "concurrency_limit",
144 Self::TotalWorkerLimit => "total_limit",
145 Self::PolicyUnavailable => "policy_unavailable",
146 }
147 }
148}
149
150#[derive(Clone, Debug, Serialize)]
151pub struct DelegationPolicy {
152 pub mode: EnforcementMode,
153 foreground: QualifiedModelId,
154 catalog: CatalogSnapshot,
155 allowed_models: BTreeSet<QualifiedModelId>,
156 grants: Vec<CrossProviderGrant>,
157 effective_choices: Vec<QualifiedModelId>,
158 pub max_concurrent_workers: usize,
159 pub max_total_workers: usize,
160}
161impl DelegationPolicy {
162 pub fn new(
164 mode: EnforcementMode,
165 foreground: QualifiedModelId,
166 models: impl IntoIterator<Item = QualifiedModelId>,
167 concurrent: usize,
168 total: usize,
169 ) -> Self {
170 let allowed_models: BTreeSet<_> = models.into_iter().collect();
171 let mut catalog_entries = allowed_models.clone();
172 catalog_entries.insert(foreground.clone());
173 let catalog = CatalogSnapshot::new(catalog_entries);
174 Self::build(
175 mode,
176 foreground,
177 catalog,
178 allowed_models,
179 Vec::new(),
180 concurrent,
181 total,
182 )
183 .expect("legacy delegation policy has valid limits")
184 }
185 pub fn enforced(
186 foreground: QualifiedModelId,
187 models: impl IntoIterator<Item = QualifiedModelId>,
188 concurrent: usize,
189 total: usize,
190 ) -> Self {
191 Self::new(
192 EnforcementMode::Enforced,
193 foreground,
194 models,
195 concurrent,
196 total,
197 )
198 }
199 pub fn baseline(
200 foreground: QualifiedModelId,
201 catalog: CatalogSnapshot,
202 concurrent: usize,
203 total: usize,
204 ) -> Result<Self, &'static str> {
205 Self::build(
206 EnforcementMode::Enforced,
207 foreground.clone(),
208 catalog,
209 [foreground].into_iter().collect(),
210 Vec::new(),
211 concurrent,
212 total,
213 )
214 }
215 pub fn provider_baseline(
219 foreground: QualifiedModelId,
220 catalog: CatalogSnapshot,
221 same_provider_models: impl IntoIterator<Item = QualifiedModelId>,
222 concurrent: usize,
223 total: usize,
224 ) -> Result<Self, &'static str> {
225 Self::build(
226 EnforcementMode::Enforced,
227 foreground,
228 catalog,
229 same_provider_models.into_iter().collect(),
230 Vec::new(),
231 concurrent,
232 total,
233 )
234 }
235 pub fn with_grants(
236 foreground: QualifiedModelId,
237 catalog: CatalogSnapshot,
238 same_provider_models: impl IntoIterator<Item = QualifiedModelId>,
239 grants: impl IntoIterator<Item = CrossProviderGrant>,
240 concurrent: usize,
241 total: usize,
242 ) -> Result<Self, &'static str> {
243 Self::build(
244 EnforcementMode::Enforced,
245 foreground,
246 catalog,
247 same_provider_models.into_iter().collect(),
248 grants.into_iter().collect(),
249 concurrent,
250 total,
251 )
252 }
253 fn build(
254 mode: EnforcementMode,
255 foreground: QualifiedModelId,
256 catalog: CatalogSnapshot,
257 allowed_models: BTreeSet<QualifiedModelId>,
258 grants: Vec<CrossProviderGrant>,
259 concurrent: usize,
260 total: usize,
261 ) -> Result<Self, &'static str> {
262 if concurrent == 0 || total == 0 || concurrent > total || !catalog.contains(&foreground) {
263 return Err("invalid delegation policy");
264 }
265 if allowed_models
266 .iter()
267 .any(|m| m.provider() != foreground.provider() || !catalog.contains(m))
268 {
269 return Err("same-provider allowlist is invalid");
270 }
271 if grants.iter().any(|g| {
272 g.from_provider != foreground.provider()
273 || g.allowed_models.iter().any(|m| !catalog.contains(m))
274 }) {
275 return Err("cross-provider grant is invalid");
276 }
277 let mut choices = allowed_models.clone();
278 for grant in &grants {
279 choices.extend(grant.allowed_models.iter().cloned());
280 }
281 Ok(Self {
282 mode,
283 foreground,
284 catalog,
285 allowed_models,
286 grants,
287 effective_choices: choices.into_iter().collect(),
288 max_concurrent_workers: concurrent,
289 max_total_workers: total,
290 })
291 }
292 pub fn authorize(&self, model: &QualifiedModelId) -> Result<Option<&str>, DispatchDenied> {
293 let now = std::time::SystemTime::now()
294 .duration_since(std::time::UNIX_EPOCH)
295 .unwrap_or_default()
296 .as_secs();
297 self.authorize_at(model, now)
298 }
299 pub fn authorize_at(
302 &self,
303 model: &QualifiedModelId,
304 now_unix: u64,
305 ) -> Result<Option<&str>, DispatchDenied> {
306 if !self.catalog.contains(model) {
307 return Err(DispatchDenied::new(
308 DispatchFailureCode::CatalogModelUnknown,
309 ));
310 }
311 if self.mode != EnforcementMode::Enforced {
312 return Ok(None);
313 }
314 if model.provider() == self.foreground.provider() {
315 return self
316 .allowed_models
317 .contains(model)
318 .then_some(None)
319 .ok_or_else(|| DispatchDenied::new(DispatchFailureCode::ModelNotAllowed));
320 }
321 if let Some(grant) = self
322 .grants
323 .iter()
324 .find(|g| g.to_provider == model.provider() && g.allowed_models.contains(model))
325 {
326 if grant
327 .expires_at_unix
328 .is_some_and(|expiry| now_unix >= expiry)
329 {
330 return Err(DispatchDenied::new(
331 DispatchFailureCode::CrossProviderGrantExpired,
332 ));
333 }
334 return Ok(Some(&grant.grant_id));
335 }
336 if self
337 .grants
338 .iter()
339 .any(|g| g.to_provider == model.provider())
340 {
341 Err(DispatchDenied::new(DispatchFailureCode::ModelNotAllowed))
342 } else {
343 Err(DispatchDenied::new(DispatchFailureCode::ProviderNotAllowed))
344 }
345 }
346 pub fn effective_choices(&self) -> &[QualifiedModelId] {
347 &self.effective_choices
348 }
349 pub fn grant_worker_model(&mut self, model: QualifiedModelId) -> Result<(), &'static str> {
357 if !self.catalog.contains(&model) {
358 self.catalog = self.catalog.with_model(model.clone());
359 }
360 if model.provider() == self.foreground.provider() {
361 self.allowed_models.insert(model.clone());
362 } else if !self.grants.iter().any(|grant| {
363 grant.to_provider == model.provider()
364 && grant.allowed_models.contains(&model)
365 && grant.expires_at_unix.is_none()
366 }) {
367 let grant = CrossProviderGrant::new(
368 format!("session-user-grant-{}", model.as_str()),
369 self.foreground.provider().to_owned(),
370 model.provider().to_owned(),
371 [model.clone()],
372 )?;
373 self.grants.insert(0, grant);
374 }
375 if !self.effective_choices.contains(&model) {
376 self.effective_choices.push(model);
377 self.effective_choices.sort();
378 }
379 Ok(())
380 }
381 pub fn foreground_model(&self) -> &QualifiedModelId {
382 &self.foreground
383 }
384 pub fn catalog_snapshot_id(&self) -> &str {
385 self.catalog.id()
386 }
387 pub fn catalog_digest(&self) -> &str {
388 self.catalog.digest_sha256()
389 }
390 pub fn digest(&self) -> String {
391 let encoded = serde_json::to_vec(self).expect("policy is serializable");
392 format!("{:x}", Sha256::digest(encoded))
393 }
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum WorkerRole {
399 Planner,
400 Implementer,
401 Tester,
402 Reviewer,
403 Researcher,
404 Debugger,
405}
406#[derive(Clone, Debug, Deserialize)]
407#[serde(rename_all = "snake_case", tag = "mode", content = "scopes")]
408pub enum WorkerWritePolicy {
409 ReadOnly,
410 IsolatedWorktree,
411 NonOverlappingPaths(Vec<String>),
412}
413#[derive(Clone, Copy, Debug)]
414pub enum WorkerTerminal {
415 Completed,
416 Failed,
417 TimedOut,
418}
419#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
420pub struct WorkerHandle(String);
421impl WorkerHandle {
422 pub fn id(&self) -> &str {
425 &self.0
426 }
427}
428#[derive(Debug, Eq, PartialEq)]
429pub enum CompletionGate {
430 Allowed,
431 Warning { workers: Vec<String> },
432 Blocked { workers: Vec<String> },
433}
434#[derive(Debug, Eq, PartialEq)]
435pub enum ScopeDecision {
436 Allowed,
437 Warning { workers: Vec<String> },
438 ReconciliationRequired { workers: Vec<String> },
439}
440#[derive(Debug)]
441pub struct DispatchDenied {
442 code: DispatchFailureCode,
443}
444impl DispatchDenied {
445 fn new(code: DispatchFailureCode) -> Self {
446 Self { code }
447 }
448 pub fn code(&self) -> &'static str {
449 self.code.as_str()
450 }
451 pub fn typed_code(&self) -> DispatchFailureCode {
452 self.code
453 }
454}
455
456#[derive(Clone, Copy, Debug, Eq, PartialEq)]
457enum State {
458 Dispatched,
459 Starting,
460 Running,
461 Terminal,
462 Collected,
463 Reconciled,
464}
465#[derive(Debug)]
466struct Worker {
467 state: State,
468 role: WorkerRole,
469 writes: WorkerWritePolicy,
470 unchanged_polls: usize,
471 steered: bool,
472 last_fingerprint: Option<String>,
473}
474#[derive(Debug, Serialize)]
475pub struct OrchestrationEvent {
476 pub name: &'static str,
477 pub worker_id: Option<String>,
478 pub worker_role: Option<WorkerRole>,
479 pub reason_code: Option<&'static str>,
480}
481
482pub struct WorkerRegistry {
483 policy: DelegationPolicy,
484 workers: BTreeMap<WorkerHandle, Worker>,
485 total: usize,
486 events: VecDeque<OrchestrationEvent>,
487 dropped_events: u64,
488}
489impl WorkerRegistry {
490 pub fn new(policy: DelegationPolicy) -> Self {
491 Self {
492 policy,
493 workers: BTreeMap::new(),
494 total: 0,
495 events: VecDeque::new(),
496 dropped_events: 0,
497 }
498 }
499 pub fn foreground_model(&self) -> &QualifiedModelId {
500 self.policy.foreground_model()
501 }
502 pub fn policy(&self) -> &DelegationPolicy {
503 &self.policy
504 }
505 pub fn grant_worker_model(&mut self, model: QualifiedModelId) -> Result<(), &'static str> {
509 self.policy.grant_worker_model(model)?;
510 self.emit(OrchestrationEvent {
511 name: "worker.model_granted",
512 worker_id: None,
513 worker_role: None,
514 reason_code: Some("user_session_grant"),
515 });
516 Ok(())
517 }
518 pub fn total_dispatched(&self) -> usize {
519 self.total
520 }
521 pub fn telemetry(&self) -> &VecDeque<OrchestrationEvent> {
522 &self.events
523 }
524 pub fn dropped_telemetry(&self) -> u64 {
525 self.dropped_events
526 }
527 fn emit(&mut self, event: OrchestrationEvent) {
528 const CAPACITY: usize = 256;
529 if self.events.len() == CAPACITY {
530 self.events.pop_front();
531 self.dropped_events += 1;
532 }
533 self.events.push_back(event);
534 }
535 pub fn mode(&self) -> EnforcementMode {
536 self.policy.mode
537 }
538 pub fn rollback_dispatch(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
539 let worker = self.workers.get(h).ok_or("unknown worker")?;
540 if worker.state != State::Dispatched && worker.state != State::Starting {
541 return Err("worker already started");
542 }
543 self.workers.remove(h);
544 self.total = self.total.saturating_sub(1);
545 self.emit(OrchestrationEvent {
546 name: "worker.dispatch_rolled_back",
547 worker_id: Some(h.0.clone()),
548 worker_role: None,
549 reason_code: Some("post_authorization_failure"),
550 });
551 Ok(())
552 }
553 pub fn validate_dispatch(&self, model: &QualifiedModelId) -> Result<(), DispatchDenied> {
554 let now = std::time::SystemTime::now()
555 .duration_since(std::time::UNIX_EPOCH)
556 .unwrap_or_default()
557 .as_secs();
558 self.validate_dispatch_at(model, now)
559 }
560 pub fn validate_dispatch_at(
561 &self,
562 model: &QualifiedModelId,
563 now_unix: u64,
564 ) -> Result<(), DispatchDenied> {
565 self.policy.authorize_at(model, now_unix)?;
566 if self.total >= self.policy.max_total_workers {
567 return Err(DispatchDenied::new(DispatchFailureCode::TotalWorkerLimit));
568 }
569 if self
570 .workers
571 .values()
572 .filter(|w| {
573 matches!(
574 w.state,
575 State::Dispatched | State::Starting | State::Running
576 )
577 })
578 .count()
579 >= self.policy.max_concurrent_workers
580 {
581 return Err(DispatchDenied::new(DispatchFailureCode::ConcurrencyLimit));
582 }
583 Ok(())
584 }
585 pub fn authorize_dispatch(
586 &mut self,
587 model: &QualifiedModelId,
588 role: WorkerRole,
589 writes: WorkerWritePolicy,
590 ) -> Result<WorkerHandle, DispatchDenied> {
591 let now = std::time::SystemTime::now()
592 .duration_since(std::time::UNIX_EPOCH)
593 .unwrap_or_default()
594 .as_secs();
595 self.authorize_dispatch_at(model, role, writes, now)
596 }
597 pub fn authorize_dispatch_at(
598 &mut self,
599 model: &QualifiedModelId,
600 role: WorkerRole,
601 writes: WorkerWritePolicy,
602 now_unix: u64,
603 ) -> Result<WorkerHandle, DispatchDenied> {
604 self.emit(OrchestrationEvent {
605 name: "worker.dispatch_requested",
606 worker_id: None,
607 worker_role: None,
608 reason_code: None,
609 });
610 self.emit(OrchestrationEvent {
611 name: "worker.model_resolution_requested",
612 worker_id: None,
613 worker_role: None,
614 reason_code: None,
615 });
616 if let Err(error) = self.validate_dispatch_at(model, now_unix) {
617 self.emit(OrchestrationEvent {
618 name: "worker.dispatch_denied",
619 worker_id: None,
620 worker_role: None,
621 reason_code: Some(error.code()),
622 });
623 return Err(error);
624 }
625 self.total += 1;
626 let h = WorkerHandle(format!("worker-{}", self.total));
627 self.workers.insert(
628 h.clone(),
629 Worker {
630 state: State::Dispatched,
631 role,
632 writes,
633 unchanged_polls: 0,
634 steered: false,
635 last_fingerprint: None,
636 },
637 );
638 self.emit(OrchestrationEvent {
639 name: "worker.dispatch_allowed",
640 worker_id: Some(h.0.clone()),
641 worker_role: Some(role),
642 reason_code: None,
643 });
644 Ok(h)
645 }
646 fn transition(
647 &mut self,
648 h: &WorkerHandle,
649 from: &[State],
650 to: State,
651 event: &'static str,
652 ) -> Result<(), &'static str> {
653 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
654 if !from.contains(&w.state) {
655 return Err("invalid lifecycle transition");
656 }
657 w.state = to;
658 let role = w.role;
659 self.emit(OrchestrationEvent {
660 name: event,
661 worker_id: Some(h.0.clone()),
662 worker_role: Some(role),
663 reason_code: None,
664 });
665 Ok(())
666 }
667 pub fn mark_starting(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
668 self.transition(h, &[State::Dispatched], State::Starting, "worker.starting")
669 }
670 pub fn mark_running(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
671 self.transition(h, &[State::Starting], State::Running, "worker.running")
672 }
673 pub fn poll(&mut self, h: &WorkerHandle, fingerprint: &str) -> Result<(), &'static str> {
674 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
675 if w.state != State::Running {
676 return Err("worker not running");
677 }
678 if w.last_fingerprint.as_deref() == Some(fingerprint) {
679 w.unchanged_polls += 1;
680 } else {
681 w.unchanged_polls = 0;
682 w.steered = false;
683 w.last_fingerprint = Some(fingerprint.to_owned());
684 }
685 self.emit(OrchestrationEvent {
686 name: "worker.polled",
687 worker_id: Some(h.0.clone()),
688 worker_role: None,
689 reason_code: None,
690 });
691 Ok(())
692 }
693 pub fn is_stalled(&self, h: &WorkerHandle) -> Result<bool, &'static str> {
694 let w = self.workers.get(h).ok_or("unknown worker")?;
695 Ok(w.unchanged_polls >= 2 && w.steered)
696 }
697 pub fn steer(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
698 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
699 if w.state != State::Running {
700 return Err("worker not running");
701 }
702 w.steered = true;
703 self.emit(OrchestrationEvent {
704 name: "worker.steered",
705 worker_id: Some(h.0.clone()),
706 worker_role: None,
707 reason_code: None,
708 });
709 Ok(())
710 }
711 pub fn mark_terminal(
712 &mut self,
713 h: &WorkerHandle,
714 _: WorkerTerminal,
715 ) -> Result<(), &'static str> {
716 if self.workers.get(h).is_some_and(|worker| {
717 matches!(
718 worker.state,
719 State::Terminal | State::Collected | State::Reconciled
720 )
721 }) {
722 return Ok(());
723 }
724 self.transition(h, &[State::Running], State::Terminal, "worker.terminal")
725 }
726 pub fn collect(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
727 if self
728 .workers
729 .get(h)
730 .is_some_and(|w| matches!(w.state, State::Collected | State::Reconciled))
731 {
732 return Ok(());
733 }
734 self.transition(h, &[State::Terminal], State::Collected, "worker.collected")
735 }
736 pub fn reconcile(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
737 if self
738 .workers
739 .get(h)
740 .is_some_and(|w| w.state == State::Reconciled)
741 {
742 return Ok(());
743 }
744 self.transition(
745 h,
746 &[State::Collected],
747 State::Reconciled,
748 "worker.reconciled",
749 )
750 }
751 pub fn completion_gate(&self) -> CompletionGate {
752 let workers: Vec<_> = self
757 .workers
758 .iter()
759 .filter(|(_, w)| matches!(w.state, State::Terminal | State::Collected))
760 .map(|(h, _)| h.0.clone())
761 .collect();
762 if workers.is_empty() || self.policy.mode == EnforcementMode::Off {
763 CompletionGate::Allowed
764 } else if self.policy.mode == EnforcementMode::Advisory {
765 CompletionGate::Warning { workers }
766 } else {
767 CompletionGate::Blocked { workers }
768 }
769 }
770 pub fn all_unreconciled_ids(&self) -> Vec<String> {
774 self.workers
775 .iter()
776 .filter(|(_, w)| w.state != State::Reconciled)
777 .map(|(h, _)| h.0.clone())
778 .collect()
779 }
780 pub fn check_foreground_write(&self, path: &str) -> ScopeDecision {
781 let workers: Vec<_> = self
782 .workers
783 .iter()
784 .filter(|(_, w)| {
785 matches!(
786 w.state,
787 State::Dispatched | State::Starting | State::Running
788 ) && overlaps(&w.writes, path)
789 })
790 .map(|(h, _)| h.0.clone())
791 .collect();
792 if workers.is_empty() || self.policy.mode == EnforcementMode::Off {
793 ScopeDecision::Allowed
794 } else if self.policy.mode == EnforcementMode::Advisory {
795 ScopeDecision::Warning { workers }
796 } else {
797 ScopeDecision::ReconciliationRequired { workers }
798 }
799 }
800}
801fn overlaps(policy: &WorkerWritePolicy, path: &str) -> bool {
802 match policy {
803 WorkerWritePolicy::ReadOnly | WorkerWritePolicy::IsolatedWorktree => false,
804 WorkerWritePolicy::NonOverlappingPaths(paths) => paths.iter().any(|p| {
805 p.strip_suffix("/**").map_or(p == path, |prefix| {
806 path == prefix || path.starts_with(&format!("{prefix}/"))
807 })
808 }),
809 }
810}