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            let app = ctx.app();
462            crate::root_cache::register_live_scope(&ctx.storage_dir(), &memory_root);
463            app.register_memory_context(memory_root, &ctx);
464            app.actor_root_registered();
465        }
466        self.wake_scheduler();
467        inserted
468    }
469
470    /// Remove an actor from scheduler state.
471    ///
472    /// This is intentionally minimal: subc uses it only for a just-created
473    /// RouteBind actor whose configure failed before any route was installed, so
474    /// there is no in-flight work to quiesce. The removed [`AppContext`] is
475    /// dropped after releasing the scheduler lock so watcher/LSP teardown never
476    /// runs under that mutex.
477    pub fn remove_actor(&self, root_id: &ProjectRootId) {
478        let removed = {
479            let mut state = self.inner.state.lock();
480            state.actor_order.retain(|actor_root| actor_root != root_id);
481            state.actors.remove(root_id)
482        };
483        if let Some(actor) = removed.as_ref() {
484            let app = actor.ctx.app();
485            crate::root_cache::unregister_live_scope(&actor.ctx.storage_dir(), root_id.as_path());
486            app.unregister_memory_context(root_id.as_path(), &actor.ctx);
487            app.actor_root_unregistered();
488        }
489        drop(removed);
490        self.wake_scheduler();
491    }
492
493    /// Return true only when the actor has no queued or running executor work.
494    pub fn actor_is_idle(&self, root_id: &ProjectRootId) -> bool {
495        let state = self.inner.state.lock();
496        state.actors.get(root_id).is_some_and(ActorState::is_idle)
497    }
498
499    /// Non-blocking idle probe for maintenance sweeps. A contended scheduler is
500    /// reported separately so root retirement can conservatively wait for the
501    /// next sweep without stalling the module loop.
502    pub fn try_actor_is_idle(&self, root_id: &ProjectRootId) -> Option<bool> {
503        let state = self.inner.state.try_lock()?;
504        Some(state.actors.get(root_id).is_some_and(ActorState::is_idle))
505    }
506
507    /// Forget an idle actor and drop its root-scoped registries off the scheduler
508    /// thread. This is reserved for roots whose project directory no longer
509    /// exists; retained existing roots are reused on a later bind.
510    pub fn retire_idle_actor_in_background(&self, root_id: &ProjectRootId) -> bool {
511        let removed = {
512            let mut state = self.inner.state.lock();
513            if !state.actors.get(root_id).is_some_and(ActorState::is_idle) {
514                return false;
515            }
516            state.actor_order.retain(|actor_root| actor_root != root_id);
517            state.actors.remove(root_id)
518        };
519        let Some(actor) = removed else {
520            return false;
521        };
522        let app = actor.ctx.app();
523        crate::root_cache::unregister_live_scope(&actor.ctx.storage_dir(), root_id.as_path());
524        app.unregister_memory_context(root_id.as_path(), &actor.ctx);
525        app.actor_root_unregistered();
526        std::thread::spawn(move || {
527            actor.ctx.teardown_deleted_root();
528            drop(actor);
529        });
530        self.wake_scheduler();
531        true
532    }
533
534    /// Cancel maintenance jobs that have not started for one retained actor.
535    ///
536    /// Interactive work and already-running maintenance remain untouched. Each
537    /// cancelled job receives a normal completion so its caller can settle
538    /// bookkeeping through the same path as an executed job.
539    pub fn cancel_queued_maintenance(&self, root_id: &ProjectRootId) -> usize {
540        let cancelled = {
541            let mut state = self.inner.state.lock();
542            state
543                .actors
544                .get_mut(root_id)
545                .map(|actor| actor.maintenance.cancel_queued_jobs())
546                .unwrap_or(0)
547        };
548        if cancelled > 0 {
549            self.wake_scheduler();
550        }
551        cancelled
552    }
553
554    /// Return whether scheduler state currently has an actor for this root.
555    pub fn actor_registered(&self, root_id: &ProjectRootId) -> bool {
556        let state = self.inner.state.lock();
557        state.actors.contains_key(root_id)
558    }
559
560    /// Snapshot one actor context without retaining the scheduler lock while
561    /// maintenance drops root-scoped resources.
562    pub fn actor_context(&self, root_id: &ProjectRootId) -> Option<Arc<AppContext>> {
563        let state = self.inner.state.lock();
564        state
565            .actors
566            .get(root_id)
567            .map(|actor| Arc::clone(&actor.ctx))
568    }
569
570    /// Snapshot the registered actor contexts.
571    ///
572    /// The returned [`Arc`]s keep contexts alive after the scheduler lock is
573    /// released, so callers can run teardown without holding executor state.
574    pub fn actor_contexts(&self) -> Vec<Arc<AppContext>> {
575        let state = self.inner.state.lock();
576        state
577            .actors
578            .values()
579            .map(|actor_state| Arc::clone(&actor_state.ctx))
580            .collect()
581    }
582
583    /// Snapshot the registered root ids paired with their actor contexts.
584    pub fn actor_entries(&self) -> Vec<(ProjectRootId, Arc<AppContext>)> {
585        let state = self.inner.state.lock();
586        state
587            .actors
588            .iter()
589            .map(|(root_id, actor_state)| (root_id.clone(), Arc::clone(&actor_state.ctx)))
590            .collect()
591    }
592
593    /// Non-blocking variant for the health path: the probe reply must stay
594    /// cheap under any load, so it skips the actor list (reported as busy)
595    /// rather than waiting on the scheduler state lock.
596    pub fn try_actor_entries(&self) -> Option<Vec<(ProjectRootId, Arc<AppContext>)>> {
597        let state = self.inner.state.try_lock()?;
598        Some(
599            state
600                .actors
601                .iter()
602                .map(|(root_id, actor_state)| (root_id.clone(), Arc::clone(&actor_state.ctx)))
603                .collect(),
604        )
605    }
606
607    pub fn submit(
608        &self,
609        root_id: ProjectRootId,
610        lane: Lane,
611        request_id: String,
612        job: ExecutorJob,
613    ) -> CompletionHandle {
614        let (completion_tx, completion_rx) = crossbeam_channel::bounded(1);
615        self.submit_with_completion(
616            root_id,
617            JobClass::Interactive,
618            lane,
619            request_id,
620            job,
621            CompletionSender::Sync(completion_tx),
622        );
623        CompletionHandle { rx: completion_rx }
624    }
625
626    pub fn submit_async(
627        &self,
628        root_id: ProjectRootId,
629        lane: Lane,
630        request_id: String,
631        job: ExecutorJob,
632    ) -> oneshot::Receiver<Response> {
633        let (completion_tx, completion_rx) = oneshot::channel();
634        self.submit_with_completion(
635            root_id,
636            JobClass::Interactive,
637            lane,
638            request_id,
639            job,
640            CompletionSender::Async(completion_tx),
641        );
642        completion_rx
643    }
644
645    /// Submit an interactive job with an exact-job cancellation token.
646    ///
647    /// The returned token cancels THIS job only (queued: removed and settled
648    /// with `request_cancelled`; running: signalled cooperatively). The job
649    /// observes the token via [`current_job_cancellation`].
650    pub fn submit_cancellable_async(
651        &self,
652        root_id: ProjectRootId,
653        lane: Lane,
654        request_id: String,
655        job: ExecutorJob,
656    ) -> (oneshot::Receiver<Response>, JobCancellation) {
657        let cancellation = JobCancellation::new();
658        let (completion_tx, completion_rx) = oneshot::channel();
659        self.submit_with_completion_cancellable(
660            root_id,
661            JobClass::Interactive,
662            lane,
663            request_id,
664            job,
665            CompletionSender::Async(completion_tx),
666            Some(cancellation.clone()),
667        );
668        (completion_rx, cancellation)
669    }
670
671    /// Cancel one exact job by its token.
672    ///
673    /// Queued jobs are removed and settled immediately; running jobs are
674    /// signalled and return through their normal completion path at the next
675    /// cooperative checkpoint. Jobs that sealed their commit finish normally.
676    pub fn cancel_job(&self, root_id: &ProjectRootId, token: &JobCancellation) -> JobCancelOutcome {
677        // Signal BEFORE actor lookup, deliberately: a job whose actor was
678        // already torn down (fatal teardown, root removal) must still abort at
679        // its next checkpoint, so the token is cancelled regardless. The
680        // outcome must then reflect what the signal actually did — reporting
681        // NotFound for a token that was RUNNING at signal time would mislead
682        // the caller into treating a signalled job as nonexistent.
683        let observed = token.signal_cancel();
684        let (outcome, settled) = {
685            let mut state = self.inner.state.lock();
686            let Some(actor) = state.actors.get_mut(root_id) else {
687                return match observed {
688                    JOB_CANCEL_STATE_COMMITTED => JobCancelOutcome::RunningCommitted,
689                    JOB_CANCEL_STATE_RUNNING | JOB_CANCEL_STATE_PENDING => {
690                        JobCancelOutcome::RunningSignalled
691                    }
692                    _ => JobCancelOutcome::NotFound,
693                };
694            };
695            match actor.remove_queued_cancellable(token) {
696                Some(queued) => (JobCancelOutcome::QueuedRemoved, Some(queued)),
697                None => match observed {
698                    // The seal won the race: the job commits and finishes.
699                    JOB_CANCEL_STATE_COMMITTED => (JobCancelOutcome::RunningCommitted, None),
700                    // RUNNING at signal time, or PENDING at signal time but
701                    // dispatched before we took the scheduler lock: either way
702                    // the job aborts at its next checkpoint.
703                    JOB_CANCEL_STATE_RUNNING | JOB_CANCEL_STATE_PENDING => {
704                        (JobCancelOutcome::RunningSignalled, None)
705                    }
706                    // Already cancelled by an earlier call and no longer queued.
707                    _ => (JobCancelOutcome::NotFound, None),
708                },
709            }
710        };
711        if let Some(queued) = settled {
712            queued.completion.send(Response::error(
713                queued.request_id,
714                "request_cancelled",
715                "request cancelled before execution",
716            ));
717            self.wake_scheduler();
718        }
719        outcome
720    }
721
722    pub fn submit_maintenance_async(
723        &self,
724        root_id: ProjectRootId,
725        lane: Lane,
726        request_id: String,
727        job: ExecutorJob,
728    ) -> oneshot::Receiver<Response> {
729        let (completion_tx, completion_rx) = oneshot::channel();
730        self.submit_with_completion(
731            root_id,
732            JobClass::Maintenance,
733            lane,
734            request_id,
735            job,
736            CompletionSender::Async(completion_tx),
737        );
738        completion_rx
739    }
740
741    fn submit_with_completion(
742        &self,
743        root_id: ProjectRootId,
744        job_class: JobClass,
745        lane: Lane,
746        request_id: String,
747        job: ExecutorJob,
748        completion: CompletionSender,
749    ) {
750        self.submit_with_completion_cancellable(
751            root_id, job_class, lane, request_id, job, completion, None,
752        );
753    }
754
755    #[allow(clippy::too_many_arguments)]
756    fn submit_with_completion_cancellable(
757        &self,
758        root_id: ProjectRootId,
759        job_class: JobClass,
760        lane: Lane,
761        request_id: String,
762        job: ExecutorJob,
763        completion: CompletionSender,
764        cancellation: Option<JobCancellation>,
765    ) {
766        let command = job_command(job_class, lane);
767        let mut job = Some(job);
768        let mut completion = Some(completion);
769
770        let response = {
771            let mut state = self.inner.state.lock();
772            match state.actors.get_mut(&root_id) {
773                Some(actor) if actor.fatal => Some(actor_fatal_response(request_id.clone())),
774                Some(actor) => {
775                    actor.push_job(
776                        job_class,
777                        lane,
778                        QueuedJob {
779                            job: job.take().expect("executor job already queued"),
780                            completion: completion
781                                .take()
782                                .expect("executor completion already queued"),
783                            request_id: request_id.clone(),
784                            command,
785                            queued_at: Instant::now(),
786                            cancellation: cancellation.clone(),
787                        },
788                    );
789                    None
790                }
791                None => Some(Response::error(
792                    request_id.clone(),
793                    "actor_not_registered",
794                    "executor actor is not registered",
795                )),
796            }
797        };
798
799        if let Some(response) = response {
800            if let Some(completion) = completion {
801                completion.send(response);
802            }
803            return;
804        }
805
806        self.wake_scheduler();
807    }
808
809    pub fn pool_size(&self) -> usize {
810        self.inner.config.pool_size
811    }
812
813    pub fn actor_cap(&self) -> usize {
814        self.inner.config.actor_cap
815    }
816
817    pub fn read_cap(&self) -> usize {
818        self.inner.config.read_cap
819    }
820
821    pub fn heavy_permits(&self) -> usize {
822        self.inner.config.heavy_permits
823    }
824
825    pub fn interactive_reserve(&self) -> usize {
826        self.inner.config.interactive_reserve
827    }
828
829    pub fn maintenance_cap(&self) -> usize {
830        self.inner.config.maintenance_cap
831    }
832
833    pub fn try_dispatch_liveness_snapshot(&self) -> Option<DispatchLivenessSnapshot> {
834        self.inner
835            .state
836            .try_lock()
837            .map(|state| state.dispatch_liveness_snapshot())
838    }
839
840    pub fn try_mutating_lane_snapshots(&self) -> Option<Vec<MutatingLaneSnapshot>> {
841        self.inner
842            .state
843            .try_lock()
844            .map(|state| state.mutating_lane_snapshots())
845    }
846
847    pub fn try_mutating_job_state_label(
848        &self,
849        root_id: &ProjectRootId,
850        request_id: &str,
851    ) -> Option<&'static str> {
852        self.inner
853            .state
854            .try_lock()
855            .map(|state| state.mutating_job_state_label(root_id, request_id))
856    }
857
858    /// Snapshot RouteBind blockers without waiting on scheduler state. The subc
859    /// health path uses this only for a delayed-bind breadcrumb, so contention
860    /// is reported as scheduler busy rather than delaying the transport loop.
861    pub fn try_bind_blocker_snapshot(
862        &self,
863        root_id: &ProjectRootId,
864        request_id: &str,
865    ) -> Option<BindBlockerSnapshot> {
866        self.inner
867            .state
868            .try_lock()
869            .map(|state| state.bind_blocker_snapshot(root_id, request_id))
870    }
871
872    pub fn nonrunnable_dispatch_count(&self) -> usize {
873        self.inner.nonrunnable_dispatches.load(Ordering::Acquire)
874    }
875
876    pub fn completion_counts(&self) -> (u64, u64) {
877        (
878            self.inner.completed_interactive.load(Ordering::Relaxed),
879            self.inner.completed_maintenance.load(Ordering::Relaxed),
880        )
881    }
882
883    pub fn actor_is_fatal(&self, root_id: &ProjectRootId) -> bool {
884        self.inner
885            .state
886            .lock()
887            .actors
888            .get(root_id)
889            .map(|actor| actor.fatal)
890            .unwrap_or(false)
891    }
892
893    fn wake_scheduler(&self) {
894        let _ = self.inner.event_tx.send(SchedulerEvent::Wake);
895    }
896}
897
898impl Default for Executor {
899    fn default() -> Self {
900        Self::new()
901    }
902}
903
904struct ExecutorInner {
905    state: Arc<Mutex<SchedulerState>>,
906    event_tx: Sender<SchedulerEvent>,
907    scheduler_handle: Mutex<Option<JoinHandle<()>>>,
908    worker_handles: Mutex<Vec<JoinHandle<()>>>,
909    config: EffectiveConfig,
910    nonrunnable_dispatches: Arc<AtomicUsize>,
911    completed_interactive: Arc<AtomicU64>,
912    completed_maintenance: Arc<AtomicU64>,
913}
914
915impl Drop for ExecutorInner {
916    fn drop(&mut self) {
917        let _ = self.event_tx.send(SchedulerEvent::Shutdown);
918
919        if let Some(handle) = self.scheduler_handle.lock().take() {
920            let _ = handle.join();
921        }
922
923        let mut workers = self.worker_handles.lock();
924        for handle in workers.drain(..) {
925            let _ = handle.join();
926        }
927    }
928}
929
930struct SchedulerState {
931    actors: HashMap<ProjectRootId, ActorState>,
932    actor_order: Vec<ProjectRootId>,
933    cursor: usize,
934    idle_workers: usize,
935    interactive_inflight: usize,
936    maintenance_inflight: usize,
937    config: EffectiveConfig,
938    running_jobs: HashMap<(ProjectRootId, String), RunningJob>,
939}
940
941impl SchedulerState {
942    fn new(config: EffectiveConfig) -> Self {
943        Self {
944            actors: HashMap::new(),
945            actor_order: Vec::new(),
946            cursor: 0,
947            idle_workers: config.pool_size,
948            interactive_inflight: 0,
949            maintenance_inflight: 0,
950            config,
951            running_jobs: HashMap::new(),
952        }
953    }
954
955    fn dispatch_liveness_snapshot(&self) -> DispatchLivenessSnapshot {
956        let now = Instant::now();
957        let mut interactive = QueueSnapshotAccumulator::default();
958        let mut maintenance = QueueSnapshotAccumulator::default();
959        for actor in self.actors.values() {
960            interactive.add(actor.class_queues(JobClass::Interactive), now);
961            maintenance.add(actor.class_queues(JobClass::Maintenance), now);
962        }
963
964        DispatchLivenessSnapshot {
965            interactive: interactive.finish(),
966            maintenance: maintenance.finish(),
967            running: DispatchRunningSnapshot {
968                interactive: self.interactive_inflight,
969                maintenance: self.maintenance_inflight,
970            },
971            interactive_reserve: self.config.interactive_reserve,
972            maintenance_cap: self.config.maintenance_cap,
973        }
974    }
975
976    fn mutating_lane_snapshots(&self) -> Vec<MutatingLaneSnapshot> {
977        let now = Instant::now();
978        let mut snapshots: Vec<_> = self
979            .actors
980            .iter()
981            .filter_map(|(root_id, actor)| {
982                actor
983                    .mutating_inflight
984                    .as_ref()
985                    .map(|job| MutatingLaneSnapshot {
986                        root_id: root_id.clone(),
987                        request_id: job.request_id.clone(),
988                        command: job.command.clone(),
989                        started_age_ms: duration_millis_u64(
990                            now.saturating_duration_since(job.started_at),
991                        ),
992                    })
993            })
994            .collect();
995        snapshots.sort_by(|left, right| left.root_id.as_path().cmp(right.root_id.as_path()));
996        snapshots
997    }
998
999    fn mutating_job_state_label(&self, root_id: &ProjectRootId, request_id: &str) -> &'static str {
1000        let Some(actor) = self.actors.get(root_id) else {
1001            return "actor_missing";
1002        };
1003        if actor
1004            .mutating_inflight
1005            .as_ref()
1006            .is_some_and(|job| job.request_id == request_id)
1007        {
1008            return "running";
1009        }
1010        if actor.has_queued_mutating_job(request_id) {
1011            return "queued";
1012        }
1013        if actor.writer_inflight {
1014            return "blocked_by_other_mutating";
1015        }
1016        "not_found"
1017    }
1018
1019    fn bind_blocker_snapshot(
1020        &self,
1021        root_id: &ProjectRootId,
1022        request_id: &str,
1023    ) -> BindBlockerSnapshot {
1024        let configure_state = self.mutating_job_state_label(root_id, request_id);
1025        let mut blockers = Vec::new();
1026
1027        if let Some(actor) = self.actors.get(root_id) {
1028            if configure_state == "queued" {
1029                let configure_count = actor.pending_configure_count();
1030                if configure_count > 0 {
1031                    blockers.push(format!("queued_behind_configure({configure_count})"));
1032                }
1033                if actor.read_inflight > 0 || actor.lsp_inflight {
1034                    blockers.push("waiting_on_readers".to_string());
1035                }
1036            }
1037        }
1038
1039        if configure_state == "queued" {
1040            let maintenance: Vec<_> = self
1041                .running_jobs
1042                .values()
1043                .filter(|job| job.job_class == JobClass::Maintenance)
1044                .collect();
1045            if !maintenance.is_empty() {
1046                blockers.push(format!(
1047                    "queued_behind_maintenance({})",
1048                    format_running_jobs(&maintenance)
1049                ));
1050            }
1051        }
1052
1053        if self.idle_workers == 0 {
1054            let running: Vec<_> = self.running_jobs.values().collect();
1055            blockers.push(format!(
1056                "idle_workers==0({})",
1057                format_running_jobs(&running)
1058            ));
1059        }
1060
1061        BindBlockerSnapshot {
1062            configure_state,
1063            configure_phase_timings: self
1064                .actors
1065                .get(root_id)
1066                .map(|actor| actor.ctx.configure_ack_phase_snapshot()),
1067            blockers,
1068        }
1069    }
1070}
1071
1072fn is_configure_request(request_id: &str) -> bool {
1073    request_id.starts_with("subc-bind-")
1074}
1075
1076fn format_running_jobs(jobs: &[&RunningJob]) -> String {
1077    let now = Instant::now();
1078    let mut labels: Vec<_> = jobs
1079        .iter()
1080        .map(|job| {
1081            format!(
1082                "job={} command={} lane={:?} root={} age_ms={}",
1083                job.request_id,
1084                job.command,
1085                job.lane,
1086                job.root_id.as_path().display(),
1087                duration_millis_u64(now.saturating_duration_since(job.started_at))
1088            )
1089        })
1090        .collect();
1091    labels.sort();
1092    labels.truncate(4);
1093    labels.join("; ")
1094}
1095
1096#[derive(Default)]
1097struct QueueSnapshotAccumulator {
1098    queued: usize,
1099    oldest_age_ms: Option<u64>,
1100}
1101
1102impl QueueSnapshotAccumulator {
1103    fn add(&mut self, queues: &ClassQueues, now: Instant) {
1104        self.queued += queues.queued_count();
1105        if let Some(queued_at) = queues.oldest_queued_at() {
1106            let age_ms = duration_millis_u64(now.saturating_duration_since(queued_at));
1107            self.oldest_age_ms = Some(
1108                self.oldest_age_ms
1109                    .map_or(age_ms, |oldest| oldest.max(age_ms)),
1110            );
1111        }
1112    }
1113
1114    fn finish(self) -> DispatchClassQueueSnapshot {
1115        DispatchClassQueueSnapshot {
1116            queued: self.queued,
1117            oldest_age_ms: self.oldest_age_ms,
1118        }
1119    }
1120}
1121
1122fn duration_millis_u64(duration: Duration) -> u64 {
1123    duration.as_millis().min(u128::from(u64::MAX)) as u64
1124}
1125
1126struct ActorState {
1127    ctx: Arc<AppContext>,
1128    epoch: Arc<RwLock<()>>,
1129    read_inflight: usize,
1130    lsp_inflight: bool,
1131    actor_total_inflight: usize,
1132    writer_inflight: bool,
1133    maintenance_commit_inflight: bool,
1134    mutating_inflight: Option<RunningMutatingJob>,
1135    deficit: isize,
1136    interactive: ClassQueues,
1137    maintenance: ClassQueues,
1138    fatal: bool,
1139}
1140
1141impl ActorState {
1142    fn new(ctx: Arc<AppContext>) -> Self {
1143        Self {
1144            ctx,
1145            epoch: Arc::new(RwLock::new(())),
1146            read_inflight: 0,
1147            lsp_inflight: false,
1148            actor_total_inflight: 0,
1149            writer_inflight: false,
1150            maintenance_commit_inflight: false,
1151            mutating_inflight: None,
1152            deficit: 0,
1153            interactive: ClassQueues::new(),
1154            maintenance: ClassQueues::new(),
1155            fatal: false,
1156        }
1157    }
1158
1159    fn push_job(&mut self, job_class: JobClass, lane: Lane, job: QueuedJob) {
1160        self.class_queues_mut(job_class).push_job(lane, job);
1161    }
1162
1163    fn has_queued_jobs(&self) -> bool {
1164        self.interactive.has_queued_jobs() || self.maintenance.has_queued_jobs()
1165    }
1166
1167    fn is_idle(&self) -> bool {
1168        self.actor_total_inflight == 0 && !self.has_queued_jobs()
1169    }
1170
1171    fn has_queued_jobs_for(&self, job_class: JobClass) -> bool {
1172        self.class_queues(job_class).has_queued_jobs()
1173    }
1174
1175    fn front_lane(&self, job_class: JobClass) -> Option<Lane> {
1176        self.class_queues(job_class).front_lane()
1177    }
1178
1179    fn pop_front_job(&mut self, job_class: JobClass, lane: Lane) -> Option<QueuedJob> {
1180        self.class_queues_mut(job_class).pop_front_job(lane)
1181    }
1182
1183    fn higher_priority_writer_barrier_blocks(&self, job_class: JobClass) -> bool {
1184        // Maintenance must not start while interactive mutating work (tool
1185        // mutations, route binds) waits: a maintenance job that takes the
1186        // actor's writer slot would push the interactive writer behind it.
1187        matches!(job_class, JobClass::Maintenance)
1188            && !self.interactive.queue(Lane::Mutating).is_empty()
1189    }
1190
1191    fn class_queues(&self, job_class: JobClass) -> &ClassQueues {
1192        match job_class {
1193            JobClass::Interactive => &self.interactive,
1194            JobClass::Maintenance => &self.maintenance,
1195        }
1196    }
1197
1198    fn class_queues_mut(&mut self, job_class: JobClass) -> &mut ClassQueues {
1199        match job_class {
1200            JobClass::Interactive => &mut self.interactive,
1201            JobClass::Maintenance => &mut self.maintenance,
1202        }
1203    }
1204
1205    fn fail_queued_jobs(&mut self) {
1206        self.interactive.fail_queued_jobs();
1207        self.maintenance.fail_queued_jobs();
1208    }
1209
1210    fn has_queued_mutating_job(&self, request_id: &str) -> bool {
1211        self.interactive.has_queued_mutating_job(request_id)
1212            || self.maintenance.has_queued_mutating_job(request_id)
1213    }
1214
1215    fn remove_queued_cancellable(&mut self, token: &JobCancellation) -> Option<QueuedJob> {
1216        self.interactive
1217            .remove_cancellable(token)
1218            .or_else(|| self.maintenance.remove_cancellable(token))
1219    }
1220
1221    fn pending_configure_count(&self) -> usize {
1222        usize::from(
1223            self.mutating_inflight
1224                .as_ref()
1225                .is_some_and(|job| is_configure_request(&job.request_id)),
1226        ) + self.interactive.queued_configure_count()
1227            + self.maintenance.queued_configure_count()
1228    }
1229}
1230
1231struct ClassQueues {
1232    order: VecDeque<Lane>,
1233    pure_reads: VecDeque<QueuedJob>,
1234    lsp_status: VecDeque<QueuedJob>,
1235    heavy_init: VecDeque<QueuedJob>,
1236    mutating: VecDeque<QueuedJob>,
1237    maintenance_commit: VecDeque<QueuedJob>,
1238}
1239
1240impl ClassQueues {
1241    fn new() -> Self {
1242        Self {
1243            order: VecDeque::new(),
1244            pure_reads: VecDeque::new(),
1245            lsp_status: VecDeque::new(),
1246            heavy_init: VecDeque::new(),
1247            mutating: VecDeque::new(),
1248            maintenance_commit: VecDeque::new(),
1249        }
1250    }
1251
1252    fn push_job(&mut self, lane: Lane, job: QueuedJob) {
1253        self.order.push_back(lane);
1254        self.queue_mut(lane).push_back(job);
1255    }
1256
1257    fn has_queued_jobs(&self) -> bool {
1258        !self.order.is_empty()
1259    }
1260
1261    fn front_lane(&self) -> Option<Lane> {
1262        self.order.front().copied()
1263    }
1264
1265    /// Interactive admission order: a hard-starved configure (queued RouteBind
1266    /// older than the promotion age) preempts everything so its daemon deadline
1267    /// survives; otherwise pure reads go first (they overlap each other and
1268    /// never barrier the actor), then remaining lanes in arrival order.
1269    /// Maintenance keeps strict arrival order via `front_lane`.
1270    fn next_interactive_lane(&self, now: Instant) -> Option<Lane> {
1271        let starved_writer = self.mutating.iter().any(|job| {
1272            now.saturating_duration_since(job.queued_at) >= INTERACTIVE_WRITER_PROMOTION_AGE
1273        });
1274        if starved_writer {
1275            // Also stops NEW readers from being admitted on this actor while
1276            // the promoted writer waits for in-flight readers to drain.
1277            return Some(Lane::Mutating);
1278        }
1279        if !self.pure_reads.is_empty() {
1280            return Some(Lane::PureRead);
1281        }
1282        self.order
1283            .iter()
1284            .copied()
1285            .find(|lane| *lane != Lane::PureRead)
1286    }
1287
1288    fn pop_front_job(&mut self, lane: Lane) -> Option<QueuedJob> {
1289        // Keep `order` consistent with per-lane queues when admission picks a
1290        // lane other than the arrival-order head: remove the FIRST occurrence
1291        // of the chosen lane from `order`, not necessarily the front.
1292        let position = self.order.iter().position(|queued| *queued == lane)?;
1293        self.order.remove(position);
1294        self.queue_mut(lane).pop_front()
1295    }
1296
1297    fn queued_count(&self) -> usize {
1298        self.order.len()
1299    }
1300
1301    fn oldest_queued_at(&self) -> Option<Instant> {
1302        self.front_lane()
1303            .and_then(|lane| self.queue(lane).front().map(|job| job.queued_at))
1304    }
1305
1306    fn fail_queued_jobs(&mut self) {
1307        self.order.clear();
1308        fail_queued_job_queue(&mut self.pure_reads);
1309        fail_queued_job_queue(&mut self.lsp_status);
1310        fail_queued_job_queue(&mut self.heavy_init);
1311        fail_queued_job_queue(&mut self.mutating);
1312        fail_queued_job_queue(&mut self.maintenance_commit);
1313    }
1314
1315    fn cancel_queued_jobs(&mut self) -> usize {
1316        self.order.clear();
1317        cancel_queued_job_queue(&mut self.pure_reads)
1318            + cancel_queued_job_queue(&mut self.lsp_status)
1319            + cancel_queued_job_queue(&mut self.heavy_init)
1320            + cancel_queued_job_queue(&mut self.mutating)
1321            + cancel_queued_job_queue(&mut self.maintenance_commit)
1322    }
1323
1324    fn has_queued_mutating_job(&self, request_id: &str) -> bool {
1325        self.mutating.iter().any(|job| job.request_id == request_id)
1326    }
1327
1328    /// Remove the queued job carrying this exact cancellation token.
1329    ///
1330    /// `order` holds one lane entry per push, and per-lane entries pair FIFO
1331    /// with the lane queue: the k-th occurrence of a lane in `order`
1332    /// corresponds to the k-th element of that lane's queue. Removing the
1333    /// FIRST matching order entry for a job deeper in its lane queue would
1334    /// shift the pairing and reorder the survivors, so the occurrence at the
1335    /// job's own queue position is removed instead.
1336    fn remove_cancellable(&mut self, token: &JobCancellation) -> Option<QueuedJob> {
1337        for lane in [
1338            Lane::PureRead,
1339            Lane::SerialLspStatus,
1340            Lane::HeavyInit,
1341            Lane::Mutating,
1342            Lane::MaintenanceCommit,
1343        ] {
1344            let queue = self.queue_mut(lane);
1345            let position = queue.iter().position(|queued| {
1346                queued
1347                    .cancellation
1348                    .as_ref()
1349                    .is_some_and(|candidate| candidate.same_token(token))
1350            });
1351            if let Some(position) = position {
1352                let removed = queue.remove(position);
1353                let mut occurrence = 0usize;
1354                if let Some(order_position) = self.order.iter().position(|entry| {
1355                    if *entry != lane {
1356                        return false;
1357                    }
1358                    let matched = occurrence == position;
1359                    occurrence += 1;
1360                    matched
1361                }) {
1362                    self.order.remove(order_position);
1363                }
1364                return removed;
1365            }
1366        }
1367        None
1368    }
1369
1370    fn queued_configure_count(&self) -> usize {
1371        self.mutating
1372            .iter()
1373            .filter(|job| is_configure_request(&job.request_id))
1374            .count()
1375    }
1376
1377    fn queue(&self, lane: Lane) -> &VecDeque<QueuedJob> {
1378        match lane {
1379            Lane::PureRead => &self.pure_reads,
1380            Lane::SerialLspStatus => &self.lsp_status,
1381            Lane::HeavyInit => &self.heavy_init,
1382            Lane::Mutating => &self.mutating,
1383            Lane::MaintenanceCommit => &self.maintenance_commit,
1384        }
1385    }
1386
1387    fn queue_mut(&mut self, lane: Lane) -> &mut VecDeque<QueuedJob> {
1388        match lane {
1389            Lane::PureRead => &mut self.pure_reads,
1390            Lane::SerialLspStatus => &mut self.lsp_status,
1391            Lane::HeavyInit => &mut self.heavy_init,
1392            Lane::Mutating => &mut self.mutating,
1393            Lane::MaintenanceCommit => &mut self.maintenance_commit,
1394        }
1395    }
1396}
1397
1398struct QueuedJob {
1399    job: ExecutorJob,
1400    completion: CompletionSender,
1401    request_id: String,
1402    command: String,
1403    queued_at: Instant,
1404    cancellation: Option<JobCancellation>,
1405}
1406
1407fn fail_queued_job_queue(queue: &mut VecDeque<QueuedJob>) {
1408    for queued in queue.drain(..) {
1409        queued
1410            .completion
1411            .send(actor_fatal_response(queued.request_id));
1412    }
1413}
1414
1415fn cancel_queued_job_queue(queue: &mut VecDeque<QueuedJob>) -> usize {
1416    let cancelled = queue.len();
1417    for queued in queue.drain(..) {
1418        queued.completion.send(Response::error(
1419            queued.request_id,
1420            "maintenance_cancelled",
1421            "maintenance cancelled because the actor has no bound routes",
1422        ));
1423    }
1424    cancelled
1425}
1426
1427fn job_command(job_class: JobClass, lane: Lane) -> String {
1428    format!("executor::{job_class:?}::{lane:?}")
1429}
1430
1431fn actor_fatal_response(request_id: impl Into<String>) -> Response {
1432    Response::error(
1433        request_id,
1434        "actor_fatal",
1435        "executor actor is fatal after a mutating job panic",
1436    )
1437}
1438
1439fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
1440    if let Some(message) = payload.downcast_ref::<&'static str>() {
1441        (*message).to_string()
1442    } else if let Some(message) = payload.downcast_ref::<String>() {
1443        message.clone()
1444    } else {
1445        "unknown panic payload".to_string()
1446    }
1447}
1448
1449fn panic_response(
1450    request_id: impl Into<String>,
1451    command: &str,
1452    payload: &(dyn std::any::Any + Send),
1453) -> Response {
1454    let panic_message = panic_payload_message(payload);
1455    Response::error(
1456        request_id,
1457        "actor_fatal",
1458        format!("command '{command}' panicked: {panic_message}"),
1459    )
1460}
1461
1462enum CompletionSender {
1463    Sync(Sender<Response>),
1464    Async(oneshot::Sender<Response>),
1465}
1466
1467impl CompletionSender {
1468    fn send(self, response: Response) {
1469        match self {
1470            Self::Sync(tx) => {
1471                let _ = tx.send(response);
1472            }
1473            Self::Async(tx) => {
1474                let _ = tx.send(response);
1475            }
1476        }
1477    }
1478}
1479
1480struct RunJob {
1481    root_id: ProjectRootId,
1482    job_class: JobClass,
1483    lane: Lane,
1484    ctx: Arc<AppContext>,
1485    epoch: Arc<RwLock<()>>,
1486    job: ExecutorJob,
1487    completion: Option<CompletionSender>,
1488    request_id: String,
1489    command: String,
1490    heavy_permit: Option<HeavyPermit>,
1491    cancellation: Option<JobCancellation>,
1492}
1493
1494struct CompletionEvent {
1495    root_id: ProjectRootId,
1496    request_id: String,
1497    job_class: JobClass,
1498    lane: Lane,
1499    heavy_permit: Option<HeavyPermit>,
1500    panicked: bool,
1501}
1502
1503enum SchedulerEvent {
1504    Wake,
1505    Completed(CompletionEvent),
1506    Shutdown,
1507}
1508
1509fn scheduler_loop(
1510    state: Arc<Mutex<SchedulerState>>,
1511    heavy: Arc<HeavySemaphore>,
1512    run_tx: Sender<RunJob>,
1513    event_rx: Receiver<SchedulerEvent>,
1514    nonrunnable_dispatches: Arc<AtomicUsize>,
1515    completed_interactive: Arc<AtomicU64>,
1516    completed_maintenance: Arc<AtomicU64>,
1517) {
1518    while let Ok(event) = event_rx.recv() {
1519        let mut shutdown = false;
1520        {
1521            let mut state = state.lock();
1522            note_completion_event(&event, &completed_interactive, &completed_maintenance);
1523            shutdown |= process_scheduler_event(event, &mut state);
1524            while !shutdown {
1525                match event_rx.try_recv() {
1526                    Ok(event) => {
1527                        note_completion_event(
1528                            &event,
1529                            &completed_interactive,
1530                            &completed_maintenance,
1531                        );
1532                        shutdown |= process_scheduler_event(event, &mut state)
1533                    }
1534                    Err(_) => break,
1535                }
1536            }
1537
1538            if !shutdown {
1539                dispatch_runnable(&mut state, &heavy, &run_tx, &nonrunnable_dispatches);
1540            }
1541        }
1542
1543        if shutdown {
1544            break;
1545        }
1546    }
1547}
1548
1549fn note_completion_event(
1550    event: &SchedulerEvent,
1551    completed_interactive: &AtomicU64,
1552    completed_maintenance: &AtomicU64,
1553) {
1554    let SchedulerEvent::Completed(event) = event else {
1555        return;
1556    };
1557    match event.job_class {
1558        JobClass::Interactive => {
1559            completed_interactive.fetch_add(1, Ordering::Relaxed);
1560        }
1561        JobClass::Maintenance => {
1562            completed_maintenance.fetch_add(1, Ordering::Relaxed);
1563        }
1564    }
1565}
1566
1567fn process_scheduler_event(event: SchedulerEvent, state: &mut SchedulerState) -> bool {
1568    match event {
1569        SchedulerEvent::Wake => false,
1570        SchedulerEvent::Completed(event) => {
1571            complete_job(state, event);
1572            false
1573        }
1574        SchedulerEvent::Shutdown => true,
1575    }
1576}
1577
1578fn complete_job(state: &mut SchedulerState, event: CompletionEvent) {
1579    let CompletionEvent {
1580        root_id,
1581        request_id,
1582        job_class,
1583        lane,
1584        heavy_permit,
1585        panicked,
1586    } = event;
1587    state.running_jobs.remove(&(root_id.clone(), request_id));
1588
1589    match job_class {
1590        JobClass::Interactive => {
1591            state.interactive_inflight = state.interactive_inflight.saturating_sub(1);
1592        }
1593        JobClass::Maintenance => {
1594            state.maintenance_inflight = state.maintenance_inflight.saturating_sub(1);
1595        }
1596    }
1597
1598    if let Some(actor) = state.actors.get_mut(&root_id) {
1599        actor.actor_total_inflight = actor.actor_total_inflight.saturating_sub(1);
1600        match lane {
1601            Lane::PureRead => {
1602                actor.read_inflight = actor.read_inflight.saturating_sub(1);
1603            }
1604            Lane::SerialLspStatus => {
1605                actor.lsp_inflight = false;
1606            }
1607            Lane::HeavyInit => {}
1608            Lane::Mutating => {
1609                actor.writer_inflight = false;
1610                actor.mutating_inflight = None;
1611            }
1612            Lane::MaintenanceCommit => {
1613                actor.maintenance_commit_inflight = false;
1614            }
1615        }
1616
1617        if panicked && lane == Lane::Mutating {
1618            actor.fatal = true;
1619            actor.fail_queued_jobs();
1620        }
1621    }
1622
1623    drop(heavy_permit);
1624    state.idle_workers += 1;
1625}
1626
1627fn dispatch_runnable(
1628    state: &mut SchedulerState,
1629    heavy: &Arc<HeavySemaphore>,
1630    run_tx: &Sender<RunJob>,
1631    nonrunnable_dispatches: &AtomicUsize,
1632) {
1633    while state.idle_workers > 0 && !state.actor_order.is_empty() {
1634        let mut made_progress = false;
1635        let mut dispatch_failed = false;
1636
1637        made_progress |= dispatch_runnable_class(
1638            state,
1639            JobClass::Interactive,
1640            heavy,
1641            run_tx,
1642            nonrunnable_dispatches,
1643            &mut dispatch_failed,
1644        );
1645        if dispatch_failed || state.idle_workers == 0 {
1646            return;
1647        }
1648
1649        if can_dispatch_class(state, JobClass::Maintenance) {
1650            made_progress |= dispatch_runnable_class(
1651                state,
1652                JobClass::Maintenance,
1653                heavy,
1654                run_tx,
1655                nonrunnable_dispatches,
1656                &mut dispatch_failed,
1657            );
1658            if dispatch_failed {
1659                return;
1660            }
1661        }
1662
1663        if !made_progress {
1664            break;
1665        }
1666    }
1667}
1668
1669fn dispatch_runnable_class(
1670    state: &mut SchedulerState,
1671    job_class: JobClass,
1672    heavy: &Arc<HeavySemaphore>,
1673    run_tx: &Sender<RunJob>,
1674    nonrunnable_dispatches: &AtomicUsize,
1675    dispatch_failed: &mut bool,
1676) -> bool {
1677    if !can_dispatch_class(state, job_class) || state.actor_order.is_empty() {
1678        return false;
1679    }
1680
1681    let actor_count = state.actor_order.len();
1682    let mut made_progress = false;
1683
1684    for _ in 0..actor_count {
1685        if !can_dispatch_class(state, job_class) || state.actor_order.is_empty() {
1686            break;
1687        }
1688
1689        if state.cursor >= state.actor_order.len() {
1690            state.cursor = 0;
1691        }
1692        let root_id = state.actor_order[state.cursor].clone();
1693        state.cursor = (state.cursor + 1) % state.actor_order.len();
1694
1695        let run_job = {
1696            let Some(actor) = state.actors.get_mut(&root_id) else {
1697                continue;
1698            };
1699
1700            if actor.fatal {
1701                actor.fail_queued_jobs();
1702                actor.deficit = 0;
1703                continue;
1704            }
1705
1706            if !actor.has_queued_jobs() {
1707                actor.deficit = 0;
1708                continue;
1709            }
1710
1711            if !actor.has_queued_jobs_for(job_class) {
1712                continue;
1713            }
1714
1715            actor.deficit =
1716                (actor.deficit + state.config.drr_quantum).min(state.config.deficit_cap);
1717            if actor.deficit < JOB_COST {
1718                continue;
1719            }
1720
1721            try_admit_actor(&root_id, actor, job_class, &state.config, heavy)
1722        };
1723
1724        if let Some(run_job) = run_job {
1725            state.running_jobs.insert(
1726                (run_job.root_id.clone(), run_job.request_id.clone()),
1727                RunningJob {
1728                    root_id: run_job.root_id.clone(),
1729                    request_id: run_job.request_id.clone(),
1730                    command: run_job.command.clone(),
1731                    job_class: run_job.job_class,
1732                    lane: run_job.lane,
1733                    started_at: Instant::now(),
1734                },
1735            );
1736            state.idle_workers -= 1;
1737            match job_class {
1738                JobClass::Interactive => state.interactive_inflight += 1,
1739                JobClass::Maintenance => state.maintenance_inflight += 1,
1740            }
1741            made_progress = true;
1742            if run_tx.send(run_job).is_err() {
1743                nonrunnable_dispatches.fetch_add(1, Ordering::AcqRel);
1744                *dispatch_failed = true;
1745                return made_progress;
1746            }
1747        }
1748    }
1749
1750    made_progress
1751}
1752
1753fn can_dispatch_class(state: &SchedulerState, job_class: JobClass) -> bool {
1754    if state.idle_workers == 0 {
1755        return false;
1756    }
1757    match job_class {
1758        JobClass::Interactive => true,
1759        JobClass::Maintenance => {
1760            state.maintenance_inflight < state.config.maintenance_cap
1761                && state.idle_workers > state.config.interactive_reserve
1762        }
1763    }
1764}
1765
1766fn try_admit_actor(
1767    root_id: &ProjectRootId,
1768    actor: &mut ActorState,
1769    job_class: JobClass,
1770    config: &EffectiveConfig,
1771    heavy: &Arc<HeavySemaphore>,
1772) -> Option<RunJob> {
1773    let lane = match job_class {
1774        JobClass::Interactive => actor
1775            .class_queues(JobClass::Interactive)
1776            .next_interactive_lane(Instant::now())?,
1777        JobClass::Maintenance => actor.front_lane(job_class)?,
1778    };
1779    let mut heavy_permit = None;
1780
1781    if actor.writer_inflight || actor.higher_priority_writer_barrier_blocks(job_class) {
1782        return None;
1783    }
1784
1785    let has_epoch_reader =
1786        actor.read_inflight > 0 || actor.lsp_inflight || actor.maintenance_commit_inflight;
1787    let actor_has_capacity = actor.actor_total_inflight < config.actor_cap;
1788    let runnable = match lane {
1789        Lane::PureRead => actor.read_inflight < config.read_cap && actor_has_capacity,
1790        Lane::SerialLspStatus => !actor.lsp_inflight && actor_has_capacity,
1791        Lane::HeavyInit => {
1792            if !actor_has_capacity {
1793                false
1794            } else if let Some(permit) = heavy.try_acquire() {
1795                heavy_permit = Some(permit);
1796                true
1797            } else {
1798                false
1799            }
1800        }
1801        Lane::Mutating => !has_epoch_reader && actor_has_capacity,
1802        // Overlaps reads (epoch read gate); one in flight per actor so
1803        // maintenance cannot stack; a running writer blocks it like reads.
1804        Lane::MaintenanceCommit => !actor.maintenance_commit_inflight && actor_has_capacity,
1805    };
1806
1807    if !runnable {
1808        return None;
1809    }
1810
1811    let queued = actor.pop_front_job(job_class, lane)?;
1812    actor.deficit -= JOB_COST;
1813    if let Some(cancellation) = queued.cancellation.as_ref() {
1814        cancellation.mark_running();
1815    }
1816    if lane == Lane::Mutating {
1817        actor.mutating_inflight = Some(RunningMutatingJob {
1818            request_id: queued.request_id.clone(),
1819            command: queued.command.clone(),
1820            started_at: Instant::now(),
1821        });
1822    }
1823    match lane {
1824        Lane::PureRead => {
1825            actor.read_inflight += 1;
1826            actor.actor_total_inflight += 1;
1827        }
1828        Lane::SerialLspStatus => {
1829            actor.lsp_inflight = true;
1830            actor.actor_total_inflight += 1;
1831        }
1832        Lane::HeavyInit => {
1833            actor.actor_total_inflight += 1;
1834        }
1835        Lane::Mutating => {
1836            actor.writer_inflight = true;
1837            actor.actor_total_inflight += 1;
1838        }
1839        Lane::MaintenanceCommit => {
1840            actor.maintenance_commit_inflight = true;
1841            actor.actor_total_inflight += 1;
1842        }
1843    }
1844
1845    Some(RunJob {
1846        root_id: root_id.clone(),
1847        job_class,
1848        lane,
1849        ctx: Arc::clone(&actor.ctx),
1850        epoch: Arc::clone(&actor.epoch),
1851        job: queued.job,
1852        completion: Some(queued.completion),
1853        request_id: queued.request_id,
1854        command: queued.command,
1855        heavy_permit,
1856        cancellation: queued.cancellation,
1857    })
1858}
1859
1860fn worker_loop(run_rx: Receiver<RunJob>, event_tx: Sender<SchedulerEvent>) {
1861    while let Ok(mut run_job) = run_rx.recv() {
1862        let response =
1863            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_lane_job(&mut run_job)));
1864        let panicked = response.is_err();
1865        let response = match response {
1866            Ok(response) => response,
1867            Err(payload) => panic_response(
1868                run_job.request_id.clone(),
1869                &run_job.command,
1870                payload.as_ref(),
1871            ),
1872        };
1873
1874        if let Some(completion) = run_job.completion.take() {
1875            completion.send(response);
1876        }
1877        let completion = CompletionEvent {
1878            root_id: run_job.root_id,
1879            request_id: run_job.request_id,
1880            job_class: run_job.job_class,
1881            lane: run_job.lane,
1882            heavy_permit: run_job.heavy_permit.take(),
1883            panicked,
1884        };
1885        let _ = event_tx.send(SchedulerEvent::Completed(completion));
1886    }
1887}
1888
1889fn run_lane_job(run_job: &mut RunJob) -> Response {
1890    let _cancellation_ctx = CurrentJobCancellationGuard::install(run_job.cancellation.clone());
1891    let missing_request_id = run_job.request_id.clone();
1892    let job = std::mem::replace(
1893        &mut run_job.job,
1894        Box::new(move |_| {
1895            Response::error(
1896                missing_request_id,
1897                "job_missing",
1898                "executor job already taken",
1899            )
1900        }),
1901    );
1902
1903    match run_job.lane {
1904        Lane::PureRead | Lane::SerialLspStatus => {
1905            let _epoch = run_job.epoch.read();
1906            job(&run_job.ctx)
1907        }
1908        Lane::HeavyInit => {
1909            let response = job(&run_job.ctx);
1910            {
1911                let _install = run_job.epoch.write();
1912            }
1913            response
1914        }
1915        Lane::Mutating => {
1916            let _epoch = run_job.epoch.write();
1917            job(&run_job.ctx)
1918        }
1919        Lane::MaintenanceCommit => {
1920            // Same gate as reads: the job's mutations are protected by the
1921            // touched subsystems' own locks, and holding only the read gate
1922            // lets interactive PureReads overlap freely.
1923            let _epoch = run_job.epoch.read();
1924            job(&run_job.ctx)
1925        }
1926    }
1927}
1928
1929#[derive(Debug)]
1930struct HeavySemaphore {
1931    available: AtomicUsize,
1932    max: usize,
1933}
1934
1935impl HeavySemaphore {
1936    fn new(permits: usize) -> Self {
1937        Self {
1938            available: AtomicUsize::new(permits),
1939            max: permits,
1940        }
1941    }
1942
1943    fn try_acquire(self: &Arc<Self>) -> Option<HeavyPermit> {
1944        loop {
1945            let available = self.available.load(Ordering::Acquire);
1946            if available == 0 {
1947                return None;
1948            }
1949            if self
1950                .available
1951                .compare_exchange(
1952                    available,
1953                    available - 1,
1954                    Ordering::AcqRel,
1955                    Ordering::Acquire,
1956                )
1957                .is_ok()
1958            {
1959                return Some(HeavyPermit {
1960                    semaphore: Arc::clone(self),
1961                });
1962            }
1963        }
1964    }
1965}
1966
1967struct HeavyPermit {
1968    semaphore: Arc<HeavySemaphore>,
1969}
1970
1971impl Drop for HeavyPermit {
1972    fn drop(&mut self) {
1973        let previous = self.semaphore.available.fetch_add(1, Ordering::Release);
1974        debug_assert!(previous < self.semaphore.max);
1975    }
1976}