Expose task placement reasons in CLI summaries

This commit is contained in:
Michel Paulissen 2026-07-03 22:15:11 +02:00
parent fe6c2d5042
commit 218ab65e47
4 changed files with 161 additions and 33 deletions

View file

@ -1,7 +1,7 @@
{ {
"kind": "disasmer-filtered-public-tree", "kind": "disasmer-filtered-public-tree",
"source_commit": "96f60dfba4756607edf60c08519050e2e4c2bf37", "source_commit": "9b295127cfd7e4e87d4518a817563ca8e695ed0b",
"release_name": "dryrun-96f60dfba475", "release_name": "dryrun-9b295127cfd7",
"filtered_out": [ "filtered_out": [
"private/**", "private/**",
"experiments/**", "experiments/**",

View file

@ -1783,8 +1783,12 @@ fn human_report(value: &Value) -> String {
if let Some(current_task_count) = value.get("current_task_count").and_then(Value::as_u64) { if let Some(current_task_count) = value.get("current_task_count").and_then(Value::as_u64) {
lines.push(format!("tasks: {current_task_count}")); lines.push(format!("tasks: {current_task_count}"));
} }
if let Some(tasks) = value.get("current_tasks").and_then(Value::as_array) {
push_task_placement_reasons(&mut lines, tasks);
}
if let Some(tasks) = value.get("tasks").and_then(Value::as_array) { if let Some(tasks) = value.get("tasks").and_then(Value::as_array) {
lines.push(format!("tasks: {}", tasks.len())); lines.push(format!("tasks: {}", tasks.len()));
push_task_placement_reasons(&mut lines, tasks);
} }
if let Some(log_entries) = value.get("log_entries").and_then(Value::as_array) { if let Some(log_entries) = value.get("log_entries").and_then(Value::as_array) {
lines.push(format!("log entries: {}", log_entries.len())); lines.push(format!("log entries: {}", log_entries.len()));
@ -1976,6 +1980,33 @@ fn push_string_field(lines: &mut Vec<String>, value: &Value, key: &str, label: &
} }
} }
fn push_task_placement_reasons(lines: &mut Vec<String>, tasks: &[Value]) {
for task in tasks {
let Some(placement) = task.get("node_placement") else {
continue;
};
let Some(reasons) = placement.get("reasons").and_then(Value::as_array) else {
continue;
};
let reasons = reasons.iter().filter_map(Value::as_str).collect::<Vec<_>>();
if reasons.is_empty() {
continue;
}
let task_name = task
.get("task")
.and_then(Value::as_str)
.unwrap_or("unknown");
let node = placement
.get("node")
.and_then(Value::as_str)
.unwrap_or("unknown");
lines.push(format!(
"placement {task_name}: {node} ({})",
reasons.join(", ")
));
}
}
fn push_nested_string_field(lines: &mut Vec<String>, value: &Value, key: &str, label: &str) { fn push_nested_string_field(lines: &mut Vec<String>, value: &Value, key: &str, label: &str) {
if let Some(text) = value.get(key).and_then(Value::as_str) { if let Some(text) = value.get(key).and_then(Value::as_str) {
lines.push(format!("{label}: {text}")); lines.push(format!("{label}: {text}"));
@ -2514,6 +2545,15 @@ fn task_summaries(task_events: Option<&Value>) -> Value {
let terminal_state = let terminal_state =
event_string(event, "terminal_state").unwrap_or_else(|| "unknown".to_owned()); event_string(event, "terminal_state").unwrap_or_else(|| "unknown".to_owned());
let node = event_string(event, "node"); let node = event_string(event, "node");
let placement = event.get("placement");
let placement_reasons = placement
.and_then(|placement| placement.get("reasons"))
.cloned()
.unwrap_or_else(|| json!([]));
let placement_score = placement
.and_then(|placement| placement.get("score"))
.cloned()
.unwrap_or(Value::Null);
json!({ json!({
"process": event_string(event, "process"), "process": event_string(event, "process"),
"task": task, "task": task,
@ -2523,6 +2563,9 @@ fn task_summaries(task_events: Option<&Value>) -> Value {
"node_placement": { "node_placement": {
"node": node, "node": node,
"source": "coordinator_task_event", "source": "coordinator_task_event",
"score": placement_score,
"reasons": placement_reasons,
"explanation_available": placement.is_some(),
}, },
"failure_reason": task_failure_reason(event), "failure_reason": task_failure_reason(event),
"stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0), "stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0),
@ -5345,7 +5388,7 @@ mod tests {
let server = std::thread::spawn(move || { let server = std::thread::spawn(move || {
let response = concat!( let response = concat!(
r#"{"type":"task_events","events":["#, r#"{"type":"task_events","events":["#,
r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-a","task":"task-a","terminal_state":"completed","status_code":0,"stdout_bytes":12,"stderr_bytes":0,"stdout_tail":"ok","stderr_tail":"","stdout_truncated":false,"stderr_truncated":false,"artifact_path":"/vfs/artifacts/app.txt","artifact_digest":"sha256:artifact","artifact_size_bytes":12},"#, r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-a","task":"task-a","placement":{"node":"node-a","score":120,"reasons":["warm environment cache","source snapshot already local"]},"terminal_state":"completed","status_code":0,"stdout_bytes":12,"stderr_bytes":0,"stdout_tail":"ok","stderr_tail":"","stdout_truncated":false,"stderr_truncated":false,"artifact_path":"/vfs/artifacts/app.txt","artifact_digest":"sha256:artifact","artifact_size_bytes":12},"#,
r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-b","task":"task-b","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":7,"stdout_tail":"","stderr_tail":"boom","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null}"#, r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-b","task":"task-b","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":7,"stdout_tail":"","stderr_tail":"boom","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null}"#,
r#"]}"# r#"]}"#
); );
@ -5397,6 +5440,14 @@ mod tests {
process["current_tasks"][0]["node_placement"]["node"], process["current_tasks"][0]["node_placement"]["node"],
"node-a" "node-a"
); );
assert_eq!(
process["current_tasks"][0]["node_placement"]["reasons"][0],
"warm environment cache"
);
assert_eq!(tasks["tasks"][0]["node_placement"]["score"], 120);
let rendered_tasks = human_report(&tasks);
assert!(rendered_tasks.contains("placement task-a: node-a"));
assert!(rendered_tasks.contains("source snapshot already local"));
assert_eq!(tasks["tasks"][1]["failure_reason"], "boom"); assert_eq!(tasks["tasks"][1]["failure_reason"], "boom");
assert_eq!(logs["log_entries"].as_array().unwrap().len(), 1); assert_eq!(logs["log_entries"].as_array().unwrap().len(), 1);
assert_eq!(logs["log_entries"][0]["task"], "task-a"); assert_eq!(logs["log_entries"][0]["task"], "task-a");

View file

@ -410,6 +410,8 @@ pub struct TaskCompletionEvent {
pub process: ProcessId, pub process: ProcessId,
pub node: NodeId, pub node: NodeId,
pub task: TaskId, pub task: TaskId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<Placement>,
pub terminal_state: TaskTerminalState, pub terminal_state: TaskTerminalState,
pub status_code: Option<i32>, pub status_code: Option<i32>,
pub stdout_bytes: u64, pub stdout_bytes: u64,
@ -717,6 +719,7 @@ pub struct CoordinatorService {
task_events: Vec<TaskCompletionEvent>, task_events: Vec<TaskCompletionEvent>,
debug_audit_events: Vec<DebugAuditEvent>, debug_audit_events: Vec<DebugAuditEvent>,
task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>, task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>,
task_placements: BTreeMap<TaskControlKey, Placement>,
active_tasks: BTreeSet<TaskControlKey>, active_tasks: BTreeSet<TaskControlKey>,
task_cancellations: BTreeSet<TaskControlKey>, task_cancellations: BTreeSet<TaskControlKey>,
process_cancellations: BTreeSet<ProcessControlKey>, process_cancellations: BTreeSet<ProcessControlKey>,
@ -745,6 +748,7 @@ impl CoordinatorService {
task_events: Vec::new(), task_events: Vec::new(),
debug_audit_events: Vec::new(), debug_audit_events: Vec::new(),
task_assignments: BTreeMap::new(), task_assignments: BTreeMap::new(),
task_placements: BTreeMap::new(),
active_tasks: BTreeSet::new(), active_tasks: BTreeSet::new(),
task_cancellations: BTreeSet::new(), task_cancellations: BTreeSet::new(),
process_cancellations: BTreeSet::new(), process_cancellations: BTreeSet::new(),
@ -1199,6 +1203,10 @@ impl CoordinatorService {
.retain(|(task_tenant, task_project, _, task_node, _)| { .retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node task_tenant != &tenant || task_project != &project || task_node != &node
}); });
self.task_placements
.retain(|(task_tenant, task_project, _, task_node, _), _| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.coordinator.persist(&mut self.store); self.coordinator.persist(&mut self.store);
Ok(CoordinatorResponse::NodeCredentialRevoked { Ok(CoordinatorResponse::NodeCredentialRevoked {
node, node,
@ -1325,13 +1333,10 @@ impl CoordinatorService {
command_args, command_args,
artifact_path, artifact_path,
}; };
self.active_tasks.insert(task_control_key( let task_key =
&tenant, task_control_key(&tenant, &project, &process, &placement.node, &task);
&project, self.active_tasks.insert(task_key.clone());
&process, self.task_placements.insert(task_key, placement.clone());
&placement.node,
&task,
));
self.task_assignments self.task_assignments
.entry((tenant, project, placement.node.clone())) .entry((tenant, project, placement.node.clone()))
.or_default() .or_default()
@ -1520,6 +1525,13 @@ impl CoordinatorService {
|| task_project != &project || task_project != &project
|| task_process != &process || task_process != &process
}); });
self.task_placements.retain(
|(task_tenant, task_project, task_process, _, _), _| {
task_tenant != &tenant
|| task_project != &project
|| task_process != &process
},
);
self.task_assignments.retain(|_, assignments| { self.task_assignments.retain(|_, assignments| {
assignments.retain(|assignment| { assignments.retain(|assignment| {
assignment.tenant != tenant assignment.tenant != tenant
@ -1913,12 +1925,13 @@ impl CoordinatorService {
.map(VfsPath::new) .map(VfsPath::new)
.transpose() .transpose()
.map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?; .map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?;
let event = TaskCompletionEvent { let mut event = TaskCompletionEvent {
tenant: TenantId::new(tenant), tenant: TenantId::new(tenant),
project: ProjectId::new(project), project: ProjectId::new(project),
process: ProcessId::new(process), process: ProcessId::new(process),
node: NodeId::new(node), node: NodeId::new(node),
task: TaskId::new(task), task: TaskId::new(task),
placement: None,
terminal_state: terminal_state terminal_state: terminal_state
.unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)), .unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)),
status_code, status_code,
@ -1938,6 +1951,14 @@ impl CoordinatorService {
&event.project, &event.project,
&event.process, &event.process,
)?; )?;
let task_key = task_control_key(
&event.tenant,
&event.project,
&event.process,
&event.node,
&event.task,
);
event.placement = self.task_placements.remove(&task_key);
if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) { if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) {
self.artifact_registry.flush_metadata( self.artifact_registry.flush_metadata(
artifact_id_from_path(path), artifact_id_from_path(path),
@ -1950,20 +1971,8 @@ impl CoordinatorService {
artifact_size_bytes.unwrap_or(stdout_bytes), artifact_size_bytes.unwrap_or(stdout_bytes),
); );
} }
self.task_cancellations.remove(&task_control_key( self.task_cancellations.remove(&task_key);
&event.tenant, self.active_tasks.remove(&task_key);
&event.project,
&event.process,
&event.node,
&event.task,
));
self.active_tasks.remove(&task_control_key(
&event.tenant,
&event.project,
&event.process,
&event.node,
&event.task,
));
self.task_events.push(event.clone()); self.task_events.push(event.clone());
Ok(CoordinatorResponse::TaskRecorded { Ok(CoordinatorResponse::TaskRecorded {
process: event.process, process: event.process,
@ -4862,10 +4871,10 @@ mod tests {
project: "project".to_owned(), project: "project".to_owned(),
node: "worker-linux".to_owned(), node: "worker-linux".to_owned(),
capabilities: linux_capabilities(), capabilities: linux_capabilities(),
cached_environment_digests: Vec::new(), cached_environment_digests: vec![Digest::sha256("env")],
dependency_cache_digests: Vec::new(), dependency_cache_digests: vec![Digest::sha256("deps")],
source_snapshots: Vec::new(), source_snapshots: vec![Digest::sha256("source")],
artifact_locations: Vec::new(), artifact_locations: vec!["bootstrap-artifact".to_owned()],
direct_connectivity: true, direct_connectivity: true,
online: true, online: true,
}) })
@ -4884,6 +4893,13 @@ mod tests {
else { else {
panic!("expected coordinator-side process start"); panic!("expected coordinator-side process start");
}; };
service
.handle_request(CoordinatorRequest::ReconnectNode {
node: "worker-linux".to_owned(),
process: "vp-control".to_owned(),
epoch,
})
.unwrap();
let CoordinatorResponse::TaskLaunched { let CoordinatorResponse::TaskLaunched {
process, process,
@ -4901,11 +4917,11 @@ mod tests {
process: "vp-control".to_owned(), process: "vp-control".to_owned(),
task: "compile-linux".to_owned(), task: "compile-linux".to_owned(),
environment: None, environment: None,
environment_digest: None, environment_digest: Some(Digest::sha256("env")),
required_capabilities: vec![Capability::Command], required_capabilities: vec![Capability::Command],
dependency_cache: None, dependency_cache: Some(Digest::sha256("deps")),
source_snapshot: None, source_snapshot: Some(Digest::sha256("source")),
required_artifacts: Vec::new(), required_artifacts: vec!["bootstrap-artifact".to_owned()],
quota_available: true, quota_available: true,
policy_allowed: true, policy_allowed: true,
command: "cargo".to_owned(), command: "cargo".to_owned(),
@ -4919,6 +4935,14 @@ mod tests {
assert_eq!(process, ProcessId::from("vp-control")); assert_eq!(process, ProcessId::from("vp-control"));
assert_eq!(task, TaskId::from("compile-linux")); assert_eq!(task, TaskId::from("compile-linux"));
assert_eq!(placement.node, NodeId::from("worker-linux")); assert_eq!(placement.node, NodeId::from("worker-linux"));
assert!(placement
.reasons
.iter()
.any(|reason| reason.contains("environment")));
assert!(placement
.reasons
.iter()
.any(|reason| reason.contains("source")));
assert_eq!(assignment.node, NodeId::from("worker-linux")); assert_eq!(assignment.node, NodeId::from("worker-linux"));
assert_eq!(assignment.epoch, epoch); assert_eq!(assignment.epoch, epoch);
@ -4949,6 +4973,44 @@ mod tests {
panic!("expected empty worker assignment poll"); panic!("expected empty worker assignment poll");
}; };
assert!(assignment.is_none()); assert!(assignment.is_none());
service
.handle_request(CoordinatorRequest::TaskCompleted {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
process: "vp-control".to_owned(),
node: "worker-linux".to_owned(),
task: "compile-linux".to_owned(),
terminal_state: Some(TaskTerminalState::Completed),
status_code: Some(0),
stdout_bytes: 12,
stderr_bytes: 0,
stdout_tail: "ok".to_owned(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()),
artifact_digest: Some(Digest::sha256("artifact")),
artifact_size_bytes: Some(12),
})
.unwrap();
let CoordinatorResponse::TaskEvents { events } = service
.handle_request(CoordinatorRequest::ListTaskEvents {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: Some("vp-control".to_owned()),
})
.unwrap()
else {
panic!("expected task events");
};
let event_placement = events[0]
.placement
.as_ref()
.expect("task event should retain launch placement explanation");
assert_eq!(event_placement.node, NodeId::from("worker-linux"));
assert_eq!(event_placement.reasons, placement.reasons);
} }
#[test] #[test]

View file

@ -178,6 +178,11 @@ expect(
"coordinator task restart boundary coverage", "coordinator task restart boundary coverage",
/fn service_reports_task_restart_boundary_through_public_api\(\)/ /fn service_reports_task_restart_boundary_through_public_api\(\)/
); );
expect(
coordinator,
"coordinator task events retain placement reasons",
/pub struct TaskCompletionEvent[\s\S]*placement: Option<Placement>[\s\S]*task_placements[\s\S]*event\.placement = self\.task_placements\.remove/
);
expect( expect(
coordinator, coordinator,
"public debug operation audit event", "public debug operation audit event",
@ -203,6 +208,16 @@ expect(
"CLI exposes node attach grant disclosures", "CLI exposes node attach grant disclosures",
/struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/
); );
expect(
cli,
"CLI exposes task placement reasons",
/fn task_summaries[\s\S]*node_placement[\s\S]*reasons[\s\S]*explanation_available/
);
expect(
cli,
"CLI renders task placement reasons",
/fn push_task_placement_reasons[\s\S]*placement \{task_name\}: \{node\}/
);
expect( expect(
cli, cli,
"CLI parses dangerous capability overrides", "CLI parses dangerous capability overrides",