Skip to main content

aft/executor/
mod.rs

1mod single_flight;
2
3#[cfg(test)]
4mod tests;
5
6use std::{
7    collections::{HashMap, VecDeque},
8    sync::{
9        atomic::{AtomicU64, AtomicU8, AtomicUsize, Ordering},
10        Arc,
11    },
12    thread::{self, JoinHandle},
13    time::{Duration, Instant},
14};
15
16use crossbeam_channel::{Receiver, RecvError, RecvTimeoutError, Sender};
17use parking_lot::{Mutex, RwLock};
18use tokio::sync::oneshot;
19
20use crate::{context::AppContext, path_identity::ProjectRootId, protocol::Response};
21
22pub use single_flight::SingleFlight;
23
24const JOB_COST: isize = 1;
25
26/// Scheduler lane for command-handler execution.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum Lane {
29    /// Pure read-only work. Runs under the actor epoch read gate and is capped
30    /// per actor.
31    PureRead,
32    /// LSP/status work. Serialized per actor by scheduler admission while still
33    /// using the shared epoch read gate.
34    SerialLspStatus,
35    /// Heavy lazy initialization. The scheduler acquires a process-wide heavy
36    /// permit before dispatch; the worker runs the build outside the epoch and
37    /// then takes a short write gate for the install point.
38    HeavyInit,
39    /// Mutating work. Becomes a writer barrier at the actor queue head, drains
40    /// in-flight reads, and runs under the actor epoch write gate. Reserved for
41    /// configure and user-initiated tool mutations: background maintenance must
42    /// use `MaintenanceCommit` so it can never exclude interactive reads.
43    Mutating,
44    /// Maintenance work that mutates only subsystem state behind that
45    /// subsystem's own lock (watcher/LSP drains, completed-build installs,
46    /// callgraph store writes). Runs under the actor epoch READ gate and
47    /// overlaps PureReads; serialized to one in-flight per actor so
48    /// maintenance cannot self-stack.
49    MaintenanceCommit,
50}
51
52/// Scheduler class used to keep deferrable maintenance from occupying the
53/// workers reserved for interactive route binds, tool calls, and bash.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum JobClass {
56    Interactive,
57    Maintenance,
58}
59
60pub type ExecutorJob = Box<dyn FnOnce(&AppContext) -> Response + Send + 'static>;
61
62/// Age at which a queued interactive mutating job (route binds, edits) jumps
63/// ahead of pure reads in interactive admission. Reads-first admission would
64/// otherwise let a sustained read stream starve queued writers; this bounds
65/// that wait. For binds it matches the half-deadline breadcrumb point: the
66/// daemon rejects binds at 12s, so promotion at 6s leaves half the budget for
67/// draining readers and running the configure itself.
68const INTERACTIVE_WRITER_PROMOTION_AGE: Duration = Duration::from_secs(6);
69
70#[derive(Debug, Clone)]
71pub struct ExecutorConfig {
72    pub pool_size: usize,
73    pub read_cap: usize,
74    pub actor_cap: usize,
75    pub heavy_permits: usize,
76    pub drr_quantum: isize,
77}
78
79impl Default for ExecutorConfig {
80    fn default() -> Self {
81        let available = thread::available_parallelism()
82            .map(usize::from)
83            .unwrap_or(2);
84        let pool_size = available.saturating_sub(1).clamp(2, 8);
85        let actor_cap = pool_size.saturating_sub(1).clamp(1, 4);
86        let read_cap = actor_cap.clamp(1, 4);
87        let heavy_permits = pool_size.saturating_sub(1).clamp(2, 3);
88
89        Self {
90            pool_size,
91            read_cap,
92            actor_cap,
93            heavy_permits,
94            drr_quantum: 1,
95        }
96    }
97}
98
99#[derive(Debug, Clone)]
100struct EffectiveConfig {
101    pool_size: usize,
102    read_cap: usize,
103    actor_cap: usize,
104    heavy_permits: usize,
105    drr_quantum: isize,
106    deficit_cap: isize,
107    interactive_reserve: usize,
108    maintenance_cap: usize,
109}
110
111impl ExecutorConfig {
112    fn effective(&self) -> EffectiveConfig {
113        let pool_size = self.pool_size.clamp(2, 8);
114        let max_actor_cap = pool_size.saturating_sub(1).max(1);
115        let actor_cap = self.actor_cap.max(1).min(max_actor_cap);
116        let read_cap = self.read_cap.max(1).min(actor_cap).min(4);
117        // HeavyInit jobs share workers with RouteBind/configure. Keep one worker
118        // available even in a two-worker pool so a heavy-init storm cannot hold
119        // a fresh bind behind every executor worker.
120        let heavy_permits = self
121            .heavy_permits
122            .max(1)
123            .min(pool_size.saturating_sub(1).max(1))
124            .min(3);
125        let drr_quantum = self.drr_quantum.max(1);
126        let deficit_cap = (actor_cap.max(1) as isize) * 4;
127        let interactive_reserve = if pool_size >= 4 { 2 } else { 1 };
128        let maintenance_cap = pool_size.saturating_sub(interactive_reserve).max(1);
129
130        EffectiveConfig {
131            pool_size,
132            read_cap,
133            actor_cap,
134            heavy_permits,
135            drr_quantum,
136            deficit_cap,
137            interactive_reserve,
138            maintenance_cap,
139        }
140    }
141}
142
143/// Synchronous completion handle used by the executor tests and the
144/// future standalone bridge.
145pub struct CompletionHandle {
146    rx: Receiver<Response>,
147}
148
149impl CompletionHandle {
150    pub fn recv(self) -> Result<Response, RecvError> {
151        self.rx.recv()
152    }
153
154    pub fn recv_timeout(&self, timeout: Duration) -> Result<Response, RecvTimeoutError> {
155        self.rx.recv_timeout(timeout)
156    }
157
158    pub fn into_receiver(self) -> Receiver<Response> {
159        self.rx
160    }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct DispatchClassQueueSnapshot {
165    pub queued: usize,
166    pub oldest_age_ms: Option<u64>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct DispatchRunningSnapshot {
171    pub interactive: usize,
172    pub maintenance: usize,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct DispatchLivenessSnapshot {
177    pub interactive: DispatchClassQueueSnapshot,
178    pub maintenance: DispatchClassQueueSnapshot,
179    pub running: DispatchRunningSnapshot,
180    pub interactive_reserve: usize,
181    pub maintenance_cap: usize,
182}
183
184/// Scheduler-owned mirror read by health probes without taking the actor map
185/// lock or scanning every root. Oldest queue timestamps use `0` for absent and
186/// elapsed-milliseconds-plus-one for a present timestamp.
187struct DispatchLivenessAtomics {
188    origin: Instant,
189    interactive_queued: AtomicUsize,
190    interactive_oldest_enqueued_ms_plus_one: AtomicU64,
191    maintenance_queued: AtomicUsize,
192    maintenance_oldest_enqueued_ms_plus_one: AtomicU64,
193    interactive_running: AtomicUsize,
194    maintenance_running: AtomicUsize,
195}
196
197impl DispatchLivenessAtomics {
198    fn new() -> Self {
199        Self {
200            origin: Instant::now(),
201            interactive_queued: AtomicUsize::new(0),
202            interactive_oldest_enqueued_ms_plus_one: AtomicU64::new(0),
203            maintenance_queued: AtomicUsize::new(0),
204            maintenance_oldest_enqueued_ms_plus_one: AtomicU64::new(0),
205            interactive_running: AtomicUsize::new(0),
206            maintenance_running: AtomicUsize::new(0),
207        }
208    }
209
210    fn now_ms(&self) -> u64 {
211        duration_millis_u64(self.origin.elapsed())
212    }
213
214    fn record(&self, snapshot: &DispatchLivenessSnapshot) {
215        let now_ms = self.now_ms();
216        let encode_oldest = |queued: usize, oldest_age_ms: Option<u64>| {
217            if queued == 0 {
218                0
219            } else {
220                now_ms
221                    .saturating_sub(oldest_age_ms.unwrap_or(0))
222                    .saturating_add(1)
223            }
224        };
225        self.interactive_oldest_enqueued_ms_plus_one.store(
226            encode_oldest(
227                snapshot.interactive.queued,
228                snapshot.interactive.oldest_age_ms,
229            ),
230            Ordering::Relaxed,
231        );
232        self.maintenance_oldest_enqueued_ms_plus_one.store(
233            encode_oldest(
234                snapshot.maintenance.queued,
235                snapshot.maintenance.oldest_age_ms,
236            ),
237            Ordering::Relaxed,
238        );
239        self.interactive_running
240            .store(snapshot.running.interactive, Ordering::Release);
241        self.maintenance_running
242            .store(snapshot.running.maintenance, Ordering::Release);
243        self.interactive_queued
244            .store(snapshot.interactive.queued, Ordering::Release);
245        self.maintenance_queued
246            .store(snapshot.maintenance.queued, Ordering::Release);
247    }
248
249    fn snapshot(&self, config: &EffectiveConfig) -> DispatchLivenessSnapshot {
250        let now_ms = self.now_ms();
251        let decode_oldest = |queued: usize, encoded: u64| {
252            (queued > 0 && encoded > 0).then(|| now_ms.saturating_sub(encoded - 1))
253        };
254        let interactive_queued = self.interactive_queued.load(Ordering::Acquire);
255        let maintenance_queued = self.maintenance_queued.load(Ordering::Acquire);
256        DispatchLivenessSnapshot {
257            interactive: DispatchClassQueueSnapshot {
258                queued: interactive_queued,
259                oldest_age_ms: decode_oldest(
260                    interactive_queued,
261                    self.interactive_oldest_enqueued_ms_plus_one
262                        .load(Ordering::Relaxed),
263                ),
264            },
265            maintenance: DispatchClassQueueSnapshot {
266                queued: maintenance_queued,
267                oldest_age_ms: decode_oldest(
268                    maintenance_queued,
269                    self.maintenance_oldest_enqueued_ms_plus_one
270                        .load(Ordering::Relaxed),
271                ),
272            },
273            running: DispatchRunningSnapshot {
274                interactive: self.interactive_running.load(Ordering::Acquire),
275                maintenance: self.maintenance_running.load(Ordering::Acquire),
276            },
277            interactive_reserve: config.interactive_reserve,
278            maintenance_cap: config.maintenance_cap,
279        }
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct MutatingLaneSnapshot {
285    pub root_id: ProjectRootId,
286    pub request_id: String,
287    pub command: String,
288    pub started_age_ms: u64,
289}
290
291/// Non-blocking scheduler explanation attached to a delayed RouteBind warning.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct BindBlockerSnapshot {
294    pub configure_state: &'static str,
295    pub configure_phase_timings: Option<String>,
296    pub blockers: Vec<String>,
297}
298
299/// Cooperative cancellation for one cancellable executor job.
300///
301/// One atomic state machine (pending → running → committed | cancelled) is
302/// shared by the executor, the canceller, and the job. `cancel` and
303/// `try_seal_committed` race through compare-exchange on the same cell, so
304/// exactly one wins: a cancel that lands first makes the seal fail (the job
305/// must abort before mutating), and a seal that lands first makes the cancel
306/// report `RunningCommitted` (the mutation tail finishes normally). A job
307/// observes cancellation only at its own checkpoints
308/// ([`JobCancellation::cancel_requested_before_commit`]).
309#[derive(Debug, Clone)]
310pub struct JobCancellation {
311    inner: Arc<JobCancellationInner>,
312}
313
314#[derive(Debug)]
315struct JobCancellationInner {
316    state: AtomicU8,
317}
318
319const JOB_CANCEL_STATE_PENDING: u8 = 0;
320const JOB_CANCEL_STATE_RUNNING: u8 = 1;
321const JOB_CANCEL_STATE_COMMITTED: u8 = 2;
322const JOB_CANCEL_STATE_CANCELLED: u8 = 3;
323
324impl JobCancellation {
325    pub(crate) fn new() -> Self {
326        Self {
327            inner: Arc::new(JobCancellationInner {
328                state: AtomicU8::new(JOB_CANCEL_STATE_PENDING),
329            }),
330        }
331    }
332
333    fn mark_running(&self) {
334        let _ = self.inner.state.compare_exchange(
335            JOB_CANCEL_STATE_PENDING,
336            JOB_CANCEL_STATE_RUNNING,
337            Ordering::SeqCst,
338            Ordering::SeqCst,
339        );
340    }
341
342    /// Seal the job as committed. Returns `false` when a cancel already won
343    /// the race: the job must return early WITHOUT mutating, because the
344    /// canceller was told `RunningSignalled` and will discard its completion.
345    #[must_use]
346    pub fn try_seal_committed(&self) -> bool {
347        loop {
348            let current = self.state();
349            match current {
350                JOB_CANCEL_STATE_CANCELLED => return false,
351                JOB_CANCEL_STATE_COMMITTED => return true,
352                _ => {
353                    if self
354                        .inner
355                        .state
356                        .compare_exchange(
357                            current,
358                            JOB_CANCEL_STATE_COMMITTED,
359                            Ordering::SeqCst,
360                            Ordering::SeqCst,
361                        )
362                        .is_ok()
363                    {
364                        return true;
365                    }
366                }
367            }
368        }
369    }
370
371    /// Move to cancelled unless the job already sealed its commit. Returns the
372    /// state the transition observed (`COMMITTED` when the seal won).
373    fn signal_cancel(&self) -> u8 {
374        loop {
375            let current = self.state();
376            match current {
377                JOB_CANCEL_STATE_COMMITTED | JOB_CANCEL_STATE_CANCELLED => return current,
378                _ => {
379                    if self
380                        .inner
381                        .state
382                        .compare_exchange(
383                            current,
384                            JOB_CANCEL_STATE_CANCELLED,
385                            Ordering::SeqCst,
386                            Ordering::SeqCst,
387                        )
388                        .is_ok()
389                    {
390                        return current;
391                    }
392                }
393            }
394        }
395    }
396
397    fn state(&self) -> u8 {
398        self.inner.state.load(Ordering::SeqCst)
399    }
400
401    /// True when a cancel won the state race and the job must abort.
402    pub fn cancel_requested_before_commit(&self) -> bool {
403        self.state() == JOB_CANCEL_STATE_CANCELLED
404    }
405
406    fn same_token(&self, other: &JobCancellation) -> bool {
407        Arc::ptr_eq(&self.inner, &other.inner)
408    }
409}
410
411/// Outcome of [`Executor::cancel_job`].
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub enum JobCancelOutcome {
414    /// The job had not started; it was removed from the queue and its
415    /// completion settled with `request_cancelled`.
416    QueuedRemoved,
417    /// The job is running; its token is signalled and the job returns through
418    /// its normal completion path at the next checkpoint.
419    RunningSignalled,
420    /// The job already sealed its commit; it finishes normally and the caller
421    /// must discard its late completion.
422    RunningCommitted,
423    /// No queued or tracked job matched the token.
424    NotFound,
425}
426
427thread_local! {
428    static CURRENT_JOB_CANCELLATION: std::cell::RefCell<Option<JobCancellation>> =
429        const { std::cell::RefCell::new(None) };
430}
431
432/// The cancellation token of the job running on this worker thread, when the
433/// job was submitted through [`Executor::submit_cancellable_async`].
434pub fn current_job_cancellation() -> Option<JobCancellation> {
435    CURRENT_JOB_CANCELLATION.with(|slot| slot.borrow().clone())
436}
437
438struct CurrentJobCancellationGuard {
439    previous: Option<JobCancellation>,
440}
441
442impl CurrentJobCancellationGuard {
443    fn install(token: Option<JobCancellation>) -> Self {
444        let previous = CURRENT_JOB_CANCELLATION.with(|slot| slot.replace(token));
445        Self { previous }
446    }
447}
448
449impl Drop for CurrentJobCancellationGuard {
450    fn drop(&mut self) {
451        CURRENT_JOB_CANCELLATION.with(|slot| {
452            *slot.borrow_mut() = self.previous.take();
453        });
454    }
455}
456
457#[derive(Debug, Clone)]
458struct RunningJob {
459    root_id: ProjectRootId,
460    request_id: String,
461    command: String,
462    job_class: JobClass,
463    lane: Lane,
464    started_at: Instant,
465}
466
467#[derive(Debug, Clone)]
468struct RunningMutatingJob {
469    request_id: String,
470    command: String,
471    started_at: Instant,
472}
473
474/// Concurrent scheduler-dispatch executor.
475pub struct Executor {
476    inner: Arc<ExecutorInner>,
477}
478
479impl Executor {
480    pub fn new() -> Self {
481        Self::with_config(ExecutorConfig::default())
482    }
483
484    pub fn with_config(config: ExecutorConfig) -> Self {
485        let effective = config.effective();
486        let state = Arc::new(Mutex::new(SchedulerState::new(effective.clone())));
487        let heavy = Arc::new(HeavySemaphore::new(effective.heavy_permits));
488        let nonrunnable_dispatches = Arc::new(AtomicUsize::new(0));
489        let completed_interactive = Arc::new(AtomicU64::new(0));
490        let completed_maintenance = Arc::new(AtomicU64::new(0));
491        let dispatch_liveness = Arc::new(DispatchLivenessAtomics::new());
492        let (run_tx, run_rx) = crossbeam_channel::unbounded();
493        let (event_tx, event_rx) = crossbeam_channel::unbounded();
494
495        let scheduler_state = Arc::clone(&state);
496        let scheduler_heavy = Arc::clone(&heavy);
497        let scheduler_violations = Arc::clone(&nonrunnable_dispatches);
498        let scheduler_completed_interactive = Arc::clone(&completed_interactive);
499        let scheduler_completed_maintenance = Arc::clone(&completed_maintenance);
500        let scheduler_dispatch_liveness = Arc::clone(&dispatch_liveness);
501        let scheduler_handle = thread::Builder::new()
502            .name("aft-executor-scheduler".to_string())
503            .spawn(move || {
504                scheduler_loop(
505                    scheduler_state,
506                    scheduler_heavy,
507                    run_tx,
508                    event_rx,
509                    scheduler_violations,
510                    scheduler_completed_interactive,
511                    scheduler_completed_maintenance,
512                    scheduler_dispatch_liveness,
513                );
514            })
515            .expect("spawn AFT executor scheduler");
516
517        let mut worker_handles = Vec::with_capacity(effective.pool_size);
518        for worker_id in 0..effective.pool_size {
519            let worker_rx = run_rx.clone();
520            let worker_events = event_tx.clone();
521            let handle = thread::Builder::new()
522                .name(format!("aft-executor-worker-{worker_id}"))
523                .spawn(move || worker_loop(worker_rx, worker_events))
524                .expect("spawn AFT executor worker");
525            worker_handles.push(handle);
526        }
527
528        Self {
529            inner: Arc::new(ExecutorInner {
530                state,
531                event_tx,
532                scheduler_handle: Mutex::new(Some(scheduler_handle)),
533                worker_handles: Mutex::new(worker_handles),
534                config: effective,
535                nonrunnable_dispatches,
536                completed_interactive,
537                completed_maintenance,
538                dispatch_liveness,
539            }),
540        }
541    }
542
543    /// Register an actor if one is not already present.
544    ///
545    /// Existing actors keep their current context and scheduler state; subc
546    /// routing reuses them and reconfigures through the Mutating lane
547    /// rather than replacing the per-root [`AppContext`]. Returns `true` when a
548    /// new actor was inserted.
549    pub fn register_actor(&self, root_id: ProjectRootId, ctx: Arc<AppContext>) -> bool {
550        let memory_root = root_id.as_path().to_path_buf();
551        let inserted = {
552            let mut state = self.inner.state.lock();
553            if state.actors.contains_key(&root_id) {
554                false
555            } else {
556                state.actor_order.push(root_id.clone());
557                state
558                    .actors
559                    .insert(root_id, ActorState::new(Arc::clone(&ctx)));
560                true
561            }
562        };
563        if inserted {
564            let app = ctx.app();
565            crate::root_cache::register_live_scope(&ctx.storage_dir(), &memory_root);
566            app.register_memory_context(memory_root, &ctx);
567            app.actor_root_registered();
568        }
569        self.wake_scheduler();
570        inserted
571    }
572
573    /// Remove an actor from scheduler state.
574    ///
575    /// This is intentionally minimal: subc uses it only for a just-created
576    /// RouteBind actor whose configure failed before any route was installed, so
577    /// there is no in-flight work to quiesce. The removed [`AppContext`] is
578    /// dropped after releasing the scheduler lock so watcher/LSP teardown never
579    /// runs under that mutex.
580    pub fn remove_actor(&self, root_id: &ProjectRootId) {
581        let removed = {
582            let mut state = self.inner.state.lock();
583            state.actor_order.retain(|actor_root| actor_root != root_id);
584            state.actors.remove(root_id)
585        };
586        if let Some(actor) = removed.as_ref() {
587            let app = actor.ctx.app();
588            crate::root_cache::unregister_live_scope(&actor.ctx.storage_dir(), root_id.as_path());
589            app.unregister_memory_context(root_id.as_path(), &actor.ctx);
590            app.actor_root_unregistered();
591        }
592        drop(removed);
593        self.wake_scheduler();
594    }
595
596    /// Return true only when the actor has no queued or running executor work.
597    pub fn actor_is_idle(&self, root_id: &ProjectRootId) -> bool {
598        let state = self.inner.state.lock();
599        state.actors.get(root_id).is_some_and(ActorState::is_idle)
600    }
601
602    /// Non-blocking idle probe for maintenance sweeps. A contended scheduler is
603    /// reported separately so root retirement can conservatively wait for the
604    /// next sweep without stalling the module loop.
605    pub fn try_actor_is_idle(&self, root_id: &ProjectRootId) -> Option<bool> {
606        let state = self.inner.state.try_lock()?;
607        Some(state.actors.get(root_id).is_some_and(ActorState::is_idle))
608    }
609
610    /// Forget an idle actor and drop its root-scoped registries off the scheduler
611    /// thread. This is reserved for roots whose project directory no longer
612    /// exists; retained existing roots are reused on a later bind.
613    pub fn retire_idle_actor_in_background(&self, root_id: &ProjectRootId) -> bool {
614        let removed = {
615            let mut state = self.inner.state.lock();
616            if !state.actors.get(root_id).is_some_and(ActorState::is_idle) {
617                return false;
618            }
619            state.actor_order.retain(|actor_root| actor_root != root_id);
620            state.actors.remove(root_id)
621        };
622        let Some(actor) = removed else {
623            return false;
624        };
625        let app = actor.ctx.app();
626        crate::root_cache::unregister_live_scope(&actor.ctx.storage_dir(), root_id.as_path());
627        app.unregister_memory_context(root_id.as_path(), &actor.ctx);
628        app.actor_root_unregistered();
629        std::thread::spawn(move || {
630            actor.ctx.teardown_deleted_root();
631            drop(actor);
632        });
633        self.wake_scheduler();
634        true
635    }
636
637    /// Cancel maintenance jobs that have not started for one retained actor.
638    ///
639    /// Interactive work and already-running maintenance remain untouched. Each
640    /// cancelled job receives a normal completion so its caller can settle
641    /// bookkeeping through the same path as an executed job.
642    pub fn cancel_queued_maintenance(&self, root_id: &ProjectRootId) -> usize {
643        let cancelled = {
644            let mut state = self.inner.state.lock();
645            state
646                .actors
647                .get_mut(root_id)
648                .map(|actor| actor.maintenance.cancel_queued_jobs())
649                .unwrap_or(0)
650        };
651        if cancelled > 0 {
652            self.wake_scheduler();
653        }
654        cancelled
655    }
656
657    /// Return whether scheduler state currently has an actor for this root.
658    pub fn actor_registered(&self, root_id: &ProjectRootId) -> bool {
659        let state = self.inner.state.lock();
660        state.actors.contains_key(root_id)
661    }
662
663    /// Snapshot one actor context without retaining the scheduler lock while
664    /// maintenance drops root-scoped resources.
665    pub fn actor_context(&self, root_id: &ProjectRootId) -> Option<Arc<AppContext>> {
666        let state = self.inner.state.lock();
667        state
668            .actors
669            .get(root_id)
670            .map(|actor| Arc::clone(&actor.ctx))
671    }
672
673    /// Snapshot the registered actor contexts.
674    ///
675    /// The returned [`Arc`]s keep contexts alive after the scheduler lock is
676    /// released, so callers can run teardown without holding executor state.
677    pub fn actor_contexts(&self) -> Vec<Arc<AppContext>> {
678        let state = self.inner.state.lock();
679        state
680            .actors
681            .values()
682            .map(|actor_state| Arc::clone(&actor_state.ctx))
683            .collect()
684    }
685
686    /// Snapshot the registered root ids paired with their actor contexts.
687    pub fn actor_entries(&self) -> Vec<(ProjectRootId, Arc<AppContext>)> {
688        let state = self.inner.state.lock();
689        state
690            .actors
691            .iter()
692            .map(|(root_id, actor_state)| (root_id.clone(), Arc::clone(&actor_state.ctx)))
693            .collect()
694    }
695
696    /// Non-blocking variant for the health path: the probe reply must stay
697    /// cheap under any load, so it skips the actor list (reported as busy)
698    /// rather than waiting on the scheduler state lock.
699    pub fn try_actor_entries(&self) -> Option<Vec<(ProjectRootId, Arc<AppContext>)>> {
700        let state = self.inner.state.try_lock()?;
701        Some(
702            state
703                .actors
704                .iter()
705                .map(|(root_id, actor_state)| (root_id.clone(), Arc::clone(&actor_state.ctx)))
706                .collect(),
707        )
708    }
709
710    /// Constant-time scheduler contention signal for the health reply path.
711    pub fn try_actor_count(&self) -> Option<usize> {
712        self.inner.state.try_lock().map(|state| state.actors.len())
713    }
714
715    pub fn submit(
716        &self,
717        root_id: ProjectRootId,
718        lane: Lane,
719        request_id: String,
720        job: ExecutorJob,
721    ) -> CompletionHandle {
722        let (completion_tx, completion_rx) = crossbeam_channel::bounded(1);
723        self.submit_with_completion(
724            root_id,
725            JobClass::Interactive,
726            lane,
727            request_id,
728            job,
729            CompletionSender::Sync(completion_tx),
730        );
731        CompletionHandle { rx: completion_rx }
732    }
733
734    pub fn submit_async(
735        &self,
736        root_id: ProjectRootId,
737        lane: Lane,
738        request_id: String,
739        job: ExecutorJob,
740    ) -> oneshot::Receiver<Response> {
741        let (completion_tx, completion_rx) = oneshot::channel();
742        self.submit_with_completion(
743            root_id,
744            JobClass::Interactive,
745            lane,
746            request_id,
747            job,
748            CompletionSender::Async(completion_tx),
749        );
750        completion_rx
751    }
752
753    /// Submit an interactive job with an exact-job cancellation token.
754    ///
755    /// The returned token cancels THIS job only (queued: removed and settled
756    /// with `request_cancelled`; running: signalled cooperatively). The job
757    /// observes the token via [`current_job_cancellation`].
758    pub fn submit_cancellable_async(
759        &self,
760        root_id: ProjectRootId,
761        lane: Lane,
762        request_id: String,
763        job: ExecutorJob,
764    ) -> (oneshot::Receiver<Response>, JobCancellation) {
765        let cancellation = JobCancellation::new();
766        let (completion_tx, completion_rx) = oneshot::channel();
767        self.submit_with_completion_cancellable(
768            root_id,
769            JobClass::Interactive,
770            lane,
771            request_id,
772            job,
773            CompletionSender::Async(completion_tx),
774            Some(cancellation.clone()),
775        );
776        (completion_rx, cancellation)
777    }
778
779    /// Cancel one exact job by its token.
780    ///
781    /// Queued jobs are removed and settled immediately; running jobs are
782    /// signalled and return through their normal completion path at the next
783    /// cooperative checkpoint. Jobs that sealed their commit finish normally.
784    pub fn cancel_job(&self, root_id: &ProjectRootId, token: &JobCancellation) -> JobCancelOutcome {
785        // Signal BEFORE actor lookup, deliberately: a job whose actor was
786        // already torn down (fatal teardown, root removal) must still abort at
787        // its next checkpoint, so the token is cancelled regardless. The
788        // outcome must then reflect what the signal actually did — reporting
789        // NotFound for a token that was RUNNING at signal time would mislead
790        // the caller into treating a signalled job as nonexistent.
791        let observed = token.signal_cancel();
792        let (outcome, settled) = {
793            let mut state = self.inner.state.lock();
794            let Some(actor) = state.actors.get_mut(root_id) else {
795                return match observed {
796                    JOB_CANCEL_STATE_COMMITTED => JobCancelOutcome::RunningCommitted,
797                    JOB_CANCEL_STATE_RUNNING | JOB_CANCEL_STATE_PENDING => {
798                        JobCancelOutcome::RunningSignalled
799                    }
800                    _ => JobCancelOutcome::NotFound,
801                };
802            };
803            match actor.remove_queued_cancellable(token) {
804                Some(queued) => (JobCancelOutcome::QueuedRemoved, Some(queued)),
805                None => match observed {
806                    // The seal won the race: the job commits and finishes.
807                    JOB_CANCEL_STATE_COMMITTED => (JobCancelOutcome::RunningCommitted, None),
808                    // RUNNING at signal time, or PENDING at signal time but
809                    // dispatched before we took the scheduler lock: either way
810                    // the job aborts at its next checkpoint.
811                    JOB_CANCEL_STATE_RUNNING | JOB_CANCEL_STATE_PENDING => {
812                        (JobCancelOutcome::RunningSignalled, None)
813                    }
814                    // Already cancelled by an earlier call and no longer queued.
815                    _ => (JobCancelOutcome::NotFound, None),
816                },
817            }
818        };
819        if let Some(queued) = settled {
820            queued.completion.send(Response::error(
821                queued.request_id,
822                "request_cancelled",
823                "request cancelled before execution",
824            ));
825            self.wake_scheduler();
826        }
827        outcome
828    }
829
830    pub fn submit_maintenance_async(
831        &self,
832        root_id: ProjectRootId,
833        lane: Lane,
834        request_id: String,
835        job: ExecutorJob,
836    ) -> oneshot::Receiver<Response> {
837        let (completion_tx, completion_rx) = oneshot::channel();
838        self.submit_with_completion(
839            root_id,
840            JobClass::Maintenance,
841            lane,
842            request_id,
843            job,
844            CompletionSender::Async(completion_tx),
845        );
846        completion_rx
847    }
848
849    fn submit_with_completion(
850        &self,
851        root_id: ProjectRootId,
852        job_class: JobClass,
853        lane: Lane,
854        request_id: String,
855        job: ExecutorJob,
856        completion: CompletionSender,
857    ) {
858        self.submit_with_completion_cancellable(
859            root_id, job_class, lane, request_id, job, completion, None,
860        );
861    }
862
863    #[allow(clippy::too_many_arguments)]
864    fn submit_with_completion_cancellable(
865        &self,
866        root_id: ProjectRootId,
867        job_class: JobClass,
868        lane: Lane,
869        request_id: String,
870        job: ExecutorJob,
871        completion: CompletionSender,
872        cancellation: Option<JobCancellation>,
873    ) {
874        let command = job_command(job_class, lane);
875        let mut job = Some(job);
876        let mut completion = Some(completion);
877
878        let response = {
879            let mut state = self.inner.state.lock();
880            match state.actors.get_mut(&root_id) {
881                Some(actor) if actor.fatal => Some(actor_fatal_response(request_id.clone())),
882                Some(actor) => {
883                    actor.push_job(
884                        job_class,
885                        lane,
886                        QueuedJob {
887                            job: job.take().expect("executor job already queued"),
888                            completion: completion
889                                .take()
890                                .expect("executor completion already queued"),
891                            request_id: request_id.clone(),
892                            command,
893                            queued_at: Instant::now(),
894                            cancellation: cancellation.clone(),
895                        },
896                    );
897                    None
898                }
899                None => Some(Response::error(
900                    request_id.clone(),
901                    "actor_not_registered",
902                    "executor actor is not registered",
903                )),
904            }
905        };
906
907        if let Some(response) = response {
908            if let Some(completion) = completion {
909                completion.send(response);
910            }
911            return;
912        }
913
914        self.wake_scheduler();
915    }
916
917    pub fn pool_size(&self) -> usize {
918        self.inner.config.pool_size
919    }
920
921    pub fn actor_cap(&self) -> usize {
922        self.inner.config.actor_cap
923    }
924
925    pub fn read_cap(&self) -> usize {
926        self.inner.config.read_cap
927    }
928
929    pub fn heavy_permits(&self) -> usize {
930        self.inner.config.heavy_permits
931    }
932
933    pub fn interactive_reserve(&self) -> usize {
934        self.inner.config.interactive_reserve
935    }
936
937    pub fn maintenance_cap(&self) -> usize {
938        self.inner.config.maintenance_cap
939    }
940
941    pub fn try_dispatch_liveness_snapshot(&self) -> Option<DispatchLivenessSnapshot> {
942        Some(self.inner.dispatch_liveness.snapshot(&self.inner.config))
943    }
944
945    pub fn try_mutating_lane_snapshots(&self) -> Option<Vec<MutatingLaneSnapshot>> {
946        self.inner
947            .state
948            .try_lock()
949            .map(|state| state.mutating_lane_snapshots())
950    }
951
952    pub fn try_mutating_job_state_label(
953        &self,
954        root_id: &ProjectRootId,
955        request_id: &str,
956    ) -> Option<&'static str> {
957        self.inner
958            .state
959            .try_lock()
960            .map(|state| state.mutating_job_state_label(root_id, request_id))
961    }
962
963    /// Snapshot RouteBind blockers without waiting on scheduler state. The subc
964    /// health path uses this only for a delayed-bind breadcrumb, so contention
965    /// is reported as scheduler busy rather than delaying the transport loop.
966    pub fn try_bind_blocker_snapshot(
967        &self,
968        root_id: &ProjectRootId,
969        request_id: &str,
970    ) -> Option<BindBlockerSnapshot> {
971        self.inner
972            .state
973            .try_lock()
974            .map(|state| state.bind_blocker_snapshot(root_id, request_id))
975    }
976
977    pub fn nonrunnable_dispatch_count(&self) -> usize {
978        self.inner.nonrunnable_dispatches.load(Ordering::Acquire)
979    }
980
981    pub fn completion_counts(&self) -> (u64, u64) {
982        (
983            self.inner.completed_interactive.load(Ordering::Relaxed),
984            self.inner.completed_maintenance.load(Ordering::Relaxed),
985        )
986    }
987
988    pub fn actor_is_fatal(&self, root_id: &ProjectRootId) -> bool {
989        self.inner
990            .state
991            .lock()
992            .actors
993            .get(root_id)
994            .map(|actor| actor.fatal)
995            .unwrap_or(false)
996    }
997
998    fn wake_scheduler(&self) {
999        let _ = self.inner.event_tx.send(SchedulerEvent::Wake);
1000    }
1001}
1002
1003impl Default for Executor {
1004    fn default() -> Self {
1005        Self::new()
1006    }
1007}
1008
1009struct ExecutorInner {
1010    state: Arc<Mutex<SchedulerState>>,
1011    event_tx: Sender<SchedulerEvent>,
1012    scheduler_handle: Mutex<Option<JoinHandle<()>>>,
1013    worker_handles: Mutex<Vec<JoinHandle<()>>>,
1014    config: EffectiveConfig,
1015    nonrunnable_dispatches: Arc<AtomicUsize>,
1016    completed_interactive: Arc<AtomicU64>,
1017    completed_maintenance: Arc<AtomicU64>,
1018    dispatch_liveness: Arc<DispatchLivenessAtomics>,
1019}
1020
1021impl Drop for ExecutorInner {
1022    fn drop(&mut self) {
1023        let _ = self.event_tx.send(SchedulerEvent::Shutdown);
1024
1025        if let Some(handle) = self.scheduler_handle.lock().take() {
1026            let _ = handle.join();
1027        }
1028
1029        let mut workers = self.worker_handles.lock();
1030        for handle in workers.drain(..) {
1031            let _ = handle.join();
1032        }
1033    }
1034}
1035
1036struct SchedulerState {
1037    actors: HashMap<ProjectRootId, ActorState>,
1038    actor_order: Vec<ProjectRootId>,
1039    cursor: usize,
1040    idle_workers: usize,
1041    interactive_inflight: usize,
1042    maintenance_inflight: usize,
1043    config: EffectiveConfig,
1044    running_jobs: HashMap<(ProjectRootId, String), RunningJob>,
1045}
1046
1047impl SchedulerState {
1048    fn new(config: EffectiveConfig) -> Self {
1049        Self {
1050            actors: HashMap::new(),
1051            actor_order: Vec::new(),
1052            cursor: 0,
1053            idle_workers: config.pool_size,
1054            interactive_inflight: 0,
1055            maintenance_inflight: 0,
1056            config,
1057            running_jobs: HashMap::new(),
1058        }
1059    }
1060
1061    fn dispatch_liveness_snapshot(&self) -> DispatchLivenessSnapshot {
1062        let now = Instant::now();
1063        let mut interactive = QueueSnapshotAccumulator::default();
1064        let mut maintenance = QueueSnapshotAccumulator::default();
1065        for actor in self.actors.values() {
1066            interactive.add(actor.class_queues(JobClass::Interactive), now);
1067            maintenance.add(actor.class_queues(JobClass::Maintenance), now);
1068        }
1069
1070        DispatchLivenessSnapshot {
1071            interactive: interactive.finish(),
1072            maintenance: maintenance.finish(),
1073            running: DispatchRunningSnapshot {
1074                interactive: self.interactive_inflight,
1075                maintenance: self.maintenance_inflight,
1076            },
1077            interactive_reserve: self.config.interactive_reserve,
1078            maintenance_cap: self.config.maintenance_cap,
1079        }
1080    }
1081
1082    fn mutating_lane_snapshots(&self) -> Vec<MutatingLaneSnapshot> {
1083        let now = Instant::now();
1084        let mut snapshots: Vec<_> = self
1085            .actors
1086            .iter()
1087            .filter_map(|(root_id, actor)| {
1088                actor
1089                    .mutating_inflight
1090                    .as_ref()
1091                    .map(|job| MutatingLaneSnapshot {
1092                        root_id: root_id.clone(),
1093                        request_id: job.request_id.clone(),
1094                        command: job.command.clone(),
1095                        started_age_ms: duration_millis_u64(
1096                            now.saturating_duration_since(job.started_at),
1097                        ),
1098                    })
1099            })
1100            .collect();
1101        snapshots.sort_by(|left, right| left.root_id.as_path().cmp(right.root_id.as_path()));
1102        snapshots
1103    }
1104
1105    fn mutating_job_state_label(&self, root_id: &ProjectRootId, request_id: &str) -> &'static str {
1106        let Some(actor) = self.actors.get(root_id) else {
1107            return "actor_missing";
1108        };
1109        if actor
1110            .mutating_inflight
1111            .as_ref()
1112            .is_some_and(|job| job.request_id == request_id)
1113        {
1114            return "running";
1115        }
1116        if actor.has_queued_mutating_job(request_id) {
1117            return "queued";
1118        }
1119        if actor.writer_inflight {
1120            return "blocked_by_other_mutating";
1121        }
1122        "not_found"
1123    }
1124
1125    fn bind_blocker_snapshot(
1126        &self,
1127        root_id: &ProjectRootId,
1128        request_id: &str,
1129    ) -> BindBlockerSnapshot {
1130        let configure_state = self.mutating_job_state_label(root_id, request_id);
1131        let mut blockers = Vec::new();
1132
1133        if let Some(actor) = self.actors.get(root_id) {
1134            if configure_state == "queued" {
1135                let configure_count = actor.pending_configure_count();
1136                if configure_count > 0 {
1137                    blockers.push(format!("queued_behind_configure({configure_count})"));
1138                }
1139                if actor.read_inflight > 0 || actor.lsp_inflight {
1140                    blockers.push("waiting_on_readers".to_string());
1141                }
1142            }
1143        }
1144
1145        if configure_state == "queued" {
1146            let maintenance: Vec<_> = self
1147                .running_jobs
1148                .values()
1149                .filter(|job| job.job_class == JobClass::Maintenance)
1150                .collect();
1151            if !maintenance.is_empty() {
1152                blockers.push(format!(
1153                    "queued_behind_maintenance({})",
1154                    format_running_jobs(&maintenance)
1155                ));
1156            }
1157        }
1158
1159        if self.idle_workers == 0 {
1160            let running: Vec<_> = self.running_jobs.values().collect();
1161            blockers.push(format!(
1162                "idle_workers==0({})",
1163                format_running_jobs(&running)
1164            ));
1165        }
1166
1167        BindBlockerSnapshot {
1168            configure_state,
1169            configure_phase_timings: self
1170                .actors
1171                .get(root_id)
1172                .map(|actor| actor.ctx.configure_ack_phase_snapshot()),
1173            blockers,
1174        }
1175    }
1176}
1177
1178fn is_configure_request(request_id: &str) -> bool {
1179    request_id.starts_with("subc-bind-")
1180}
1181
1182fn format_running_jobs(jobs: &[&RunningJob]) -> String {
1183    let now = Instant::now();
1184    let mut labels: Vec<_> = jobs
1185        .iter()
1186        .map(|job| {
1187            format!(
1188                "job={} command={} lane={:?} root={} age_ms={}",
1189                job.request_id,
1190                job.command,
1191                job.lane,
1192                job.root_id.as_path().display(),
1193                duration_millis_u64(now.saturating_duration_since(job.started_at))
1194            )
1195        })
1196        .collect();
1197    labels.sort();
1198    labels.truncate(4);
1199    labels.join("; ")
1200}
1201
1202#[derive(Default)]
1203struct QueueSnapshotAccumulator {
1204    queued: usize,
1205    oldest_age_ms: Option<u64>,
1206}
1207
1208impl QueueSnapshotAccumulator {
1209    fn add(&mut self, queues: &ClassQueues, now: Instant) {
1210        self.queued += queues.queued_count();
1211        if let Some(queued_at) = queues.oldest_queued_at() {
1212            let age_ms = duration_millis_u64(now.saturating_duration_since(queued_at));
1213            self.oldest_age_ms = Some(
1214                self.oldest_age_ms
1215                    .map_or(age_ms, |oldest| oldest.max(age_ms)),
1216            );
1217        }
1218    }
1219
1220    fn finish(self) -> DispatchClassQueueSnapshot {
1221        DispatchClassQueueSnapshot {
1222            queued: self.queued,
1223            oldest_age_ms: self.oldest_age_ms,
1224        }
1225    }
1226}
1227
1228fn duration_millis_u64(duration: Duration) -> u64 {
1229    duration.as_millis().min(u128::from(u64::MAX)) as u64
1230}
1231
1232struct ActorState {
1233    ctx: Arc<AppContext>,
1234    epoch: Arc<RwLock<()>>,
1235    read_inflight: usize,
1236    lsp_inflight: bool,
1237    actor_total_inflight: usize,
1238    writer_inflight: bool,
1239    maintenance_commit_inflight: bool,
1240    mutating_inflight: Option<RunningMutatingJob>,
1241    deficit: isize,
1242    interactive: ClassQueues,
1243    maintenance: ClassQueues,
1244    fatal: bool,
1245}
1246
1247impl ActorState {
1248    fn new(ctx: Arc<AppContext>) -> Self {
1249        Self {
1250            ctx,
1251            epoch: Arc::new(RwLock::new(())),
1252            read_inflight: 0,
1253            lsp_inflight: false,
1254            actor_total_inflight: 0,
1255            writer_inflight: false,
1256            maintenance_commit_inflight: false,
1257            mutating_inflight: None,
1258            deficit: 0,
1259            interactive: ClassQueues::new(),
1260            maintenance: ClassQueues::new(),
1261            fatal: false,
1262        }
1263    }
1264
1265    fn push_job(&mut self, job_class: JobClass, lane: Lane, job: QueuedJob) {
1266        self.class_queues_mut(job_class).push_job(lane, job);
1267    }
1268
1269    fn has_queued_jobs(&self) -> bool {
1270        self.interactive.has_queued_jobs() || self.maintenance.has_queued_jobs()
1271    }
1272
1273    fn is_idle(&self) -> bool {
1274        self.actor_total_inflight == 0 && !self.has_queued_jobs()
1275    }
1276
1277    fn has_queued_jobs_for(&self, job_class: JobClass) -> bool {
1278        self.class_queues(job_class).has_queued_jobs()
1279    }
1280
1281    fn front_lane(&self, job_class: JobClass) -> Option<Lane> {
1282        self.class_queues(job_class).front_lane()
1283    }
1284
1285    fn pop_front_job(&mut self, job_class: JobClass, lane: Lane) -> Option<QueuedJob> {
1286        self.class_queues_mut(job_class).pop_front_job(lane)
1287    }
1288
1289    fn higher_priority_writer_barrier_blocks(&self, job_class: JobClass) -> bool {
1290        // Maintenance must not start while interactive mutating work (tool
1291        // mutations, route binds) waits: a maintenance job that takes the
1292        // actor's writer slot would push the interactive writer behind it.
1293        matches!(job_class, JobClass::Maintenance)
1294            && !self.interactive.queue(Lane::Mutating).is_empty()
1295    }
1296
1297    fn class_queues(&self, job_class: JobClass) -> &ClassQueues {
1298        match job_class {
1299            JobClass::Interactive => &self.interactive,
1300            JobClass::Maintenance => &self.maintenance,
1301        }
1302    }
1303
1304    fn class_queues_mut(&mut self, job_class: JobClass) -> &mut ClassQueues {
1305        match job_class {
1306            JobClass::Interactive => &mut self.interactive,
1307            JobClass::Maintenance => &mut self.maintenance,
1308        }
1309    }
1310
1311    fn fail_queued_jobs(&mut self) {
1312        self.interactive.fail_queued_jobs();
1313        self.maintenance.fail_queued_jobs();
1314    }
1315
1316    fn has_queued_mutating_job(&self, request_id: &str) -> bool {
1317        self.interactive.has_queued_mutating_job(request_id)
1318            || self.maintenance.has_queued_mutating_job(request_id)
1319    }
1320
1321    fn remove_queued_cancellable(&mut self, token: &JobCancellation) -> Option<QueuedJob> {
1322        self.interactive
1323            .remove_cancellable(token)
1324            .or_else(|| self.maintenance.remove_cancellable(token))
1325    }
1326
1327    fn pending_configure_count(&self) -> usize {
1328        usize::from(
1329            self.mutating_inflight
1330                .as_ref()
1331                .is_some_and(|job| is_configure_request(&job.request_id)),
1332        ) + self.interactive.queued_configure_count()
1333            + self.maintenance.queued_configure_count()
1334    }
1335}
1336
1337struct ClassQueues {
1338    order: VecDeque<Lane>,
1339    pure_reads: VecDeque<QueuedJob>,
1340    lsp_status: VecDeque<QueuedJob>,
1341    heavy_init: VecDeque<QueuedJob>,
1342    mutating: VecDeque<QueuedJob>,
1343    maintenance_commit: VecDeque<QueuedJob>,
1344}
1345
1346impl ClassQueues {
1347    fn new() -> Self {
1348        Self {
1349            order: VecDeque::new(),
1350            pure_reads: VecDeque::new(),
1351            lsp_status: VecDeque::new(),
1352            heavy_init: VecDeque::new(),
1353            mutating: VecDeque::new(),
1354            maintenance_commit: VecDeque::new(),
1355        }
1356    }
1357
1358    fn push_job(&mut self, lane: Lane, job: QueuedJob) {
1359        self.order.push_back(lane);
1360        self.queue_mut(lane).push_back(job);
1361    }
1362
1363    fn has_queued_jobs(&self) -> bool {
1364        !self.order.is_empty()
1365    }
1366
1367    fn front_lane(&self) -> Option<Lane> {
1368        self.order.front().copied()
1369    }
1370
1371    /// Interactive admission order: a hard-starved configure (queued RouteBind
1372    /// older than the promotion age) preempts everything so its daemon deadline
1373    /// survives; otherwise pure reads go first (they overlap each other and
1374    /// never barrier the actor), then remaining lanes in arrival order.
1375    /// Maintenance keeps strict arrival order via `front_lane`.
1376    fn next_interactive_lane(&self, now: Instant) -> Option<Lane> {
1377        let starved_writer = self.mutating.iter().any(|job| {
1378            now.saturating_duration_since(job.queued_at) >= INTERACTIVE_WRITER_PROMOTION_AGE
1379        });
1380        if starved_writer {
1381            // Also stops NEW readers from being admitted on this actor while
1382            // the promoted writer waits for in-flight readers to drain.
1383            return Some(Lane::Mutating);
1384        }
1385        if !self.pure_reads.is_empty() {
1386            return Some(Lane::PureRead);
1387        }
1388        self.order
1389            .iter()
1390            .copied()
1391            .find(|lane| *lane != Lane::PureRead)
1392    }
1393
1394    fn pop_front_job(&mut self, lane: Lane) -> Option<QueuedJob> {
1395        // Keep `order` consistent with per-lane queues when admission picks a
1396        // lane other than the arrival-order head: remove the FIRST occurrence
1397        // of the chosen lane from `order`, not necessarily the front.
1398        let position = self.order.iter().position(|queued| *queued == lane)?;
1399        self.order.remove(position);
1400        self.queue_mut(lane).pop_front()
1401    }
1402
1403    fn queued_count(&self) -> usize {
1404        self.order.len()
1405    }
1406
1407    fn oldest_queued_at(&self) -> Option<Instant> {
1408        self.front_lane()
1409            .and_then(|lane| self.queue(lane).front().map(|job| job.queued_at))
1410    }
1411
1412    fn fail_queued_jobs(&mut self) {
1413        self.order.clear();
1414        fail_queued_job_queue(&mut self.pure_reads);
1415        fail_queued_job_queue(&mut self.lsp_status);
1416        fail_queued_job_queue(&mut self.heavy_init);
1417        fail_queued_job_queue(&mut self.mutating);
1418        fail_queued_job_queue(&mut self.maintenance_commit);
1419    }
1420
1421    fn cancel_queued_jobs(&mut self) -> usize {
1422        self.order.clear();
1423        cancel_queued_job_queue(&mut self.pure_reads)
1424            + cancel_queued_job_queue(&mut self.lsp_status)
1425            + cancel_queued_job_queue(&mut self.heavy_init)
1426            + cancel_queued_job_queue(&mut self.mutating)
1427            + cancel_queued_job_queue(&mut self.maintenance_commit)
1428    }
1429
1430    fn has_queued_mutating_job(&self, request_id: &str) -> bool {
1431        self.mutating.iter().any(|job| job.request_id == request_id)
1432    }
1433
1434    /// Remove the queued job carrying this exact cancellation token.
1435    ///
1436    /// `order` holds one lane entry per push, and per-lane entries pair FIFO
1437    /// with the lane queue: the k-th occurrence of a lane in `order`
1438    /// corresponds to the k-th element of that lane's queue. Removing the
1439    /// FIRST matching order entry for a job deeper in its lane queue would
1440    /// shift the pairing and reorder the survivors, so the occurrence at the
1441    /// job's own queue position is removed instead.
1442    fn remove_cancellable(&mut self, token: &JobCancellation) -> Option<QueuedJob> {
1443        for lane in [
1444            Lane::PureRead,
1445            Lane::SerialLspStatus,
1446            Lane::HeavyInit,
1447            Lane::Mutating,
1448            Lane::MaintenanceCommit,
1449        ] {
1450            let queue = self.queue_mut(lane);
1451            let position = queue.iter().position(|queued| {
1452                queued
1453                    .cancellation
1454                    .as_ref()
1455                    .is_some_and(|candidate| candidate.same_token(token))
1456            });
1457            if let Some(position) = position {
1458                let removed = queue.remove(position);
1459                let mut occurrence = 0usize;
1460                if let Some(order_position) = self.order.iter().position(|entry| {
1461                    if *entry != lane {
1462                        return false;
1463                    }
1464                    let matched = occurrence == position;
1465                    occurrence += 1;
1466                    matched
1467                }) {
1468                    self.order.remove(order_position);
1469                }
1470                return removed;
1471            }
1472        }
1473        None
1474    }
1475
1476    fn queued_configure_count(&self) -> usize {
1477        self.mutating
1478            .iter()
1479            .filter(|job| is_configure_request(&job.request_id))
1480            .count()
1481    }
1482
1483    fn queue(&self, lane: Lane) -> &VecDeque<QueuedJob> {
1484        match lane {
1485            Lane::PureRead => &self.pure_reads,
1486            Lane::SerialLspStatus => &self.lsp_status,
1487            Lane::HeavyInit => &self.heavy_init,
1488            Lane::Mutating => &self.mutating,
1489            Lane::MaintenanceCommit => &self.maintenance_commit,
1490        }
1491    }
1492
1493    fn queue_mut(&mut self, lane: Lane) -> &mut VecDeque<QueuedJob> {
1494        match lane {
1495            Lane::PureRead => &mut self.pure_reads,
1496            Lane::SerialLspStatus => &mut self.lsp_status,
1497            Lane::HeavyInit => &mut self.heavy_init,
1498            Lane::Mutating => &mut self.mutating,
1499            Lane::MaintenanceCommit => &mut self.maintenance_commit,
1500        }
1501    }
1502}
1503
1504struct QueuedJob {
1505    job: ExecutorJob,
1506    completion: CompletionSender,
1507    request_id: String,
1508    command: String,
1509    queued_at: Instant,
1510    cancellation: Option<JobCancellation>,
1511}
1512
1513fn fail_queued_job_queue(queue: &mut VecDeque<QueuedJob>) {
1514    for queued in queue.drain(..) {
1515        queued
1516            .completion
1517            .send(actor_fatal_response(queued.request_id));
1518    }
1519}
1520
1521fn cancel_queued_job_queue(queue: &mut VecDeque<QueuedJob>) -> usize {
1522    let cancelled = queue.len();
1523    for queued in queue.drain(..) {
1524        queued.completion.send(Response::error(
1525            queued.request_id,
1526            "maintenance_cancelled",
1527            "maintenance cancelled because the actor has no bound routes",
1528        ));
1529    }
1530    cancelled
1531}
1532
1533fn job_command(job_class: JobClass, lane: Lane) -> String {
1534    format!("executor::{job_class:?}::{lane:?}")
1535}
1536
1537fn actor_fatal_response(request_id: impl Into<String>) -> Response {
1538    Response::error(
1539        request_id,
1540        "actor_fatal",
1541        "executor actor is fatal after a mutating job panic",
1542    )
1543}
1544
1545fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
1546    if let Some(message) = payload.downcast_ref::<&'static str>() {
1547        (*message).to_string()
1548    } else if let Some(message) = payload.downcast_ref::<String>() {
1549        message.clone()
1550    } else {
1551        "unknown panic payload".to_string()
1552    }
1553}
1554
1555fn panic_response(
1556    request_id: impl Into<String>,
1557    command: &str,
1558    payload: &(dyn std::any::Any + Send),
1559) -> Response {
1560    let panic_message = panic_payload_message(payload);
1561    Response::error(
1562        request_id,
1563        "actor_fatal",
1564        format!("command '{command}' panicked: {panic_message}"),
1565    )
1566}
1567
1568enum CompletionSender {
1569    Sync(Sender<Response>),
1570    Async(oneshot::Sender<Response>),
1571}
1572
1573impl CompletionSender {
1574    fn send(self, response: Response) {
1575        match self {
1576            Self::Sync(tx) => {
1577                let _ = tx.send(response);
1578            }
1579            Self::Async(tx) => {
1580                let _ = tx.send(response);
1581            }
1582        }
1583    }
1584}
1585
1586struct RunJob {
1587    root_id: ProjectRootId,
1588    job_class: JobClass,
1589    lane: Lane,
1590    ctx: Arc<AppContext>,
1591    epoch: Arc<RwLock<()>>,
1592    job: ExecutorJob,
1593    completion: Option<CompletionSender>,
1594    request_id: String,
1595    command: String,
1596    heavy_permit: Option<HeavyPermit>,
1597    cancellation: Option<JobCancellation>,
1598}
1599
1600struct CompletionEvent {
1601    root_id: ProjectRootId,
1602    request_id: String,
1603    job_class: JobClass,
1604    lane: Lane,
1605    heavy_permit: Option<HeavyPermit>,
1606    panicked: bool,
1607}
1608
1609enum SchedulerEvent {
1610    Wake,
1611    Completed(CompletionEvent),
1612    Shutdown,
1613}
1614
1615fn scheduler_loop(
1616    state: Arc<Mutex<SchedulerState>>,
1617    heavy: Arc<HeavySemaphore>,
1618    run_tx: Sender<RunJob>,
1619    event_rx: Receiver<SchedulerEvent>,
1620    nonrunnable_dispatches: Arc<AtomicUsize>,
1621    completed_interactive: Arc<AtomicU64>,
1622    completed_maintenance: Arc<AtomicU64>,
1623    dispatch_liveness: Arc<DispatchLivenessAtomics>,
1624) {
1625    while let Ok(event) = event_rx.recv() {
1626        let mut shutdown = false;
1627        {
1628            let mut state = state.lock();
1629            note_completion_event(&event, &completed_interactive, &completed_maintenance);
1630            shutdown |= process_scheduler_event(event, &mut state);
1631            while !shutdown {
1632                match event_rx.try_recv() {
1633                    Ok(event) => {
1634                        note_completion_event(
1635                            &event,
1636                            &completed_interactive,
1637                            &completed_maintenance,
1638                        );
1639                        shutdown |= process_scheduler_event(event, &mut state)
1640                    }
1641                    Err(_) => break,
1642                }
1643            }
1644
1645            if !shutdown {
1646                dispatch_runnable(&mut state, &heavy, &run_tx, &nonrunnable_dispatches);
1647            }
1648            dispatch_liveness.record(&state.dispatch_liveness_snapshot());
1649        }
1650
1651        if shutdown {
1652            break;
1653        }
1654    }
1655}
1656
1657fn note_completion_event(
1658    event: &SchedulerEvent,
1659    completed_interactive: &AtomicU64,
1660    completed_maintenance: &AtomicU64,
1661) {
1662    let SchedulerEvent::Completed(event) = event else {
1663        return;
1664    };
1665    match event.job_class {
1666        JobClass::Interactive => {
1667            completed_interactive.fetch_add(1, Ordering::Relaxed);
1668        }
1669        JobClass::Maintenance => {
1670            completed_maintenance.fetch_add(1, Ordering::Relaxed);
1671        }
1672    }
1673}
1674
1675fn process_scheduler_event(event: SchedulerEvent, state: &mut SchedulerState) -> bool {
1676    match event {
1677        SchedulerEvent::Wake => false,
1678        SchedulerEvent::Completed(event) => {
1679            complete_job(state, event);
1680            false
1681        }
1682        SchedulerEvent::Shutdown => true,
1683    }
1684}
1685
1686fn complete_job(state: &mut SchedulerState, event: CompletionEvent) {
1687    let CompletionEvent {
1688        root_id,
1689        request_id,
1690        job_class,
1691        lane,
1692        heavy_permit,
1693        panicked,
1694    } = event;
1695    state.running_jobs.remove(&(root_id.clone(), request_id));
1696
1697    match job_class {
1698        JobClass::Interactive => {
1699            state.interactive_inflight = state.interactive_inflight.saturating_sub(1);
1700        }
1701        JobClass::Maintenance => {
1702            state.maintenance_inflight = state.maintenance_inflight.saturating_sub(1);
1703        }
1704    }
1705
1706    if let Some(actor) = state.actors.get_mut(&root_id) {
1707        actor.actor_total_inflight = actor.actor_total_inflight.saturating_sub(1);
1708        match lane {
1709            Lane::PureRead => {
1710                actor.read_inflight = actor.read_inflight.saturating_sub(1);
1711            }
1712            Lane::SerialLspStatus => {
1713                actor.lsp_inflight = false;
1714            }
1715            Lane::HeavyInit => {}
1716            Lane::Mutating => {
1717                actor.writer_inflight = false;
1718                actor.mutating_inflight = None;
1719            }
1720            Lane::MaintenanceCommit => {
1721                actor.maintenance_commit_inflight = false;
1722            }
1723        }
1724
1725        if panicked && lane == Lane::Mutating {
1726            actor.fatal = true;
1727            actor.fail_queued_jobs();
1728        }
1729    }
1730
1731    drop(heavy_permit);
1732    state.idle_workers += 1;
1733}
1734
1735fn dispatch_runnable(
1736    state: &mut SchedulerState,
1737    heavy: &Arc<HeavySemaphore>,
1738    run_tx: &Sender<RunJob>,
1739    nonrunnable_dispatches: &AtomicUsize,
1740) {
1741    while state.idle_workers > 0 && !state.actor_order.is_empty() {
1742        let mut made_progress = false;
1743        let mut dispatch_failed = false;
1744
1745        made_progress |= dispatch_runnable_class(
1746            state,
1747            JobClass::Interactive,
1748            heavy,
1749            run_tx,
1750            nonrunnable_dispatches,
1751            &mut dispatch_failed,
1752        );
1753        if dispatch_failed || state.idle_workers == 0 {
1754            return;
1755        }
1756
1757        if can_dispatch_class(state, JobClass::Maintenance) {
1758            made_progress |= dispatch_runnable_class(
1759                state,
1760                JobClass::Maintenance,
1761                heavy,
1762                run_tx,
1763                nonrunnable_dispatches,
1764                &mut dispatch_failed,
1765            );
1766            if dispatch_failed {
1767                return;
1768            }
1769        }
1770
1771        if !made_progress {
1772            break;
1773        }
1774    }
1775}
1776
1777fn dispatch_runnable_class(
1778    state: &mut SchedulerState,
1779    job_class: JobClass,
1780    heavy: &Arc<HeavySemaphore>,
1781    run_tx: &Sender<RunJob>,
1782    nonrunnable_dispatches: &AtomicUsize,
1783    dispatch_failed: &mut bool,
1784) -> bool {
1785    if !can_dispatch_class(state, job_class) || state.actor_order.is_empty() {
1786        return false;
1787    }
1788
1789    let actor_count = state.actor_order.len();
1790    let mut made_progress = false;
1791
1792    for _ in 0..actor_count {
1793        if !can_dispatch_class(state, job_class) || state.actor_order.is_empty() {
1794            break;
1795        }
1796
1797        if state.cursor >= state.actor_order.len() {
1798            state.cursor = 0;
1799        }
1800        let root_id = state.actor_order[state.cursor].clone();
1801        state.cursor = (state.cursor + 1) % state.actor_order.len();
1802
1803        let run_job = {
1804            let Some(actor) = state.actors.get_mut(&root_id) else {
1805                continue;
1806            };
1807
1808            if actor.fatal {
1809                actor.fail_queued_jobs();
1810                actor.deficit = 0;
1811                continue;
1812            }
1813
1814            if !actor.has_queued_jobs() {
1815                actor.deficit = 0;
1816                continue;
1817            }
1818
1819            if !actor.has_queued_jobs_for(job_class) {
1820                continue;
1821            }
1822
1823            actor.deficit =
1824                (actor.deficit + state.config.drr_quantum).min(state.config.deficit_cap);
1825            if actor.deficit < JOB_COST {
1826                continue;
1827            }
1828
1829            try_admit_actor(&root_id, actor, job_class, &state.config, heavy)
1830        };
1831
1832        if let Some(run_job) = run_job {
1833            state.running_jobs.insert(
1834                (run_job.root_id.clone(), run_job.request_id.clone()),
1835                RunningJob {
1836                    root_id: run_job.root_id.clone(),
1837                    request_id: run_job.request_id.clone(),
1838                    command: run_job.command.clone(),
1839                    job_class: run_job.job_class,
1840                    lane: run_job.lane,
1841                    started_at: Instant::now(),
1842                },
1843            );
1844            state.idle_workers -= 1;
1845            match job_class {
1846                JobClass::Interactive => state.interactive_inflight += 1,
1847                JobClass::Maintenance => state.maintenance_inflight += 1,
1848            }
1849            made_progress = true;
1850            if run_tx.send(run_job).is_err() {
1851                nonrunnable_dispatches.fetch_add(1, Ordering::AcqRel);
1852                *dispatch_failed = true;
1853                return made_progress;
1854            }
1855        }
1856    }
1857
1858    made_progress
1859}
1860
1861fn can_dispatch_class(state: &SchedulerState, job_class: JobClass) -> bool {
1862    if state.idle_workers == 0 {
1863        return false;
1864    }
1865    match job_class {
1866        JobClass::Interactive => true,
1867        JobClass::Maintenance => {
1868            state.maintenance_inflight < state.config.maintenance_cap
1869                && state.idle_workers > state.config.interactive_reserve
1870        }
1871    }
1872}
1873
1874fn try_admit_actor(
1875    root_id: &ProjectRootId,
1876    actor: &mut ActorState,
1877    job_class: JobClass,
1878    config: &EffectiveConfig,
1879    heavy: &Arc<HeavySemaphore>,
1880) -> Option<RunJob> {
1881    let lane = match job_class {
1882        JobClass::Interactive => actor
1883            .class_queues(JobClass::Interactive)
1884            .next_interactive_lane(Instant::now())?,
1885        JobClass::Maintenance => actor.front_lane(job_class)?,
1886    };
1887    let mut heavy_permit = None;
1888
1889    if actor.writer_inflight || actor.higher_priority_writer_barrier_blocks(job_class) {
1890        return None;
1891    }
1892
1893    let has_epoch_reader =
1894        actor.read_inflight > 0 || actor.lsp_inflight || actor.maintenance_commit_inflight;
1895    let actor_has_capacity = actor.actor_total_inflight < config.actor_cap;
1896    let runnable = match lane {
1897        Lane::PureRead => actor.read_inflight < config.read_cap && actor_has_capacity,
1898        Lane::SerialLspStatus => !actor.lsp_inflight && actor_has_capacity,
1899        Lane::HeavyInit => {
1900            if !actor_has_capacity {
1901                false
1902            } else if let Some(permit) = heavy.try_acquire() {
1903                heavy_permit = Some(permit);
1904                true
1905            } else {
1906                false
1907            }
1908        }
1909        Lane::Mutating => !has_epoch_reader && actor_has_capacity,
1910        // Overlaps reads (epoch read gate); one in flight per actor so
1911        // maintenance cannot stack; a running writer blocks it like reads.
1912        Lane::MaintenanceCommit => !actor.maintenance_commit_inflight && actor_has_capacity,
1913    };
1914
1915    if !runnable {
1916        return None;
1917    }
1918
1919    let queued = actor.pop_front_job(job_class, lane)?;
1920    actor.deficit -= JOB_COST;
1921    if let Some(cancellation) = queued.cancellation.as_ref() {
1922        cancellation.mark_running();
1923    }
1924    if lane == Lane::Mutating {
1925        actor.mutating_inflight = Some(RunningMutatingJob {
1926            request_id: queued.request_id.clone(),
1927            command: queued.command.clone(),
1928            started_at: Instant::now(),
1929        });
1930    }
1931    match lane {
1932        Lane::PureRead => {
1933            actor.read_inflight += 1;
1934            actor.actor_total_inflight += 1;
1935        }
1936        Lane::SerialLspStatus => {
1937            actor.lsp_inflight = true;
1938            actor.actor_total_inflight += 1;
1939        }
1940        Lane::HeavyInit => {
1941            actor.actor_total_inflight += 1;
1942        }
1943        Lane::Mutating => {
1944            actor.writer_inflight = true;
1945            actor.actor_total_inflight += 1;
1946        }
1947        Lane::MaintenanceCommit => {
1948            actor.maintenance_commit_inflight = true;
1949            actor.actor_total_inflight += 1;
1950        }
1951    }
1952
1953    Some(RunJob {
1954        root_id: root_id.clone(),
1955        job_class,
1956        lane,
1957        ctx: Arc::clone(&actor.ctx),
1958        epoch: Arc::clone(&actor.epoch),
1959        job: queued.job,
1960        completion: Some(queued.completion),
1961        request_id: queued.request_id,
1962        command: queued.command,
1963        heavy_permit,
1964        cancellation: queued.cancellation,
1965    })
1966}
1967
1968fn worker_loop(run_rx: Receiver<RunJob>, event_tx: Sender<SchedulerEvent>) {
1969    while let Ok(mut run_job) = run_rx.recv() {
1970        let response =
1971            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_lane_job(&mut run_job)));
1972        let panicked = response.is_err();
1973        let response = match response {
1974            Ok(response) => response,
1975            Err(payload) => panic_response(
1976                run_job.request_id.clone(),
1977                &run_job.command,
1978                payload.as_ref(),
1979            ),
1980        };
1981
1982        if let Some(completion) = run_job.completion.take() {
1983            completion.send(response);
1984        }
1985        let completion = CompletionEvent {
1986            root_id: run_job.root_id,
1987            request_id: run_job.request_id,
1988            job_class: run_job.job_class,
1989            lane: run_job.lane,
1990            heavy_permit: run_job.heavy_permit.take(),
1991            panicked,
1992        };
1993        let _ = event_tx.send(SchedulerEvent::Completed(completion));
1994    }
1995}
1996
1997fn run_lane_job(run_job: &mut RunJob) -> Response {
1998    let _cancellation_ctx = CurrentJobCancellationGuard::install(run_job.cancellation.clone());
1999    let missing_request_id = run_job.request_id.clone();
2000    let job = std::mem::replace(
2001        &mut run_job.job,
2002        Box::new(move |_| {
2003            Response::error(
2004                missing_request_id,
2005                "job_missing",
2006                "executor job already taken",
2007            )
2008        }),
2009    );
2010
2011    match run_job.lane {
2012        Lane::PureRead | Lane::SerialLspStatus => {
2013            let _epoch = run_job.epoch.read();
2014            job(&run_job.ctx)
2015        }
2016        Lane::HeavyInit => {
2017            let response = job(&run_job.ctx);
2018            {
2019                let _install = run_job.epoch.write();
2020            }
2021            response
2022        }
2023        Lane::Mutating => {
2024            let _epoch = run_job.epoch.write();
2025            job(&run_job.ctx)
2026        }
2027        Lane::MaintenanceCommit => {
2028            // Same gate as reads: the job's mutations are protected by the
2029            // touched subsystems' own locks, and holding only the read gate
2030            // lets interactive PureReads overlap freely.
2031            let _epoch = run_job.epoch.read();
2032            job(&run_job.ctx)
2033        }
2034    }
2035}
2036
2037#[derive(Debug)]
2038struct HeavySemaphore {
2039    available: AtomicUsize,
2040    max: usize,
2041}
2042
2043impl HeavySemaphore {
2044    fn new(permits: usize) -> Self {
2045        Self {
2046            available: AtomicUsize::new(permits),
2047            max: permits,
2048        }
2049    }
2050
2051    fn try_acquire(self: &Arc<Self>) -> Option<HeavyPermit> {
2052        loop {
2053            let available = self.available.load(Ordering::Acquire);
2054            if available == 0 {
2055                return None;
2056            }
2057            if self
2058                .available
2059                .compare_exchange(
2060                    available,
2061                    available - 1,
2062                    Ordering::AcqRel,
2063                    Ordering::Acquire,
2064                )
2065                .is_ok()
2066            {
2067                return Some(HeavyPermit {
2068                    semaphore: Arc::clone(self),
2069                });
2070            }
2071        }
2072    }
2073}
2074
2075struct HeavyPermit {
2076    semaphore: Arc<HeavySemaphore>,
2077}
2078
2079impl Drop for HeavyPermit {
2080    fn drop(&mut self) {
2081        let previous = self.semaphore.available.fetch_add(1, Ordering::Release);
2082        debug_assert!(previous < self.semaphore.max);
2083    }
2084}