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