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    /// Manifestless interactive policies count outstanding work, not session age.
161    pub recycle_reconciled_workers: bool,
162}
163impl DelegationPolicy {
164    /// Compatibility constructor. Its explicit models form the trusted pinned catalog.
165    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    /// Secure manifestless baseline: every source-controlled worker-eligible
218    /// descriptor for the foreground provider is allowed. Other providers still
219    /// require explicit cross-provider grants.
220    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    /// Atomically evaluates exact model, provenance, grant id and expiry against one
303    /// trusted clock sample supplied by the runtime.
304    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    /// Honors an explicit user trust grant issued after session start. Trusted
353    /// models were never meant to be pinned at session load: the exact identity
354    /// joins the catalog and either the same-provider allowlist or a fresh
355    /// non-expiring session cross-provider grant. Session grants are inserted
356    /// ahead of pinned grants so a stale expiring grant for the same identity
357    /// can never shadow the user's explicit decision. Worker lifecycle state
358    /// and concurrency limits are untouched.
359    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    /// Stable policy-side identity (`worker-N`). Runtime layers map this back
426    /// to tool-facing handles (`sa_*`) for remediation messages.
427    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    /// Applies an explicit mid-session user trust grant for one exact worker
511    /// model. Existing worker lifecycle state and limits are unchanged; the
512    /// grant is visible to the next dispatch decision.
513    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    /// Drop metadata only after result reconciliation AND runtime-handle retirement.
766    /// Lifetime dispatch count and monotonic identity sequence are never refunded.
767    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        // Only block on FINISHED-but-unreconciled workers. Running/Starting/
781        // Dispatched workers pass through — that's the reactive pattern
782        // (subagent_start → collect on wake). Blocking on Running makes
783        // subagent_start functionally identical to subagent.
784        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    /// All non-reconciled worker IDs — including running ones. Used by the
799    /// reaper to decide retention (don't GC a worker the orchestration still
800    /// tracks, even if the completion gate lets the turn through).
801    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}