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