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 pub recycle_reconciled_workers: bool,
162}
163impl DelegationPolicy {
164 pub fn new(
166 mode: EnforcementMode,
167 foreground: QualifiedModelId,
168 models: impl IntoIterator<Item = QualifiedModelId>,
169 concurrent: usize,
170 total: usize,
171 ) -> Self {
172 let allowed_models: BTreeSet<_> = models.into_iter().collect();
173 let mut catalog_entries = allowed_models.clone();
174 catalog_entries.insert(foreground.clone());
175 let catalog = CatalogSnapshot::new(catalog_entries);
176 Self::build(
177 mode,
178 foreground,
179 catalog,
180 allowed_models,
181 Vec::new(),
182 concurrent,
183 total,
184 )
185 .expect("legacy delegation policy has valid limits")
186 }
187 pub fn enforced(
188 foreground: QualifiedModelId,
189 models: impl IntoIterator<Item = QualifiedModelId>,
190 concurrent: usize,
191 total: usize,
192 ) -> Self {
193 Self::new(
194 EnforcementMode::Enforced,
195 foreground,
196 models,
197 concurrent,
198 total,
199 )
200 }
201 pub fn baseline(
202 foreground: QualifiedModelId,
203 catalog: CatalogSnapshot,
204 concurrent: usize,
205 total: usize,
206 ) -> Result<Self, &'static str> {
207 Self::build(
208 EnforcementMode::Enforced,
209 foreground.clone(),
210 catalog,
211 [foreground].into_iter().collect(),
212 Vec::new(),
213 concurrent,
214 total,
215 )
216 }
217 pub fn provider_baseline(
221 foreground: QualifiedModelId,
222 catalog: CatalogSnapshot,
223 same_provider_models: impl IntoIterator<Item = QualifiedModelId>,
224 concurrent: usize,
225 total: usize,
226 ) -> Result<Self, &'static str> {
227 Self::build(
228 EnforcementMode::Enforced,
229 foreground,
230 catalog,
231 same_provider_models.into_iter().collect(),
232 Vec::new(),
233 concurrent,
234 total,
235 )
236 }
237 pub fn with_grants(
238 foreground: QualifiedModelId,
239 catalog: CatalogSnapshot,
240 same_provider_models: impl IntoIterator<Item = QualifiedModelId>,
241 grants: impl IntoIterator<Item = CrossProviderGrant>,
242 concurrent: usize,
243 total: usize,
244 ) -> Result<Self, &'static str> {
245 Self::build(
246 EnforcementMode::Enforced,
247 foreground,
248 catalog,
249 same_provider_models.into_iter().collect(),
250 grants.into_iter().collect(),
251 concurrent,
252 total,
253 )
254 }
255 fn build(
256 mode: EnforcementMode,
257 foreground: QualifiedModelId,
258 catalog: CatalogSnapshot,
259 allowed_models: BTreeSet<QualifiedModelId>,
260 grants: Vec<CrossProviderGrant>,
261 concurrent: usize,
262 total: usize,
263 ) -> Result<Self, &'static str> {
264 if concurrent == 0 || total == 0 || concurrent > total || !catalog.contains(&foreground) {
265 return Err("invalid delegation policy");
266 }
267 if allowed_models
268 .iter()
269 .any(|m| m.provider() != foreground.provider() || !catalog.contains(m))
270 {
271 return Err("same-provider allowlist is invalid");
272 }
273 if grants.iter().any(|g| {
274 g.from_provider != foreground.provider()
275 || g.allowed_models.iter().any(|m| !catalog.contains(m))
276 }) {
277 return Err("cross-provider grant is invalid");
278 }
279 let mut choices = allowed_models.clone();
280 for grant in &grants {
281 choices.extend(grant.allowed_models.iter().cloned());
282 }
283 Ok(Self {
284 mode,
285 foreground,
286 catalog,
287 allowed_models,
288 grants,
289 effective_choices: choices.into_iter().collect(),
290 max_concurrent_workers: concurrent,
291 max_total_workers: total,
292 recycle_reconciled_workers: false,
293 })
294 }
295 pub fn authorize(&self, model: &QualifiedModelId) -> Result<Option<&str>, DispatchDenied> {
296 let now = std::time::SystemTime::now()
297 .duration_since(std::time::UNIX_EPOCH)
298 .unwrap_or_default()
299 .as_secs();
300 self.authorize_at(model, now)
301 }
302 pub fn authorize_at(
305 &self,
306 model: &QualifiedModelId,
307 now_unix: u64,
308 ) -> Result<Option<&str>, DispatchDenied> {
309 if !self.catalog.contains(model) {
310 return Err(DispatchDenied::new(
311 DispatchFailureCode::CatalogModelUnknown,
312 ));
313 }
314 if self.mode != EnforcementMode::Enforced {
315 return Ok(None);
316 }
317 if model.provider() == self.foreground.provider() {
318 return self
319 .allowed_models
320 .contains(model)
321 .then_some(None)
322 .ok_or_else(|| DispatchDenied::new(DispatchFailureCode::ModelNotAllowed));
323 }
324 if let Some(grant) = self
325 .grants
326 .iter()
327 .find(|g| g.to_provider == model.provider() && g.allowed_models.contains(model))
328 {
329 if grant
330 .expires_at_unix
331 .is_some_and(|expiry| now_unix >= expiry)
332 {
333 return Err(DispatchDenied::new(
334 DispatchFailureCode::CrossProviderGrantExpired,
335 ));
336 }
337 return Ok(Some(&grant.grant_id));
338 }
339 if self
340 .grants
341 .iter()
342 .any(|g| g.to_provider == model.provider())
343 {
344 Err(DispatchDenied::new(DispatchFailureCode::ModelNotAllowed))
345 } else {
346 Err(DispatchDenied::new(DispatchFailureCode::ProviderNotAllowed))
347 }
348 }
349 pub fn effective_choices(&self) -> &[QualifiedModelId] {
350 &self.effective_choices
351 }
352 pub fn grant_worker_model(&mut self, model: QualifiedModelId) -> Result<(), &'static str> {
360 if !self.catalog.contains(&model) {
361 self.catalog = self.catalog.with_model(model.clone());
362 }
363 if model.provider() == self.foreground.provider() {
364 self.allowed_models.insert(model.clone());
365 } else if !self.grants.iter().any(|grant| {
366 grant.to_provider == model.provider()
367 && grant.allowed_models.contains(&model)
368 && grant.expires_at_unix.is_none()
369 }) {
370 let grant = CrossProviderGrant::new(
371 format!("session-user-grant-{}", model.as_str()),
372 self.foreground.provider().to_owned(),
373 model.provider().to_owned(),
374 [model.clone()],
375 )?;
376 self.grants.insert(0, grant);
377 }
378 if !self.effective_choices.contains(&model) {
379 self.effective_choices.push(model);
380 self.effective_choices.sort();
381 }
382 Ok(())
383 }
384 pub fn foreground_model(&self) -> &QualifiedModelId {
385 &self.foreground
386 }
387 pub fn catalog_snapshot_id(&self) -> &str {
388 self.catalog.id()
389 }
390 pub fn catalog_digest(&self) -> &str {
391 self.catalog.digest_sha256()
392 }
393 pub fn digest(&self) -> String {
394 let encoded = serde_json::to_vec(self).expect("policy is serializable");
395 format!("{:x}", Sha256::digest(encoded))
396 }
397}
398
399#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum WorkerRole {
402 Planner,
403 Implementer,
404 Tester,
405 Reviewer,
406 Researcher,
407 Debugger,
408}
409#[derive(Clone, Debug, Deserialize)]
410#[serde(rename_all = "snake_case", tag = "mode", content = "scopes")]
411pub enum WorkerWritePolicy {
412 ReadOnly,
413 IsolatedWorktree,
414 NonOverlappingPaths(Vec<String>),
415}
416#[derive(Clone, Copy, Debug)]
417pub enum WorkerTerminal {
418 Completed,
419 Failed,
420 TimedOut,
421}
422#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
423pub struct WorkerHandle(String);
424impl WorkerHandle {
425 pub fn id(&self) -> &str {
428 &self.0
429 }
430}
431#[derive(Debug, Eq, PartialEq)]
432pub enum CompletionGate {
433 Allowed,
434 Warning { workers: Vec<String> },
435 Blocked { workers: Vec<String> },
436}
437#[derive(Debug, Eq, PartialEq)]
438pub enum ScopeDecision {
439 Allowed,
440 Warning { workers: Vec<String> },
441 ReconciliationRequired { workers: Vec<String> },
442}
443#[derive(Debug)]
444pub struct DispatchDenied {
445 code: DispatchFailureCode,
446}
447impl DispatchDenied {
448 fn new(code: DispatchFailureCode) -> Self {
449 Self { code }
450 }
451 pub fn code(&self) -> &'static str {
452 self.code.as_str()
453 }
454 pub fn typed_code(&self) -> DispatchFailureCode {
455 self.code
456 }
457}
458
459#[derive(Clone, Copy, Debug, Eq, PartialEq)]
460enum State {
461 Dispatched,
462 Starting,
463 Running,
464 Terminal,
465 Collected,
466 Reconciled,
467}
468#[derive(Debug)]
469struct Worker {
470 state: State,
471 role: WorkerRole,
472 writes: WorkerWritePolicy,
473 unchanged_polls: usize,
474 steered: bool,
475 last_fingerprint: Option<String>,
476}
477#[derive(Debug, Serialize)]
478pub struct OrchestrationEvent {
479 pub name: &'static str,
480 pub worker_id: Option<String>,
481 pub worker_role: Option<WorkerRole>,
482 pub reason_code: Option<&'static str>,
483}
484
485pub struct WorkerRegistry {
486 policy: DelegationPolicy,
487 workers: BTreeMap<WorkerHandle, Worker>,
488 total: usize,
489 next_worker_id: u64,
490 events: VecDeque<OrchestrationEvent>,
491 dropped_events: u64,
492}
493impl WorkerRegistry {
494 pub fn new(policy: DelegationPolicy) -> Self {
495 Self {
496 policy,
497 workers: BTreeMap::new(),
498 total: 0,
499 next_worker_id: 0,
500 events: VecDeque::new(),
501 dropped_events: 0,
502 }
503 }
504 pub fn foreground_model(&self) -> &QualifiedModelId {
505 self.policy.foreground_model()
506 }
507 pub fn policy(&self) -> &DelegationPolicy {
508 &self.policy
509 }
510 pub fn grant_worker_model(&mut self, model: QualifiedModelId) -> Result<(), &'static str> {
514 self.policy.grant_worker_model(model)?;
515 self.emit(OrchestrationEvent {
516 name: "worker.model_granted",
517 worker_id: None,
518 worker_role: None,
519 reason_code: Some("user_session_grant"),
520 });
521 Ok(())
522 }
523 pub fn total_dispatched(&self) -> usize {
524 self.total
525 }
526 pub fn telemetry(&self) -> &VecDeque<OrchestrationEvent> {
527 &self.events
528 }
529 pub fn dropped_telemetry(&self) -> u64 {
530 self.dropped_events
531 }
532 fn emit(&mut self, event: OrchestrationEvent) {
533 const CAPACITY: usize = 256;
534 if self.events.len() == CAPACITY {
535 self.events.pop_front();
536 self.dropped_events += 1;
537 }
538 self.events.push_back(event);
539 }
540 pub fn mode(&self) -> EnforcementMode {
541 self.policy.mode
542 }
543 pub fn rollback_dispatch(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
544 let worker = self.workers.get(h).ok_or("unknown worker")?;
545 if worker.state != State::Dispatched && worker.state != State::Starting {
546 return Err("worker already started");
547 }
548 self.workers.remove(h);
549 self.total = self.total.saturating_sub(1);
550 self.emit(OrchestrationEvent {
551 name: "worker.dispatch_rolled_back",
552 worker_id: Some(h.0.clone()),
553 worker_role: None,
554 reason_code: Some("post_authorization_failure"),
555 });
556 Ok(())
557 }
558 pub fn validate_dispatch(&self, model: &QualifiedModelId) -> Result<(), DispatchDenied> {
559 let now = std::time::SystemTime::now()
560 .duration_since(std::time::UNIX_EPOCH)
561 .unwrap_or_default()
562 .as_secs();
563 self.validate_dispatch_at(model, now)
564 }
565 pub fn validate_dispatch_at(
566 &self,
567 model: &QualifiedModelId,
568 now_unix: u64,
569 ) -> Result<(), DispatchDenied> {
570 self.policy.authorize_at(model, now_unix)?;
571 let budget_used = if self.policy.recycle_reconciled_workers {
572 self.workers
573 .values()
574 .filter(|w| w.state != State::Reconciled)
575 .count()
576 } else {
577 self.total
578 };
579 if budget_used >= self.policy.max_total_workers {
580 return Err(DispatchDenied::new(DispatchFailureCode::TotalWorkerLimit));
581 }
582 if self
583 .workers
584 .values()
585 .filter(|w| {
586 matches!(
587 w.state,
588 State::Dispatched | State::Starting | State::Running
589 )
590 })
591 .count()
592 >= self.policy.max_concurrent_workers
593 {
594 return Err(DispatchDenied::new(DispatchFailureCode::ConcurrencyLimit));
595 }
596 Ok(())
597 }
598 pub fn authorize_dispatch(
599 &mut self,
600 model: &QualifiedModelId,
601 role: WorkerRole,
602 writes: WorkerWritePolicy,
603 ) -> Result<WorkerHandle, DispatchDenied> {
604 let now = std::time::SystemTime::now()
605 .duration_since(std::time::UNIX_EPOCH)
606 .unwrap_or_default()
607 .as_secs();
608 self.authorize_dispatch_at(model, role, writes, now)
609 }
610 pub fn authorize_dispatch_at(
611 &mut self,
612 model: &QualifiedModelId,
613 role: WorkerRole,
614 writes: WorkerWritePolicy,
615 now_unix: u64,
616 ) -> Result<WorkerHandle, DispatchDenied> {
617 self.emit(OrchestrationEvent {
618 name: "worker.dispatch_requested",
619 worker_id: None,
620 worker_role: None,
621 reason_code: None,
622 });
623 self.emit(OrchestrationEvent {
624 name: "worker.model_resolution_requested",
625 worker_id: None,
626 worker_role: None,
627 reason_code: None,
628 });
629 if let Err(error) = self.validate_dispatch_at(model, now_unix) {
630 self.emit(OrchestrationEvent {
631 name: "worker.dispatch_denied",
632 worker_id: None,
633 worker_role: None,
634 reason_code: Some(error.code()),
635 });
636 return Err(error);
637 }
638 self.total += 1;
639 self.next_worker_id += 1;
640 let h = WorkerHandle(format!("worker-{}", self.next_worker_id));
641 self.workers.insert(
642 h.clone(),
643 Worker {
644 state: State::Dispatched,
645 role,
646 writes,
647 unchanged_polls: 0,
648 steered: false,
649 last_fingerprint: None,
650 },
651 );
652 self.emit(OrchestrationEvent {
653 name: "worker.dispatch_allowed",
654 worker_id: Some(h.0.clone()),
655 worker_role: Some(role),
656 reason_code: None,
657 });
658 Ok(h)
659 }
660 fn transition(
661 &mut self,
662 h: &WorkerHandle,
663 from: &[State],
664 to: State,
665 event: &'static str,
666 ) -> Result<(), &'static str> {
667 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
668 if !from.contains(&w.state) {
669 return Err("invalid lifecycle transition");
670 }
671 w.state = to;
672 let role = w.role;
673 self.emit(OrchestrationEvent {
674 name: event,
675 worker_id: Some(h.0.clone()),
676 worker_role: Some(role),
677 reason_code: None,
678 });
679 Ok(())
680 }
681 pub fn mark_starting(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
682 self.transition(h, &[State::Dispatched], State::Starting, "worker.starting")
683 }
684 pub fn mark_running(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
685 self.transition(h, &[State::Starting], State::Running, "worker.running")
686 }
687 pub fn poll(&mut self, h: &WorkerHandle, fingerprint: &str) -> Result<(), &'static str> {
688 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
689 if w.state != State::Running {
690 return Err("worker not running");
691 }
692 if w.last_fingerprint.as_deref() == Some(fingerprint) {
693 w.unchanged_polls += 1;
694 } else {
695 w.unchanged_polls = 0;
696 w.steered = false;
697 w.last_fingerprint = Some(fingerprint.to_owned());
698 }
699 self.emit(OrchestrationEvent {
700 name: "worker.polled",
701 worker_id: Some(h.0.clone()),
702 worker_role: None,
703 reason_code: None,
704 });
705 Ok(())
706 }
707 pub fn is_stalled(&self, h: &WorkerHandle) -> Result<bool, &'static str> {
708 let w = self.workers.get(h).ok_or("unknown worker")?;
709 Ok(w.unchanged_polls >= 2 && w.steered)
710 }
711 pub fn steer(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
712 let w = self.workers.get_mut(h).ok_or("unknown worker")?;
713 if w.state != State::Running {
714 return Err("worker not running");
715 }
716 w.steered = true;
717 self.emit(OrchestrationEvent {
718 name: "worker.steered",
719 worker_id: Some(h.0.clone()),
720 worker_role: None,
721 reason_code: None,
722 });
723 Ok(())
724 }
725 pub fn mark_terminal(
726 &mut self,
727 h: &WorkerHandle,
728 _: WorkerTerminal,
729 ) -> Result<(), &'static str> {
730 if self.workers.get(h).is_some_and(|worker| {
731 matches!(
732 worker.state,
733 State::Terminal | State::Collected | State::Reconciled
734 )
735 }) {
736 return Ok(());
737 }
738 self.transition(h, &[State::Running], State::Terminal, "worker.terminal")
739 }
740 pub fn collect(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
741 if self
742 .workers
743 .get(h)
744 .is_some_and(|w| matches!(w.state, State::Collected | State::Reconciled))
745 {
746 return Ok(());
747 }
748 self.transition(h, &[State::Terminal], State::Collected, "worker.collected")
749 }
750 pub fn reconcile(&mut self, h: &WorkerHandle) -> Result<(), &'static str> {
751 if self
752 .workers
753 .get(h)
754 .is_some_and(|w| w.state == State::Reconciled)
755 {
756 return Ok(());
757 }
758 self.transition(
759 h,
760 &[State::Collected],
761 State::Reconciled,
762 "worker.reconciled",
763 )
764 }
765 pub fn retire_reconciled(&mut self, h: &WorkerHandle) -> bool {
768 if self
769 .workers
770 .get(h)
771 .is_some_and(|w| w.state == State::Reconciled)
772 {
773 self.workers.remove(h);
774 true
775 } else {
776 false
777 }
778 }
779 pub fn completion_gate(&self) -> CompletionGate {
780 let workers: Vec<_> = self
785 .workers
786 .iter()
787 .filter(|(_, w)| matches!(w.state, State::Terminal | State::Collected))
788 .map(|(h, _)| h.0.clone())
789 .collect();
790 if workers.is_empty() || self.policy.mode == EnforcementMode::Off {
791 CompletionGate::Allowed
792 } else if self.policy.mode == EnforcementMode::Advisory {
793 CompletionGate::Warning { workers }
794 } else {
795 CompletionGate::Blocked { workers }
796 }
797 }
798 pub fn all_unreconciled_ids(&self) -> Vec<String> {
802 self.workers
803 .iter()
804 .filter(|(_, w)| w.state != State::Reconciled)
805 .map(|(h, _)| h.0.clone())
806 .collect()
807 }
808 pub fn check_foreground_write(&self, path: &str) -> ScopeDecision {
809 let workers: Vec<_> = self
810 .workers
811 .iter()
812 .filter(|(_, w)| {
813 matches!(
814 w.state,
815 State::Dispatched | State::Starting | State::Running
816 ) && overlaps(&w.writes, path)
817 })
818 .map(|(h, _)| h.0.clone())
819 .collect();
820 if workers.is_empty() || self.policy.mode == EnforcementMode::Off {
821 ScopeDecision::Allowed
822 } else if self.policy.mode == EnforcementMode::Advisory {
823 ScopeDecision::Warning { workers }
824 } else {
825 ScopeDecision::ReconciliationRequired { workers }
826 }
827 }
828}
829fn overlaps(policy: &WorkerWritePolicy, path: &str) -> bool {
830 match policy {
831 WorkerWritePolicy::ReadOnly | WorkerWritePolicy::IsolatedWorktree => false,
832 WorkerWritePolicy::NonOverlappingPaths(paths) => paths.iter().any(|p| {
833 p.strip_suffix("/**").map_or(p == path, |prefix| {
834 path == prefix || path.starts_with(&format!("{prefix}/"))
835 })
836 }),
837 }
838}