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