Skip to main content

aft/executor/
mod.rs

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