Skip to main content

agent_core/
orchestration.rs

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/// One runtime-owned model descriptor. A manifest can only narrow these capabilities.
17#[derive(Clone, Debug, Serialize)]
18pub struct CatalogEntry {
19    pub model: QualifiedModelId,
20    pub available: bool,
21    pub worker_eligible: bool,
22}
23
24/// Immutable, runtime-controlled worker catalog. Entries are exact qualified identities.
25#[derive(Clone, Debug, Serialize)]
26pub struct CatalogSnapshot {
27    id: String,
28    digest_sha256: String,
29    entries: BTreeMap<QualifiedModelId, CatalogEntry>,
30}
31impl CatalogSnapshot {
32    /// Trusted convenience constructor for callers whose descriptors are all active workers.
33    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    /// Admits one additional exact identity as an available worker-eligible
65    /// descriptor, recomputing the snapshot id/digest. Used for explicit
66    /// mid-session user grants; an existing entry is upgraded to eligible.
67    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    /// Trusted Unix expiry. `None` is retained for compatibility with pinned policy files.
85    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    /// Compatibility constructor. Its explicit models form the trusted pinned catalog.
163    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    /// Secure manifestless baseline: every source-controlled worker-eligible
216    /// descriptor for the foreground provider is allowed. Other providers still
217    /// require explicit cross-provider grants.
218    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    /// Atomically evaluates exact model, provenance, grant id and expiry against one
300    /// trusted clock sample supplied by the runtime.
301    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    /// Honors an explicit user trust grant issued after session start. Trusted
350    /// models were never meant to be pinned at session load: the exact identity
351    /// joins the catalog and either the same-provider allowlist or a fresh
352    /// non-expiring session cross-provider grant. Session grants are inserted
353    /// ahead of pinned grants so a stale expiring grant for the same identity
354    /// can never shadow the user's explicit decision. Worker lifecycle state
355    /// and concurrency limits are untouched.
356    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    /// Stable policy-side identity (`worker-N`). Runtime layers map this back
423    /// to tool-facing handles (`sa_*`) for remediation messages.
424    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    /// Applies an explicit mid-session user trust grant for one exact worker
506    /// model. Existing worker lifecycle state and limits are unchanged; the
507    /// grant is visible to the next dispatch decision.
508    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        // Only block on FINISHED-but-unreconciled workers. Running/Starting/
753        // Dispatched workers pass through — that's the reactive pattern
754        // (subagent_start → collect on wake). Blocking on Running makes
755        // subagent_start functionally identical to subagent.
756        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    /// All non-reconciled worker IDs — including running ones. Used by the
771    /// reaper to decide retention (don't GC a worker the orchestration still
772    /// tracks, even if the completion gate lets the turn through).
773    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}