Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions crates/fractal-reconcile/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub mod idle_nudge;
pub mod placement;
pub mod run_report;
pub mod start_spacing;
pub mod program;
pub mod start_jobs;
pub mod seat_admission;
mod seat_admission_queue;
Expand Down Expand Up @@ -1421,6 +1422,8 @@ pub struct Reconciler<R = NativeRuntime> {
file_observations: Arc<Mutex<HashMap<String, FileStamp>>>,
file_watch_revision: Arc<std::sync::atomic::AtomicU64>,
launch_input_wake: std::sync::OnceLock<Arc<dyn Fn() + Send + Sync>>,
/// Runs `program` steps; started with the first one (docs/st3/program-engine.md).
program_engine: std::sync::OnceLock<Arc<program::ProgramEngine>>,
resource_provider: Arc<dyn ResourceProvider>,
/// Open faults by subject and scope, loaded from the graph on first use.
faults: Mutex<Option<BTreeMap<(String, String), String>>>,
Expand Down Expand Up @@ -1637,6 +1640,7 @@ impl Reconciler<NativeRuntime> {
file_observations: Arc::new(Mutex::new(HashMap::new())),
file_watch_revision: Default::default(),
launch_input_wake: Default::default(),
program_engine: Default::default(),
resource_provider: Arc::new(RegisteredResourceProvider),
faults: Mutex::new(None),
unrecorded_faults: Mutex::new(Vec::new()),
Expand Down Expand Up @@ -1857,6 +1861,7 @@ impl<R: RuntimeControl> Reconciler<R> {
file_observations: Arc::new(Mutex::new(HashMap::new())),
file_watch_revision: Default::default(),
launch_input_wake: Default::default(),
program_engine: Default::default(),
resource_provider: Arc::new(RegisteredResourceProvider),
faults: Mutex::new(None),
unrecorded_faults: Mutex::new(Vec::new()),
Expand Down Expand Up @@ -10601,6 +10606,11 @@ impl<R: RuntimeControl> Reconciler<R> {
smallclaims::touched::record_due(|| {
self.isolate("mission-run", &subject, || {
let (run, mission) = self.store.mission_run_for_evaluation(id)?;
// Eager cancellation (§6): a program step that left its attempt in this run's
// latest transition stops now, before an inactive run returns below.
if let Some(engine) = self.program_engine.get() {
engine.release_left(&run);
}
if active_only
&& !matches!(run.status.as_str(), "running" | "standing" | "blocked")
{
Expand Down Expand Up @@ -11489,9 +11499,8 @@ impl<R: RuntimeControl> Reconciler<R> {
if !self.step_declarations_hold(run, &view.subject)? {
return Ok(changed);
}
// The program engine alone decides a program step's outcome: nothing here
// completes it, so it stays working until the engine runs it.
if step.spec.program.is_some() {
if let Some(program) = &step.spec.program {
changed |= self.dispatch_program(run, &step, view, program)?;
return Ok(changed);
}
if !view.agentless && !view.worker_reported {
Expand Down Expand Up @@ -14365,6 +14374,53 @@ impl<R: RuntimeControl> Reconciler<R> {
Ok(true)
}

/// The program engine, started with the first program step this daemon runs.
fn program_engine(&self) -> &Arc<program::ProgramEngine> {
self.program_engine.get_or_init(|| {
let incremental = self.incremental.clone();
let notify = self.notify.clone();
let event_notify = self.event_notify.clone();
program::ProgramEngine::new(
self.store.clone(),
Arc::new(move |step: &str| {
incremental.touch(step);
signal_changed(&notify, &event_notify);
}),
self.driver_state_dir.join("programs"),
program::Liveness::default(),
)
})
}

/// Hand an active program step to the program engine, which completes or fails it. A step
/// whose bundle this member does not hold fails here.
fn dispatch_program(
&self,
run: &MissionRunView,
step: &RuntimeStep<'_>,
view: &crate::model::StepRunView,
program: &crate::model::ProgramSpec,
) -> Result<bool> {
let dispatch = program::Dispatch {
step: view.subject.clone(),
attempt: view.attempt,
run: run.subject.clone(),
program: program.clone(),
payload: run.inputs.iter().map(|(name, input)| (name.clone(), input.value.clone())).collect(),
env: run_variables(run, step, view).into_iter().filter(|(name, _)| name.starts_with("ST_")).collect(),
cwd: run.workspace.clone(),
};
match self.program_engine().dispatch(dispatch)? {
program::Dispatched::Running | program::Dispatched::Started => Ok(false),
// The step keeps its state; `ProgramEngine::host_fault` names the reason.
program::Dispatched::HostDown(reason) => {
eprintln!("fractal: program {}: {reason}", view.subject);
Ok(false)
}
program::Dispatched::Refused(reason) => self.store.set_step_state(&view.subject, "failed", Some(&reason)),
}
}

fn materialize_step_declarations(
&self,
run: &MissionRunView,
Expand Down Expand Up @@ -21189,7 +21245,7 @@ pub mod tests {
mod start_preemption;
mod seat_admission_queue;
mod terminal_step_guard;
mod program_step_hold;
mod program_engine;
#[test]
fn native_exec_and_gate_shell_resolve_the_declared_path() {
use super::{NativeRuntime, RuntimeControl};
Expand Down
Loading
Loading