robonix_executor/dispatch/
async_poll.rs1use robonix_scribe::warn;
5use std::time::Duration;
6
7use crate::dispatch::{self, async_registry::AsyncGroup};
8use crate::pb::pilot::rtdl_node_state::RtdlNodeStateEnum;
9use crate::pb::pilot::{CapabilityCall, CapabilityCallResult};
10use crate::plan_runtime::{PlanRuntime, RunningAsyncCall};
11use crate::rtdl_wire::{self, NodeEventContext};
12use robonix_atlas::client::AtlasClient;
13use robonix_atlas::pb as atlas_pb;
14use tokio::time;
15
16const POLL_INTERVAL: Duration = Duration::from_secs(2);
17
18pub async fn run_until_terminal(
21 call: &CapabilityCall,
22 group: &AsyncGroup,
23 self_provider_id: &str,
24 atlas: &mut AtlasClient,
25 node: &NodeEventContext,
26 runtime: &PlanRuntime,
27) -> (CapabilityCallResult, u32) {
28 let initial = dispatch::dispatch(call, self_provider_id, atlas, runtime, &node.plan_id).await;
29 if !initial.success {
30 return (initial, RtdlNodeStateEnum::Failed as u32);
31 }
32
33 let run_id = extract_run_id(&initial.output);
34 let accepted = runtime
35 .register_or_cancel_async_call(
36 &node.plan_id,
37 RunningAsyncCall {
38 call_id: call.call_id.clone(),
39 provider_id: call.provider_id.clone(),
40 cancel_contract: group.cancel_contract.clone(),
41 run_id: run_id.clone(),
42 },
43 self_provider_id,
44 atlas,
45 )
46 .await;
47 if !accepted {
48 let result = canceled_result(call, "plan was cancelled before async polling began");
49 return (result, RtdlNodeStateEnum::Canceled as u32);
50 }
51
52 let mut interval = time::interval(POLL_INTERVAL);
53 interval.tick().await;
54
55 loop {
56 interval.tick().await;
57 if runtime.is_cancelled(&node.plan_id).await {
58 runtime
59 .cancel_async_call_for_plan(&node.plan_id, &call.call_id, self_provider_id, atlas)
60 .await;
61 let result = canceled_result(call, "plan was cancelled");
62 return (result, RtdlNodeStateEnum::Canceled as u32);
63 }
64 let status_out = match poll_status(
65 self_provider_id,
66 &call.provider_id,
67 &group.status_contract,
68 &run_id,
69 atlas,
70 )
71 .await
72 {
73 Ok(s) => s,
74 Err(e) => {
75 let error = format!("status poll failed for {}: {e:#}", call.contract_id);
76 warn!("[executor] {error}");
77 let result = failed_result(call, &error);
78 runtime
79 .unregister_async_call(&node.plan_id, &call.call_id)
80 .await;
81 return (result, RtdlNodeStateEnum::Failed as u32);
82 }
83 };
84
85 let (state, detail) = parse_status_json(&status_out);
86 if rtdl_wire::is_terminal_state(state) {
87 let result = terminal_result(call, state, &detail, &status_out);
88 runtime
89 .unregister_async_call(&node.plan_id, &call.call_id)
90 .await;
91 return (result, state);
92 }
93 runtime
96 .record_op_state(&node.plan_id, &node.op_id, state)
97 .await;
98 }
99}
100
101async fn poll_status(
102 consumer_id: &str,
103 provider_id: &str,
104 status_contract: &str,
105 run_id: &str,
106 atlas: &mut AtlasClient,
107) -> anyhow::Result<String> {
108 let args = if run_id.is_empty() {
109 "{}".to_string()
110 } else {
111 serde_json::json!({ "run_id": run_id }).to_string()
112 };
113 let status_call = CapabilityCall {
114 call_id: format!("status-{}", uuid::Uuid::new_v4()),
115 provider_id: provider_id.to_string(),
116 contract_id: status_contract.to_string(),
117 args_json: args,
118 };
119 let (channel_id, endpoint, _) = atlas
120 .connect_capability(
121 consumer_id,
122 provider_id,
123 status_contract,
124 atlas_pb::Transport::Mcp,
125 )
126 .await?;
127 let result = crate::dispatch::mcp::execute(&status_call, &endpoint).await;
128 let _ = atlas.disconnect_capability(&channel_id).await;
129 if result.success {
130 Ok(result.output)
131 } else {
132 anyhow::bail!("{}", result.error)
133 }
134}
135
136pub fn extract_run_id(output: &str) -> String {
138 let Ok(v) = serde_json::from_str::<serde_json::Value>(output) else {
139 return String::new();
140 };
141 v.get("run_id")
142 .and_then(|x| x.as_str())
143 .unwrap_or_default()
144 .to_string()
145}
146
147pub fn parse_status_json(output: &str) -> (u32, String) {
149 let Ok(v) = serde_json::from_str::<serde_json::Value>(output) else {
150 warn!("[executor] status response is not valid JSON: {output}");
151 return (RtdlNodeStateEnum::Running as u32, output.to_string());
152 };
153
154 let Some(state_str) = v.get("state").and_then(|s| s.as_str()) else {
155 let error = format!("status response missing required 'state' field: {output}");
156 warn!("[executor] {error}");
157 return (RtdlNodeStateEnum::Failed as u32, error);
158 };
159
160 let detail = v
161 .get("detail")
162 .and_then(|x| x.as_str())
163 .unwrap_or_default()
164 .to_string();
165
166 match parse_state_name(state_str) {
167 Some(state) => (state, detail),
168 None => {
169 let error = format!("status response has unknown state '{state_str}': {output}");
170 warn!("[executor] {error}");
171 (RtdlNodeStateEnum::Failed as u32, error)
172 }
173 }
174}
175
176pub fn parse_state_name(s: &str) -> Option<u32> {
178 match s.to_uppercase().as_str() {
179 "PENDING" => Some(RtdlNodeStateEnum::Pending as u32),
180 "RUNNING" => Some(RtdlNodeStateEnum::Running as u32),
181 "SUCCEEDED" => Some(RtdlNodeStateEnum::Succeeded as u32),
182 "FAILED" => Some(RtdlNodeStateEnum::Failed as u32),
183 "CANCELED" | "CANCELLED" => Some(RtdlNodeStateEnum::Canceled as u32),
184 "TIMEOUT" => Some(RtdlNodeStateEnum::Timeout as u32),
185 "PAUSED" => Some(RtdlNodeStateEnum::Paused as u32),
186 _ => None,
187 }
188}
189
190fn terminal_result(
191 call: &CapabilityCall,
192 state: u32,
193 detail: &str,
194 raw: &str,
195) -> CapabilityCallResult {
196 let success = state == RtdlNodeStateEnum::Succeeded as u32;
197 CapabilityCallResult {
198 call_id: call.call_id.clone(),
199 provider_id: call.provider_id.clone(),
200 contract_id: call.contract_id.clone(),
201 success,
202 output: if success {
203 raw.to_string()
204 } else {
205 String::new()
206 },
207 error: if success {
208 String::new()
209 } else {
210 detail.to_string()
211 },
212 }
213}
214
215fn canceled_result(call: &CapabilityCall, error: &str) -> CapabilityCallResult {
216 failed_result(call, error)
217}
218
219fn failed_result(call: &CapabilityCall, error: &str) -> CapabilityCallResult {
223 CapabilityCallResult {
224 call_id: call.call_id.clone(),
225 provider_id: call.provider_id.clone(),
226 contract_id: call.contract_id.clone(),
227 success: false,
228 output: String::new(),
229 error: error.to_string(),
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn extract_run_id_from_response() {
239 assert_eq!(extract_run_id(r#"{"run_id":"r1","accepted":true}"#), "r1");
240 assert_eq!(extract_run_id(r#"{"goal_id":"g1"}"#), "");
241 }
242
243 #[test]
244 fn parse_status_requires_state_field() {
245 let (s, d) = parse_status_json(r#"{"state":"SUCCEEDED","detail":"done"}"#);
246 assert_eq!(s, RtdlNodeStateEnum::Succeeded as u32);
247 assert_eq!(d, "done");
248 }
249
250 #[test]
251 fn parse_status_missing_state_fails() {
252 let (s, d) = parse_status_json(r#"{"known":true,"terminal":false}"#);
253 assert_eq!(s, RtdlNodeStateEnum::Failed as u32);
254 assert!(d.contains("missing required 'state' field"));
255 }
256}