Skip to main content

aft/executor/
mod.rs

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