Skip to main content

agent_engine/tools/subagent/
start.rs

1//! SubagentStartTool — dispatch a reactive subagent and return a handle_id immediately.
2//!
3//! Unlike the one-shot `subagent` tool, this tool returns *before* the subagent
4//! finishes. The caller gets a `handle_id` they can poll via `subagent_status`,
5//! steer via `subagent_steer`, or block on via `subagent_collect`.
6
7use serde_json::{json, Value};
8use std::sync::atomic::Ordering;
9use std::sync::{Arc, RwLock};
10use tokio::sync::{mpsc, oneshot};
11
12use super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
13use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
14use crate::{AgentEvent, Result, RuntimeError};
15
16pub struct SubagentStartTool;
17
18#[async_trait::async_trait]
19impl Tool for SubagentStartTool {
20    fn origin(&self) -> crate::tools::ToolOrigin {
21        crate::tools::ToolOrigin::Builtin
22    }
23
24    fn name(&self) -> &str {
25        "subagent_start"
26    }
27
28    fn description(&self) -> &str {
29        "Dispatch a reactive subagent and return immediately with a handle_id. \
30         The subagent runs in the background and is REACTIVE: when it finishes, a \
31         completion event is pushed into your queue and you are woken with its \
32         status — do NOT sleep, busy-wait, or poll in a loop waiting for it. After \
33         dispatching, end your turn or keep doing other work; on the completion \
34         event call subagent_collect once with reconciled=true. subagent_status is \
35         for an occasional progress peek, and subagent_steer injects guidance \
36         mid-run. Use this for parallel execution or when you want to continue \
37         working while the subagent runs. For simple sequential delegation that \
38         must block, use subagent instead. Provide either an agent name (resolves \
39         from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly; \
40         give an inline subagent a short name so it is recognisable in the \
41         subagent panel."
42    }
43
44    fn parameters(&self) -> Value {
45        json!({
46            "type": "object",
47            "properties": {
48                "agent": {
49                    "type": "string",
50                    "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
51                },
52                "system_prompt": {
53                    "type": "string",
54                    "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
55                },
56                "name": {
57                    "type": "string",
58                    "description": "Short display name for an inline (system_prompt) subagent, e.g. \"gif-recorder\". Shown in the subagent panel and in status/collect output. Letters, digits, - and _ only, max 32. Ignored when agent is set; defaults to \"inline\"."
59                },
60                "task": {
61                    "type": "string",
62                    "description": "The task/prompt to send to the subagent."
63                },
64                "model": {
65                    "type": "string",
66                    "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."
67                },
68                "thinking": {
69                    "type": "string",
70                    "enum": ["low", "medium", "high", "xhigh", "max"],
71                    "description": "Worker effort override. Omit for subagent_thinking (default xhigh), independent of foreground thinking. Unsupported model levels are refused."
72                },
73                "timeout": {
74                    "type": "integer",
75                    "description": "Wall-clock limit in seconds. Omit for the session default (config `subagent_timeout`); 0 = no limit. A timed-out worker keeps its full conversation: subagent_resume continues it where it stopped."
76                }
77            },
78            "required": ["task"]
79        })
80    }
81
82    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
83        // One config snapshot for the whole spawn (`spawn_config`).
84        let config = super::spawn_config();
85        let thinking = super::resolve_subagent_thinking(&params, None, &config)?;
86        // ── Parse params ───────────────────────────────────────────────────────
87        let task = params["task"]
88            .as_str()
89            .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
90            .to_string();
91
92        // Treat blank / whitespace / control-char strings as absent.
93        // Some model providers serialize "unset" as "" rather than omitting the field,
94        // and we must not try to resolve "" (or " ", or "\u{0}") as an agent name —
95        // doing so produces a hard "Agent '' not found" error that the model then
96        // retries forever with sentinel values instead of falling back to system_prompt.
97        let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
98        let agent_name = params["agent"]
99            .as_str()
100            .map(|s| s.to_string())
101            .filter(|s| !is_blank(s));
102        let inline_prompt = params["system_prompt"]
103            .as_str()
104            .map(|s| s.to_string())
105            .filter(|s| !is_blank(s));
106        let requested_model = params["model"].as_str();
107        // Validate the registry before authorization, id allocation, or event emission;
108        // the same borrow is reused below when publishing the authorized handle.
109        let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
110            RuntimeError::Tool("subagent_start requires a subagent_registry in ToolContext".into())
111        })?;
112        let system_prompt = match (&agent_name, &inline_prompt) {
113            (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
114            (None, Some(p)) => p.clone(),
115            (None, None) => {
116                return Err(RuntimeError::Tool(
117                    "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
118                        .to_string(),
119                ));
120            }
121        };
122        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
123        let handle_id = format!("sa_{}", subagent_id);
124        let orchestration = ctx
125            .capabilities
126            .orchestration
127            .as_ref()
128            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
129        orchestration
130            .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
131            .map_err(|reason| RuntimeError::Tool(format!("delegation denied: {reason}")))?;
132        let decision = orchestration
133            .resolve_and_authorize(&handle_id, requested_model)
134            .map_err(|error| {
135                orchestration
136                    .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
137                RuntimeError::Tool(error.to_string())
138            })?;
139        let model = decision.model.as_str().to_owned();
140        let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
141        let memory_backend = ctx.capabilities.memory_backend.clone();
142        let session_allow_all = ctx.capabilities.session_allow_all.clone();
143        let telemetry = ctx.capabilities.telemetry.clone();
144        let workspace = super::WorkerWorkspace::from_capabilities(&ctx.capabilities);
145        let timeout_secs = params["timeout"]
146            .as_u64()
147            .unwrap_or(ctx.limits.subagent_timeout);
148
149        let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
150        let telemetry = telemetry.map(|t| t.for_worker(&label, &handle_id));
151        let task_preview: String = task.chars().take(80).collect();
152        let task_full = task.clone();
153
154        tracing::info!(
155            "subagent_start: dispatching '{}' (id={}) model={}",
156            label,
157            handle_id,
158            model
159        );
160
161        // ── Shared state ───────────────────────────────────────────────────────
162        let state = Arc::new(RwLock::new(SubagentState::new()));
163        state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
164            agent_name: label.clone(),
165            model: model.clone(),
166            system_prompt: system_prompt.clone(),
167            timeout_secs,
168            cwd: workspace
169                .cwd
170                .clone()
171                .or_else(|| std::env::current_dir().ok()),
172            thinking: Some(thinking.as_str().into()),
173        });
174
175        state.write().unwrap().archive_dir = Some(super::archive::process_dir());
176        let initial_messages = vec![Arc::new(
177            serde_json::json!({"role": "user", "content": task.clone()}),
178        )];
179        state.write().unwrap().history = Some(initial_messages.clone());
180
181        // ── Channels ───────────────────────────────────────────────────────────
182        let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
183        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
184        let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
185
186        // ── Forward SubagentStart event to TUI ─────────────────────────────────
187        if let Some(ref tx) = ctx.channels.tx_events {
188            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
189                subagent_id,
190                agent_name: label.clone(),
191                task_preview: task_preview.clone(),
192            }));
193        }
194
195        // ── Clone state for the spawned thread ─────────────────────────────────
196        let state_t = Arc::clone(&state);
197        let task_full_a = task_full.clone();
198        let label_inner = label.clone();
199        let model_inner = model.clone();
200        let tx_events_inner = ctx.channels.tx_events.clone();
201        let start_time = std::time::Instant::now();
202        let parent_queue = ctx.capabilities.event_queue.clone();
203        let handle_id_inner = handle_id.clone();
204        let child_parent_id = handle_id.clone();
205
206        // ── Build and register handle BEFORE spawning ─────────────────────────
207        // This closes the publish-before-register race: finalize_subagent can
208        // push a completion event the instant the thread exits, but the registry
209        // must already contain the handle so the parent's collect succeeds.
210        let system_prompt_for_handle = system_prompt.clone();
211        let handle = SubagentHandle::new(
212            handle_id.clone(),
213            subagent_id,
214            label.clone(),
215            task_preview,
216            model.clone(),
217            system_prompt_for_handle,
218            timeout_secs,
219            Arc::clone(&state),
220            Some(steer_tx),
221            Some(shutdown_tx),
222            Some(result_rx),
223        )
224        .with_authorization(&decision);
225        {
226            let mut reg = registry.lock().unwrap();
227            reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
228        }
229
230        let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
231        let orchestration_for_worker = Arc::clone(orchestration);
232        let parent_for_worker = ctx.capabilities.delegation_parent.clone();
233        if let Err(error) = orchestration.mark_starting(&handle_id) {
234            orchestration.rollback(&handle_id);
235            orchestration
236                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
237            return Err(RuntimeError::Tool(error));
238        }
239
240        // ── Spawn subagent thread (mirrors subagent.rs) ────────────────────────
241        let thread_handle = std::thread::spawn(move || {
242            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
243            let state_for_finalizer = Arc::clone(&state_t);
244            let label_for_finalizer = label_inner.clone();
245            let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
246
247            let mut pending_result = None;
248            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
249                let rt = match tokio::runtime::Builder::new_current_thread()
250                    .enable_all()
251                    .build()
252                {
253                    Ok(rt) => rt,
254                    Err(_) => {
255                        state_t.write().unwrap().status =
256                            SubagentStatus::Failed("runtime initialization failed".into());
257                        return;
258                    }
259                };
260
261                // Clones for the async block — the outer closure still needs the originals.
262                let state_a = Arc::clone(&state_t);
263                let label_a = label_inner.clone();
264                let model_a = model_inner.clone();
265                let tx_events_a = tx_events_inner.clone();
266                let task_for_timeout = task_full_a.clone();
267                let task_for_complete = task_full_a;
268
269                let outcome: std::result::Result<SubagentResult, String> =
270                    rt.block_on(async move {
271                        // Host-built worker (shared client/creds/token cache, cached
272                        // registry) or the legacy fresh runtime — see `spawn_runtime`.
273                        let mut runtime = match super::spawn_runtime().await {
274                            Ok(r) => r,
275                            Err(_) => return Err("subagent runtime initialization failed".into()),
276                        };
277
278                        // Apply subagent spawn policy: worker role, 5m cache TTL, worker
279                        // turn budget. Subagents are short-lived one-shots — paying the 1h
280                        // write premium (~2× input price) on them is unrecoverable waste
281                        // (~$0.23 per 10-spawn fan-out). (#110)
282                        super::apply_subagent_runtime_policy(
283                            &mut runtime,
284                            &config,
285                            memory_backend.as_ref(),
286                            session_allow_all.as_ref(),
287                            telemetry.as_ref(),
288                            &workspace,
289                        );
290                        runtime.set_system_prompt(super::compose_system_prompt(system_prompt));
291                        runtime.set_model(model_a.clone());
292                        super::fit_subagent_context_window(&mut runtime);
293                        super::apply_codex_worker_reasoning(
294                            &mut runtime,
295                            codex_parent_plan.as_ref(),
296                        );
297                        super::apply_subagent_thinking(&mut runtime, thinking)?;
298                        runtime
299                            .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
300                        runtime.set_delegation_parent(Some(child_parent_id.clone()));
301                        // Rolled-over context goes beside this worker's
302                        // resume archive (see `worker_context`).
303                        let archive_dir = state_a
304                            .read()
305                            .unwrap_or_else(|p| p.into_inner())
306                            .archive_dir
307                            .clone()
308                            .unwrap_or_else(super::archive::process_dir);
309                        super::attach_worker_transcripts(
310                            &mut runtime,
311                            archive_dir,
312                            &child_parent_id,
313                            Some(Arc::clone(&state_a)),
314                            &initial_messages,
315                            tx_events_a.clone(),
316                            subagent_id,
317                            &label_a,
318                        );
319
320                        let cancel = crate::CancellationToken::new();
321                        let cancel_inner = cancel.clone();
322                        tokio::spawn(async move {
323                            let _ = shutdown_rx.await;
324                            cancel_inner.cancel();
325                        });
326
327                        let cancel_on_timeout = cancel.clone();
328                        let stream = runtime.run_stream_with_messages(
329                                initial_messages,
330                                cancel,
331                                Some(steer_rx),
332                                None,
333                                false,
334                            ).await;
335
336                        let drive_outcome = super::drive::WorkerDrive {
337                            state: Arc::clone(&state_a),
338                            tx_events: tx_events_a.clone(),
339                            subagent_id,
340                            label: label_a.clone(),
341                            model: model_a.clone(),
342                            timeout_secs,
343                            cancel: cancel_on_timeout,
344                            task: task_for_timeout,
345                        }
346                        .run(stream)
347                        .await;
348                        // Date this run's cache for a later resume (cold check).
349                        super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
350                        drive_outcome
351                    });
352
353                match outcome {
354                    Ok(sa_result) => {
355                        // Only overwrite Running → Completed (don't stomp TimedOut or a cancellation).
356                        {
357                            let mut s = state_t.write().unwrap();
358                            if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
359                                s.status = SubagentStatus::Completed;
360                                s.conversation_state = vec![
361                                    serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
362                                    serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
363                                ];
364                            }
365                        }
366                        pending_result = Some(sa_result);
367                    }
368                    Err(e) => {
369                        state_t.write().unwrap().status = SubagentStatus::Failed(e);
370                    }
371                }
372            }));
373
374            if let Err(panic_info) = panic_result {
375                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
376                    s.to_string()
377                } else if let Some(s) = panic_info.downcast_ref::<String>() {
378                    s.clone()
379                } else {
380                    "unknown panic".to_string()
381                };
382                tracing::error!("Subagent thread panicked: {}", msg);
383                state_t.write().unwrap_or_else(|p| p.into_inner()).status =
384                    SubagentStatus::Failed(format!("panic: {}", msg));
385            }
386
387            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
388            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
389            super::finalize::finalize_subagent(
390                &state_for_finalizer,
391                parent_queue.as_ref(),
392                &handle_id_inner,
393                subagent_id,
394                &label_for_finalizer,
395                start_time,
396                None, // start.rs: not a resume
397            );
398            // A cancelled worker publishes no wake, so nothing would ever
399            // prompt a collect: retire it before any terminal publication.
400            super::finalize::retire_if_cancelled(
401                &state_for_finalizer,
402                &orchestration_for_worker,
403                &handle_id_inner,
404            );
405            // All terminal publications follow the recovery/finalization barrier.
406            if let Some(ref tx) = tx_events_inner {
407                let s = state_for_finalizer
408                    .read()
409                    .unwrap_or_else(|p| p.into_inner());
410                let preview = match s.status.failure_reason() {
411                    Some(reason) => format!("ERROR: {reason}"),
412                    None => s.partial_text.chars().take(120).collect(),
413                };
414                let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
415                    subagent_id,
416                    agent_name: label_for_finalizer.clone(),
417                    result_preview: preview,
418                    duration_secs: start_time.elapsed().as_secs_f64(),
419                }));
420            }
421            if let Some(result) = pending_result {
422                let _ = result_tx.send(result);
423            }
424            orchestration_for_worker
425                .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
426        });
427
428        // ── Attach the already-persisted handle to the started thread ──────────
429        {
430            let mut reg = registry.lock().unwrap();
431            if let Some(h) = reg.get_mut(&handle_id) {
432                h.set_thread_handle(thread_handle);
433            }
434        }
435        if let Err(error) = ctx
436            .capabilities
437            .orchestration
438            .as_ref()
439            .unwrap()
440            .mark_running(&handle_id)
441        {
442            let thread = {
443                let mut reg = registry.lock().unwrap();
444                if let Some(handle) = reg.get_mut(&handle_id) {
445                    handle.cancel();
446                }
447                reg.remove(&handle_id)
448            };
449            if let Some(handle) = thread {
450                let _ = handle.collect().await;
451            }
452            orchestration.rollback(&handle_id);
453            orchestration
454                .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
455            return Err(RuntimeError::Tool(error));
456        }
457        // The thread may have been cancelled and finalized before it was
458        // marked running; its own retire attempt was then too early.
459        super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
460
461        Ok(json!({
462            "handle_id":  handle_id,
463            "agent_name": label,
464            "status":     "running"
465        })
466        .to_string())
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::tools::test_helpers::create_tool_context;
474    use crate::tools::{SubagentRegistry, Tool};
475    use serde_json::json;
476    use std::sync::{Arc, Mutex};
477
478    // V4: registry=None must return Err immediately, before any thread is spawned.
479    #[tokio::test]
480    async fn test_subagent_start_no_registry_returns_err() {
481        let tool = SubagentStartTool;
482        let ctx = create_tool_context(); // subagent_registry is None by default
483
484        let params = json!({
485            "system_prompt": "You are a test subagent.",
486            "task": "Say ok",
487        });
488
489        let result = tool.execute(params, ctx).await;
490        assert!(
491            result.is_err(),
492            "missing registry must return Err before spawn"
493        );
494        let msg = format!("{:?}", result.unwrap_err());
495        assert!(
496            msg.contains("subagent_registry"),
497            "error must mention subagent_registry: {msg}"
498        );
499    }
500
501    /// These tests launch a real worker thread. Run unpinned, it read the
502    /// developer's real `auth.json` (and could reach the provider), and wrote
503    /// `logs/subagents` and `subagent-history` under the real `~/.synaps-cli`,
504    /// after the test returned. Pin an empty base dir and clear the
505    /// environment's provider credentials/endpoints (`OfflineProviderEnv`), so
506    /// the worker fails at auth, offline; keep both until it has exited.
507    fn join_worker(registry: &Mutex<SubagentRegistry>, handle_id: &str) {
508        let thread = registry
509            .lock()
510            .unwrap()
511            .get_mut(handle_id)
512            .and_then(|h| h.thread_handle.take());
513        if let Some(thread) = thread {
514            let _ = thread.join();
515        }
516    }
517
518    #[tokio::test]
519    #[serial_test::serial(synaps_base_dir)]
520    async fn test_subagent_start_blank_agent_uses_system_prompt() {
521        let _base = crate::test_env::BaseDirGuard::new();
522        let _offline = crate::test_env::OfflineProviderEnv::new();
523        let tool = SubagentStartTool;
524        let mut ctx = create_tool_context();
525        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
526        ctx.capabilities.subagent_registry = Some(registry.clone());
527
528        let params = json!({
529            "agent": "",
530            "system_prompt": "You are a concise test subagent. Reply with only: ok",
531            "task": "Say ok",
532            "model": "anthropic/claude-sonnet-4-6",
533            "timeout": 1
534        });
535
536        let result = tool.execute(params, ctx).await;
537        assert!(
538            result.is_ok(),
539            "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
540        );
541        let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
542        assert_eq!(body["agent_name"], "inline");
543        let handle_id = body["handle_id"].as_str().unwrap_or_default();
544        assert!(handle_id.starts_with("sa_"));
545        join_worker(&registry, handle_id);
546    }
547
548    #[tokio::test]
549    #[serial_test::serial(synaps_base_dir)]
550    async fn test_subagent_start_inline_name_labels_the_handle() {
551        let _base = crate::test_env::BaseDirGuard::new();
552        let _offline = crate::test_env::OfflineProviderEnv::new();
553        let tool = SubagentStartTool;
554        let mut ctx = create_tool_context();
555        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
556        ctx.capabilities.subagent_registry = Some(registry.clone());
557
558        let params = json!({
559            "system_prompt": "You are a concise test subagent. Reply with only: ok",
560            "name": "gif recorder",
561            "task": "Say ok",
562            "model": "anthropic/claude-sonnet-4-6",
563            "timeout": 1
564        });
565
566        let result = tool.execute(params, ctx).await.expect("dispatch");
567        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
568        assert_eq!(body["agent_name"], "gif-recorder");
569        let handle_id = body["handle_id"].as_str().unwrap();
570        {
571            let reg = registry.lock().unwrap();
572            assert_eq!(
573                reg.get(handle_id).expect("registered").agent_name,
574                "gif-recorder"
575            );
576        }
577        join_worker(&registry, handle_id);
578    }
579
580    /// `role` and `write_policy` are no longer advertised (they were never
581    /// read). A caller that still sends them is not refused, and the worker
582    /// is dispatched exactly as without them: as an implementer.
583    #[tokio::test]
584    #[serial_test::serial(synaps_base_dir)]
585    async fn test_subagent_start_ignores_retired_role_and_write_policy() {
586        let _base = crate::test_env::BaseDirGuard::new();
587        let _offline = crate::test_env::OfflineProviderEnv::new();
588        let tool = SubagentStartTool;
589        let mut ctx = create_tool_context();
590        let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
591        ctx.capabilities.subagent_registry = Some(registry.clone());
592        let orchestration = Arc::clone(ctx.capabilities.orchestration.as_ref().unwrap());
593
594        let params = json!({
595            "system_prompt": "You are a concise test subagent. Reply with only: ok",
596            "task": "Say ok",
597            "model": "anthropic/claude-sonnet-4-6",
598            "timeout": 1,
599            "role": "reviewer",
600            "write_policy": {"mode": "read_only"}
601        });
602
603        let result = tool
604            .execute(params, ctx)
605            .await
606            .expect("old params must be ignored, not refused");
607        let body: serde_json::Value = serde_json::from_str(&result).unwrap();
608        assert_eq!(body["status"], "running");
609        assert_eq!(body["agent_name"], "inline");
610        let handle_id = body["handle_id"].as_str().unwrap();
611        assert!(registry.lock().unwrap().get(handle_id).is_some());
612
613        let events: serde_json::Value =
614            serde_json::from_str(&orchestration.telemetry_json()).unwrap();
615        let dispatch = events
616            .as_array()
617            .unwrap()
618            .iter()
619            .find(|event| event["name"] == "worker.dispatch_allowed")
620            .expect("dispatch recorded");
621        assert_eq!(dispatch["worker_role"], "implementer");
622        join_worker(&registry, handle_id);
623    }
624}