use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::{DagScheduler, RunningTask, SchedulerAction, TaskEvent, TaskOutcome};
use crate::command::TaskRef;
use crate::dag;
use crate::graph::{ExecutionMode, GraphStatus, TaskId, TaskResult, TaskStatus};
use crate::lineage::{ErrorLineage, LineageEntry, LineageKind, classify_error, now_ms};
use crate::topology::DispatchStrategy;
use zeph_subagent::SubAgentError;
struct CompletedTaskData {
output: String,
artifacts: Vec<std::path::PathBuf>,
tool_trace: Option<Vec<crate::verifier::ToolCallSummary>>,
}
struct HandoffTaskData {
output: String,
goto: TaskRef,
tool_trace: Option<Vec<crate::verifier::ToolCallSummary>>,
}
fn counts_toward_completion_heuristic(call: &crate::verifier::ToolCallSummary) -> bool {
!call.is_read_only || zeph_common::quarantine::is_quarantine_denied(&call.tool)
}
fn all_tool_calls_failed(trace: &[crate::verifier::ToolCallSummary]) -> bool {
if trace.is_empty() {
return false;
}
let mut counting_calls = trace
.iter()
.filter(|c| counts_toward_completion_heuristic(c))
.peekable();
if counting_calls.peek().is_some() {
counting_calls.all(|c| !c.ok)
} else {
trace.iter().all(|c| !c.ok)
}
}
impl DagScheduler {
pub fn tick(&mut self) -> Vec<SchedulerAction> {
if self.graph.status != GraphStatus::Running {
return vec![SchedulerAction::Done {
status: self.graph.status,
}];
}
self.reanalyze_topology_if_dirty();
let mut actions = self.drain_events_into_actions();
if self.graph.status != GraphStatus::Running {
return actions;
}
let timeout_actions = self.check_timeouts();
actions.extend(timeout_actions);
if self.graph.status != GraphStatus::Running {
return actions;
}
let ready = self.ordered_ready_tasks();
let dispatch_actions = self.dispatch_ready_tasks(ready);
actions.extend(dispatch_actions);
actions.extend(self.emit_pending_predicate_actions());
actions.extend(self.check_graph_completion());
actions
}
fn drain_events_into_actions(&mut self) -> Vec<SchedulerAction> {
let mut actions = Vec::new();
while let Some(event) = self.buffered_events.pop_front() {
actions.extend(self.process_event(event));
}
while let Ok(event) = self.event_rx.try_recv() {
actions.extend(self.process_event(event));
}
actions
}
fn ordered_ready_tasks(&mut self) -> Vec<TaskId> {
let raw_ready = dag::ready_tasks(&self.graph);
let ready: Vec<TaskId> = if self.topology.strategy == DispatchStrategy::CascadeAware {
if let Some(ref mut detector) = self.cascade_detector {
let graph = &self.graph;
let deprioritized = detector.deprioritized_tasks(graph);
if deprioritized.is_empty() {
raw_ready
} else {
let (preferred, deferred): (Vec<_>, Vec<_>) =
raw_ready.into_iter().partition(|id| {
let is_sequential = self.graph.tasks[id.index()].execution_mode
== ExecutionMode::Sequential;
is_sequential || !deprioritized.contains(id)
});
preferred.into_iter().chain(deferred).collect()
}
} else {
raw_ready
}
} else {
raw_ready
};
if self.topology.strategy == DispatchStrategy::TreeOptimized {
let max_depth = self.topology.depth;
let mut sortable = ready;
sortable.sort_by_key(|id| {
let task_depth = self.topology.depths.get(id).copied().unwrap_or(0);
max_depth.saturating_sub(task_depth)
});
sortable
} else {
ready
}
}
fn dispatch_ready_tasks(&mut self, ready: Vec<TaskId>) -> Vec<SchedulerAction> {
self.advance_level_barrier_if_needed();
let mut actions = Vec::new();
let mut slots = self.max_parallel.saturating_sub(self.running.len());
let mut sequential_spawned_this_tick = false;
let has_running_sequential = self
.running
.keys()
.any(|tid| self.graph.tasks[tid.index()].execution_mode == ExecutionMode::Sequential);
for task_id in ready {
if slots == 0 {
break;
}
let task = &self.graph.tasks[task_id.index()];
let is_activated_fallback = task.routed_from.is_some();
if self.topology.strategy == DispatchStrategy::LevelBarrier && !is_activated_fallback {
let task_depth = self
.topology
.depths
.get(&task_id)
.copied()
.unwrap_or(usize::MAX);
if task_depth != self.current_level {
continue;
}
}
if task.execution_mode == ExecutionMode::Sequential {
if sequential_spawned_this_tick || has_running_sequential {
continue;
}
sequential_spawned_this_tick = true;
}
let Some(agent_def_name) = self.router.route(task, &self.available_agents) else {
tracing::debug!(
task_id = %task_id,
title = %task.title,
"no agent available, routing task to main agent inline"
);
let prompt = self.build_task_prompt(task);
self.graph.tasks[task_id.index()].status = TaskStatus::Running;
actions.push(SchedulerAction::RunInline { task_id, prompt });
slots -= 1;
continue;
};
if let Some(ref gate) = self.admission_gate {
let provider_key: Option<&str> = self
.agent_provider_map
.get(&agent_def_name)
.map(String::as_str);
if let Some(key) = provider_key.filter(|k| gate.has_gate(k)) {
if let Some(permit) = gate.try_acquire(key) {
self.pending_permits.insert(task_id, permit);
} else {
tracing::debug!(
task_id = %task_id,
provider = %key,
agent = %agent_def_name,
"admission gate saturated, deferring task to next tick"
);
self.consecutive_spawn_failures =
self.consecutive_spawn_failures.saturating_add(1);
continue;
}
}
}
let prompt = self.build_task_prompt(task);
self.graph.tasks[task_id.index()].status = TaskStatus::Running;
actions.push(SchedulerAction::Spawn {
task_id,
agent_def_name,
prompt,
});
slots -= 1;
}
actions
}
fn emit_pending_predicate_actions(&self) -> Vec<SchedulerAction> {
if !self.verify_predicate_enabled {
return Vec::new();
}
self.graph
.tasks
.iter()
.filter_map(|task| {
if task.status == TaskStatus::Completed
&& let (Some(predicate), None) =
(&task.verify_predicate, &task.predicate_outcome)
{
let output = task
.result
.as_ref()
.map_or_else(String::new, |r| r.output.clone());
Some(SchedulerAction::VerifyPredicate {
task_id: task.id,
predicate: predicate.clone(),
output,
})
} else {
None
}
})
.collect()
}
#[tracing::instrument(name = "orchestration.scheduler.wait_event", skip(self), fields(running = self.running.len()))]
pub async fn wait_event(&mut self) {
if self.running.is_empty() {
tokio::time::sleep(self.current_deferral_backoff()).await;
return;
}
let nearest_timeout = self
.running
.iter()
.map(|(id, r)| {
self.effective_run_timeout(*id)
.checked_sub(r.started_at.elapsed())
.unwrap_or(Duration::ZERO)
})
.min()
.unwrap_or(Duration::from_secs(1));
let wait_duration = nearest_timeout.max(Duration::from_millis(100));
tokio::select! {
Some(event) = self.event_rx.recv() => {
if self.buffered_events.len() >= self.graph.tasks.len() * 2 {
if let Some(dropped) = self.buffered_events.pop_front() {
tracing::error!(
task_id = %dropped.task_id,
buffer_len = self.buffered_events.len(),
"event buffer saturated; completion event dropped — task may \
remain Running until timeout"
);
}
}
self.buffered_events.push_back(event);
}
() = tokio::time::sleep(wait_duration) => {}
}
}
pub fn record_spawn(
&mut self,
task_id: TaskId,
agent_handle_id: String,
agent_def_name: String,
last_progress_at: Option<Arc<AtomicU64>>,
) {
self.consecutive_spawn_failures = 0;
self.graph.tasks[task_id.index()].assigned_agent = Some(agent_handle_id.clone());
let admission_permit = self.pending_permits.remove(&task_id);
self.running.insert(
task_id,
RunningTask {
agent_handle_id,
agent_def_name,
started_at: std::time::Instant::now(),
admission_permit,
last_progress_at,
},
);
}
pub fn record_spawn_failure(
&mut self,
task_id: TaskId,
error: &SubAgentError,
) -> Vec<SchedulerAction> {
self.pending_permits.remove(&task_id);
if let SubAgentError::ConcurrencyLimit { active, max } = error {
tracing::warn!(
task_id = %task_id,
active,
max,
next_backoff_ms = self.current_deferral_backoff().as_millis(),
"concurrency limit reached, deferring task to next tick"
);
self.graph.tasks[task_id.index()].status = TaskStatus::Ready;
return Vec::new();
}
let error_excerpt: String = error.to_string().chars().take(512).collect();
tracing::warn!(
task_id = %task_id,
error = %error_excerpt,
"spawn failed, marking task failed"
);
self.graph_dirty = true;
self.graph.tasks[task_id.index()].status = TaskStatus::Failed;
self.graph.tasks[task_id.index()].result = Some(TaskResult {
output: error_excerpt,
artifacts: Vec::new(),
duration_ms: 0,
agent_id: None,
agent_def: None,
});
let cancel_ids = dag::propagate_failure(&mut self.graph, task_id, &self.topology.rev_adj);
let mut actions = Vec::new();
for cancel_task_id in cancel_ids {
if let Some(running) = self.running.remove(&cancel_task_id) {
actions.push(SchedulerAction::Cancel {
agent_handle_id: running.agent_handle_id,
});
}
}
if self.graph.status != GraphStatus::Running {
self.graph.finished_at = Some(crate::graph::chrono_now());
actions.push(SchedulerAction::Done {
status: self.graph.status,
});
}
actions
}
pub fn record_batch_backoff(&mut self, any_success: bool, any_concurrency_failure: bool) {
if any_success {
self.consecutive_spawn_failures = 0;
} else if any_concurrency_failure {
self.consecutive_spawn_failures = self.consecutive_spawn_failures.saturating_add(1);
}
}
pub fn cancel_all(&mut self) -> Vec<SchedulerAction> {
self.graph_dirty = true;
self.graph.status = GraphStatus::Canceled;
self.graph.finished_at = Some(crate::graph::chrono_now());
let running: Vec<(TaskId, RunningTask)> = self.running.drain().collect();
let mut actions: Vec<SchedulerAction> = running
.into_iter()
.map(|(task_id, r)| {
self.graph.tasks[task_id.index()].status = TaskStatus::Canceled;
SchedulerAction::Cancel {
agent_handle_id: r.agent_handle_id,
}
})
.collect();
for task in &mut self.graph.tasks {
if !task.status.is_terminal() {
task.status = TaskStatus::Canceled;
}
}
actions.push(SchedulerAction::Done {
status: GraphStatus::Canceled,
});
actions
}
fn current_deferral_backoff(&self) -> Duration {
const MAX_BACKOFF: Duration = Duration::from_secs(5);
let multiplier = 1u32
.checked_shl(self.consecutive_spawn_failures.min(10))
.unwrap_or(u32::MAX);
self.deferral_backoff
.saturating_mul(multiplier)
.min(MAX_BACKOFF)
}
fn process_event(&mut self, event: TaskEvent) -> Vec<SchedulerAction> {
let TaskEvent {
task_id,
agent_handle_id,
outcome,
} = event;
let Some((duration_ms, agent_def_name)) =
self.consume_running_for_event(task_id, &agent_handle_id)
else {
return Vec::new();
};
match outcome {
TaskOutcome::Completed {
output,
artifacts,
tool_trace,
} => self.handle_completed_outcome(
task_id,
agent_handle_id,
agent_def_name,
duration_ms,
CompletedTaskData {
output,
artifacts,
tool_trace,
},
),
TaskOutcome::Failed { error } => self.handle_failed_outcome(task_id, &error),
TaskOutcome::Handoff {
output,
goto,
tool_trace,
} => self.handle_handoff_outcome(
task_id,
agent_handle_id,
agent_def_name,
duration_ms,
HandoffTaskData {
output,
goto,
tool_trace,
},
),
}
}
fn consume_running_for_event(
&mut self,
task_id: TaskId,
agent_handle_id: &str,
) -> Option<(u64, Option<String>)> {
match self.running.get(&task_id) {
Some(running) if running.agent_handle_id != agent_handle_id => {
tracing::warn!(
task_id = %task_id,
expected = %running.agent_handle_id,
got = %agent_handle_id,
"discarding stale event from previous agent incarnation"
);
return None;
}
None => {
tracing::debug!(
task_id = %task_id,
agent_handle_id = %agent_handle_id,
"ignoring event for task not in running map"
);
return None;
}
Some(_) => {}
}
let duration_ms = self.running.get(&task_id).map_or(0, |r| {
u64::try_from(r.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
});
let agent_def_name = self.running.get(&task_id).map(|r| r.agent_def_name.clone());
self.running.remove(&task_id);
Some((duration_ms, agent_def_name))
}
fn handle_completed_outcome(
&mut self,
task_id: TaskId,
agent_handle_id: String,
agent_def_name: Option<String>,
duration_ms: u64,
completed: CompletedTaskData,
) -> Vec<SchedulerAction> {
let CompletedTaskData {
output,
artifacts,
tool_trace,
} = completed;
if let Some(trace) = tool_trace.as_ref()
&& all_tool_calls_failed(trace)
{
let error = format!(
"all {} tool call(s) in this task failed or were policy-blocked; \
narration: {output}",
trace.len()
);
return self.handle_failed_outcome(task_id, &error);
}
self.graph_dirty = true;
self.graph.tasks[task_id.index()].status = TaskStatus::Completed;
self.graph.tasks[task_id.index()].result = Some(TaskResult {
output: output.clone(),
artifacts,
duration_ms,
agent_id: Some(agent_handle_id),
agent_def: agent_def_name,
});
let effective_budget = self.graph.tasks[task_id.index()]
.token_budget_cents
.unwrap_or(self.default_task_budget_cents);
if effective_budget > 0.0 {
#[allow(clippy::cast_precision_loss)]
let estimated_cents = duration_ms as f64 / 1_000.0;
if estimated_cents > effective_budget {
tracing::warn!(
task_id = %task_id,
estimated_cents,
budget_cents = effective_budget,
"task exceeded token budget (warn-only; hard enforcement is future work)"
);
}
}
self.lineage_chains.remove(&task_id);
if let Some(ref mut detector) = self.cascade_detector {
detector.record_outcome(task_id, true, &self.graph);
}
let newly_ready = dag::ready_tasks(&self.graph);
for ready_id in newly_ready {
if self.graph.tasks[ready_id.index()].status == TaskStatus::Pending {
self.graph.tasks[ready_id.index()].status = TaskStatus::Ready;
}
}
let mut actions = vec![SchedulerAction::CheckToolOutcome {
task_id,
tool_trace: tool_trace.clone(),
}];
if self.verify_completeness {
actions.push(SchedulerAction::Verify {
task_id,
output,
tool_trace,
});
}
actions
}
pub fn correct_completed_to_failed_if_all_tool_calls_failed(
&mut self,
task_id: TaskId,
tool_trace: Option<&[crate::verifier::ToolCallSummary]>,
) -> bool {
let Some(trace) = tool_trace else {
return false;
};
if !all_tool_calls_failed(trace) {
return false;
}
let Some(task) = self.graph.tasks.get_mut(task_id.index()) else {
return false;
};
if task.status != TaskStatus::Completed {
return false;
}
tracing::warn!(
task_id = %task_id,
tool_call_count = trace.len(),
"correcting task status Completed -> Failed: all tool calls failed or were \
policy-blocked (#6380/#6397)"
);
task.status = TaskStatus::Failed;
if let Some(result) = task.result.as_mut() {
result.output = format!(
"{} [corrected: all {} tool call(s) failed or were policy-blocked]",
result.output,
trace.len()
);
}
self.graph_dirty = true;
true
}
fn handle_handoff_outcome(
&mut self,
task_id: TaskId,
agent_handle_id: String,
agent_def_name: Option<String>,
duration_ms: u64,
handoff: HandoffTaskData,
) -> Vec<SchedulerAction> {
let HandoffTaskData {
output,
goto,
tool_trace,
} = handoff;
if let Some(trace) = tool_trace.as_ref()
&& all_tool_calls_failed(trace)
{
let error = format!(
"all {} tool call(s) in this task failed or were policy-blocked; \
narration: {output}",
trace.len()
);
return self.handle_failed_outcome(task_id, &error);
}
self.graph_dirty = true;
self.graph.tasks[task_id.index()].status = TaskStatus::Completed;
self.graph.tasks[task_id.index()].result = Some(TaskResult {
output: output.clone(),
artifacts: Vec::new(),
duration_ms,
agent_id: Some(agent_handle_id),
agent_def: agent_def_name,
});
self.lineage_chains.remove(&task_id);
if let Some(ref mut detector) = self.cascade_detector {
detector.record_outcome(task_id, true, &self.graph);
}
match dag::try_handoff(&mut self.graph, task_id, &goto, self.max_handoffs) {
Ok(target) => {
tracing::info!(
task_id = %task_id,
target = %target,
"orchestration.scheduler.handoff: Command handoff routed"
);
}
Err(error) => {
self.graph.tasks[task_id.index()].handoff_rejected = Some(error.to_string());
tracing::error!(
task_id = %task_id,
%error,
"orchestration.scheduler.handoff: Command handoff rejected — routing \
intent dropped, node stays Completed"
);
}
}
let newly_ready = dag::ready_tasks(&self.graph);
for ready_id in newly_ready {
if self.graph.tasks[ready_id.index()].status == TaskStatus::Pending {
self.graph.tasks[ready_id.index()].status = TaskStatus::Ready;
}
}
let mut actions = vec![SchedulerAction::CheckToolOutcome {
task_id,
tool_trace: tool_trace.clone(),
}];
if self.verify_completeness {
actions.push(SchedulerAction::Verify {
task_id,
output,
tool_trace,
});
}
actions
}
pub fn propagate_corrected_task_failure(&mut self, task_id: TaskId) -> Vec<SchedulerAction> {
let task = &self.graph.tasks[task_id.index()];
let effective_strategy = task
.failure_strategy
.unwrap_or(self.graph.default_failure_strategy);
let has_state_injection = task
.recovery
.as_ref()
.and_then(|r| r.state_injection.as_ref())
.is_some();
let needs_forced_terminal =
matches!(
effective_strategy,
zeph_config::FailureStrategy::Retry | zeph_config::FailureStrategy::Ask
) || (effective_strategy == zeph_config::FailureStrategy::Abort && has_state_injection);
let mut cancel_ids = if needs_forced_terminal {
dag::propagate_failure_forced_terminal(&mut self.graph, task_id)
} else {
dag::propagate_failure(&mut self.graph, task_id, &self.topology.rev_adj)
};
cancel_ids.extend(dag::cancel_dangling_commanded_targets(
&mut self.graph,
task_id,
&self.topology.rev_adj,
));
let mut actions = Vec::new();
for cancel_task_id in cancel_ids {
if let Some(running) = self.running.remove(&cancel_task_id) {
actions.push(SchedulerAction::Cancel {
agent_handle_id: running.agent_handle_id,
});
}
}
if self.graph.status != GraphStatus::Running {
self.graph.finished_at = Some(crate::graph::chrono_now());
actions.push(SchedulerAction::Done {
status: self.graph.status,
});
}
actions
}
fn handle_failed_outcome(&mut self, task_id: TaskId, error: &str) -> Vec<SchedulerAction> {
self.graph_dirty = true;
let error_excerpt: String = error.chars().take(512).collect();
tracing::warn!(
task_id = %task_id,
error = %error_excerpt,
"task failed"
);
self.graph.tasks[task_id.index()].status = TaskStatus::Failed;
self.graph.tasks[task_id.index()].result = Some(TaskResult {
output: error_excerpt,
artifacts: Vec::new(),
duration_ms: 0,
agent_id: None,
agent_def: None,
});
if let Some(ref mut detector) = self.cascade_detector {
detector.record_outcome(task_id, false, &self.graph);
}
let deps: Vec<TaskId> = self.graph.tasks[task_id.index()].depends_on.clone();
let mut chain = ErrorLineage::default();
for parent_id in &deps {
if let Some(parent_chain) = self.lineage_chains.get(parent_id) {
chain.merge(parent_chain, self.lineage_ttl_secs);
}
}
chain.push(LineageEntry {
task_id,
kind: LineageKind::Failed {
error_class: classify_error(error),
},
ts_ms: now_ms(),
});
self.lineage_chains.insert(task_id, chain.clone());
let ttl = self.lineage_ttl_secs;
self.lineage_chains.retain(|_, c| c.is_recent(ttl));
let graph = &self.graph;
let threshold = self.cascade_failure_rate_abort_threshold;
if let Some(ref mut detector) = self.cascade_detector {
match detector.evaluate_abort(graph, task_id, threshold) {
crate::cascade::AbortDecision::FanOutCascade {
region_root,
failure_rate,
region_size,
} => {
tracing::error!(
root = %region_root,
failure_rate = failure_rate,
region_size = region_size,
cause = "fan_out_rate",
"cascade abort: fan-out failure rate threshold exceeded"
);
return self.abort_dag_with_lineage(region_root, chain.entries());
}
crate::cascade::AbortDecision::None => {}
}
}
if self.cascade_chain_threshold > 0
&& chain.consecutive_failed_len() >= self.cascade_chain_threshold
{
let root_id = chain.first_entry().map_or(task_id, |e| e.task_id);
tracing::error!(
root = %root_id,
chain_depth = chain.consecutive_failed_len(),
threshold = self.cascade_chain_threshold,
cause = "chain_threshold",
"cascade abort: consecutive failure chain threshold exceeded"
);
return self.abort_dag_with_lineage(root_id, chain.entries());
}
let cancel_ids = dag::propagate_failure(&mut self.graph, task_id, &self.topology.rev_adj);
let mut actions = Vec::new();
for cancel_task_id in cancel_ids {
if let Some(running) = self.running.remove(&cancel_task_id) {
actions.push(SchedulerAction::Cancel {
agent_handle_id: running.agent_handle_id,
});
}
}
if self.graph.status != GraphStatus::Running {
self.graph.finished_at = Some(crate::graph::chrono_now());
actions.push(SchedulerAction::Done {
status: self.graph.status,
});
}
actions
}
fn abort_dag_with_lineage(
&mut self,
root: TaskId,
chain: &[crate::lineage::LineageEntry],
) -> Vec<SchedulerAction> {
self.graph.status = GraphStatus::Failed;
self.graph.finished_at = Some(crate::graph::chrono_now());
tracing::error!(
root = %root,
chain_depth = chain.len(),
chain = ?chain.iter().map(|e| e.task_id).collect::<Vec<_>>(),
"cascade abort: DAG terminated"
);
let mut actions: Vec<SchedulerAction> = self
.running
.drain()
.map(|(_, r)| SchedulerAction::Cancel {
agent_handle_id: r.agent_handle_id,
})
.collect();
actions.push(SchedulerAction::Done {
status: self.graph.status,
});
actions
}
fn effective_run_timeout(&self, task_id: TaskId) -> Duration {
self.graph.tasks[task_id.index()]
.timeout
.as_ref()
.and_then(|t| t.run_timeout_secs)
.map_or(self.task_timeout, Duration::from_secs)
}
fn effective_idle_timeout(&self, task_id: TaskId) -> Option<Duration> {
self.graph.tasks[task_id.index()]
.timeout
.as_ref()
.and_then(|t| t.idle_timeout_secs)
.map(Duration::from_secs)
.or(self.default_idle_timeout)
}
fn check_timeouts(&mut self) -> Vec<SchedulerAction> {
let timed_out: Vec<(TaskId, String, TimeoutCause, Duration)> = self
.running
.iter()
.filter_map(|(id, r)| {
let run_limit = self.effective_run_timeout(*id);
if r.started_at.elapsed() > run_limit {
return Some((*id, r.agent_handle_id.clone(), TimeoutCause::Run, run_limit));
}
let idle_limit = self.effective_idle_timeout(*id)?;
let progress = r.last_progress_at.as_ref()?;
let idle_elapsed_ms = zeph_common::monotonic_millis()
.saturating_sub(progress.load(Ordering::Relaxed));
let idle_limit_ms = u64::try_from(idle_limit.as_millis()).unwrap_or(u64::MAX);
(idle_elapsed_ms > idle_limit_ms).then(|| {
(
*id,
r.agent_handle_id.clone(),
TimeoutCause::Idle,
idle_limit,
)
})
})
.collect();
let mut actions = Vec::new();
for (task_id, agent_handle_id, cause, limit) in timed_out {
tracing::warn!(
task_id = %task_id,
cause = ?cause,
timeout_secs = limit.as_secs(),
"task timed out"
);
self.graph_dirty = true;
let removed = self.running.remove(&task_id);
self.graph.tasks[task_id.index()].status = TaskStatus::Failed;
let duration_ms = removed.as_ref().map_or(0, |r| {
u64::try_from(r.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
});
let agent_def_name = removed.map(|r| r.agent_def_name);
self.graph.tasks[task_id.index()].result = Some(TaskResult {
output: match cause {
TimeoutCause::Run => {
format!("task exceeded run timeout ({}s)", limit.as_secs())
}
TimeoutCause::Idle => format!(
"task exceeded idle timeout ({}s of no progress)",
limit.as_secs()
),
},
artifacts: Vec::new(),
duration_ms,
agent_id: Some(agent_handle_id.clone()),
agent_def: agent_def_name,
});
actions.push(SchedulerAction::Cancel { agent_handle_id });
let cancel_ids =
dag::propagate_failure(&mut self.graph, task_id, &self.topology.rev_adj);
for cancel_task_id in cancel_ids {
if let Some(running) = self.running.remove(&cancel_task_id) {
actions.push(SchedulerAction::Cancel {
agent_handle_id: running.agent_handle_id,
});
}
}
if self.graph.status != GraphStatus::Running {
self.graph.finished_at = Some(crate::graph::chrono_now());
actions.push(SchedulerAction::Done {
status: self.graph.status,
});
break;
}
}
actions
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutCause {
Run,
Idle,
}
#[cfg(test)]
mod tests;