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