返回 CodeWhale
executor.rs
根目录 / crates / tui / src / fleet / executor.rs
1 //! Fleet executor — runs a fleet worker as a real `codewhale exec` subprocess.
2 //!
3 //! A fleet worker IS a headless `codewhale exec` run. There is no separate
4 //! "fleet worker" execution engine: the sub-agent runtime, full tool surface,
5 //! and recursion depth all come from the one `codewhale exec` runtime, so
6 //! fleet and sub-agents are one substrate (not two moving targets).
7 //!
8 //! This module is the bridge:
9 //! - [`build_worker_exec_command`] turns a `FleetTaskSpec` + `FleetExecConfig`
10 //! into the `codewhale [route flags] exec --output-format stream-json …`
11 //! argv that a host adapter ([`super::host`]) launches locally or over SSH.
12 //! - [`map_exec_stream_line`] maps one stream-json line emitted by that worker
13 //! into a [`FleetWorkerEventPayload`] for the durable ledger, so the ledger
14 //! persists the worker's own event vocabulary instead of a simulated one.
15 //! - [`classify_worker_exit`] turns the process exit into a terminal event.
16 //!
17 //! The TUI/CLI/Runtime API observe the ledger's compact event stream — they
18 //! never render a child session, which is what keeps the orchestrator light at
19 //! high fanout.
20
21 #![allow(dead_code)]
22
23 use anyhow::Result;
24 use codewhale_config::FleetExecConfig;
25 use codewhale_protocol::fleet::{FleetHostSpec, FleetTaskSpec, FleetWorkerEventPayload};
26
27 use super::host::{FleetHostAdapter, FleetWorkerCommand};
28 use super::profile::AgentProfile;
29 use super::worker_runtime::{
30 fleet_task_prompt, fleet_task_prompt_with_profiles, fleet_worker_launch_reasoning_effort,
31 fleet_worker_launch_route,
32 };
33 use crate::tools::spec::{ToolAuthorityEnvelope, ToolMutationAuthority};
34 use crate::tools::subagent::AgentWorkerSpec;
35
36 /// Resolve the executable used for Fleet worker subprocesses.
37 ///
38 /// Kept here so every long-lived surface (CLI and Runtime API) launches the
39 /// same configured worker binary instead of silently diverging.
40 pub fn configured_codewhale_binary() -> String {
41 std::env::var("CODEWHALE_FLEET_CODEWHALE_BINARY")
42 .ok()
43 .map(|value| value.trim().to_string())
44 .filter(|value| !value.is_empty())
45 .unwrap_or_else(|| "codewhale".to_string())
46 }
47
48 /// Build the `codewhale exec` argv that runs a fleet task headlessly.
49 ///
50 /// `--auto` is always passed: a headless worker has no human to approve tool
51 /// calls, so it runs with full (policy-gated) tool access. `--output-format
52 /// stream-json` makes the worker emit the NDJSON event stream this module
53 /// parses. A worker launched with the v0.9.1 machine-readable outer authority
54 /// cap is a truthful leaf (`max_spawn_depth = 0`): the nested-agent surface is
55 /// disabled until authority scopes can be intersected across
56 /// process/workspace boundaries.
57 ///
58 /// Secrets are NEVER placed on the argv: provider credentials are resolved by
59 /// the worker process from its own config/keyring exactly like an interactive
60 /// run. The host adapter additionally refuses secret-bearing env keys. The
61 /// `--provider` flag threaded by [`build_worker_exec_command_with_profiles`] is
62 /// a non-secret provider *identifier* only (#4093) — the worker still resolves
63 /// that provider's credentials from its own env/config, so this invariant
64 /// holds.
65 pub fn build_worker_exec_command(
66 codewhale_binary: &str,
67 task_spec: &FleetTaskSpec,
68 exec_config: &FleetExecConfig,
69 model: Option<&str>,
70 ) -> FleetWorkerCommand {
71 build_worker_exec_command_from_prompt(
72 codewhale_binary,
73 fleet_task_prompt(task_spec),
74 exec_config,
75 model,
76 None,
77 None,
78 None,
79 )
80 }
81
82 /// Build a worker command after resolving workspace Fleet profile input.
83 ///
84 /// The launched subprocess runs on the worker's RESOLVED route, not blindly on
85 /// the run-level session model (#4093 AC #4): the per-worker model+provider are
86 /// resolved from the task's agent profile via the same explicit-only path the
87 /// receipt uses ([`fleet_worker_launch_route`]). A worker whose profile pins
88 /// provider B thus launches on provider B's model even when the parent session
89 /// is on provider A. Workers with no profile-bound provider fall back to the
90 /// run-level model and emit no `--provider`, so the worker keeps its own
91 /// session default (today's behavior, unchanged).
92 pub fn build_worker_exec_command_with_profiles(
93 codewhale_binary: &str,
94 task_spec: &FleetTaskSpec,
95 exec_config: &FleetExecConfig,
96 model: Option<&str>,
97 agent_profiles: &[AgentProfile],
98 ) -> Result<FleetWorkerCommand> {
99 let (worker_model, worker_provider) =
100 fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default());
101 let worker_reasoning_effort = fleet_worker_launch_reasoning_effort(task_spec, agent_profiles);
102 Ok(build_worker_exec_command_from_prompt(
103 codewhale_binary,
104 fleet_task_prompt_with_profiles(task_spec, agent_profiles)?,
105 exec_config,
106 Some(worker_model.as_str()),
107 worker_provider.as_deref(),
108 worker_reasoning_effort.as_deref(),
109 None,
110 ))
111 }
112
113 /// Build the exact Fleet subprocess command from the coordination-registered
114 /// worker spec. Unlike the compatibility helpers above, production dispatch
115 /// uses the projected objective and carries a machine-readable outer authority
116 /// envelope into the child process.
117 pub fn build_worker_exec_command_with_launch_spec(
118 codewhale_binary: &str,
119 task_spec: &FleetTaskSpec,
120 launch_spec: &AgentWorkerSpec,
121 exec_config: &FleetExecConfig,
122 model: Option<&str>,
123 agent_profiles: &[AgentProfile],
124 ) -> Result<FleetWorkerCommand> {
125 let (worker_model, worker_provider) =
126 fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default());
127 let worker_reasoning_effort = fleet_worker_launch_reasoning_effort(task_spec, agent_profiles);
128 let authority = authority_envelope_for_worker(launch_spec, task_spec)?;
129 Ok(build_worker_exec_command_from_prompt(
130 codewhale_binary,
131 launch_spec.objective.clone(),
132 exec_config,
133 Some(worker_model.as_str()),
134 worker_provider.as_deref(),
135 worker_reasoning_effort.as_deref(),
136 Some(&authority),
137 ))
138 }
139
140 pub(crate) fn authority_envelope_for_worker(
141 spec: &AgentWorkerSpec,
142 task_spec: &FleetTaskSpec,
143 ) -> Result<ToolAuthorityEnvelope> {
144 let (authority, writable_roots, writable_files, coordination_contracts) =
145 if spec.runtime_profile.permissions.write {
146 let manifest = spec.launch_manifest.as_ref().ok_or_else(|| {
147 anyhow::anyhow!(
148 "write-capable Fleet worker '{}' has no launch manifest",
149 spec.worker_id
150 )
151 })?;
152 (
153 ToolMutationAuthority::ScopedWrite,
154 super::worker_runtime::fleet_runtime_write_roots(task_spec)?,
155 manifest.writable_files.clone(),
156 manifest.coordination_contracts.clone(),
157 )
158 } else {
159 (
160 ToolMutationAuthority::ReadOnly,
161 Vec::new(),
162 Vec::new(),
163 Vec::new(),
164 )
165 };
166 ToolAuthorityEnvelope {
167 schema_version: 1,
168 owner: spec.worker_id.clone(),
169 authority,
170 network_access: Some(spec.runtime_profile.permissions.network),
171 writable_roots,
172 writable_files,
173 coordination_contracts,
174 }
175 .normalized()
176 .map_err(anyhow::Error::msg)
177 }
178
179 fn build_worker_exec_command_from_prompt(
180 codewhale_binary: &str,
181 task_prompt: String,
182 exec_config: &FleetExecConfig,
183 model: Option<&str>,
184 provider: Option<&str>,
185 reasoning_effort: Option<&str>,
186 authority: Option<&ToolAuthorityEnvelope>,
187 ) -> FleetWorkerCommand {
188 let mut args: Vec<String> = Vec::new();
189
190 // The canonical `codewhale` dispatcher owns these route overrides as
191 // global flags and deliberately rejects them after `exec`. Keep them in
192 // front of the subcommand so Fleet commands work through the installed
193 // dispatcher as well as when a host points directly at `codewhale-tui`.
194 if let Some(model) = model.map(str::trim).filter(|m| !m.is_empty()) {
195 args.push("--model".to_string());
196 args.push(model.to_string());
197 }
198
199 // Non-secret provider identifier only (#4093): the worker resolves the
200 // provider's credentials from its own env/config. Emitted ONLY when the
201 // worker's profile explicitly pins a provider, so profile-less workers keep
202 // their own session default exactly as before.
203 if let Some(provider) = provider.map(str::trim).filter(|p| !p.is_empty()) {
204 args.push("--provider".to_string());
205 args.push(provider.to_string());
206 }
207
208 args.extend([
209 "exec".to_string(),
210 "--auto".to_string(),
211 "--output-format".to_string(),
212 "stream-json".to_string(),
213 ]);
214
215 // Non-secret thinking tier only (#4137). This is profile metadata and
216 // follows the same explicit-only policy as provider: omit it when the
217 // worker profile inherits the session/default reasoning setting.
218 if let Some(reasoning_effort) = reasoning_effort.map(str::trim).filter(|e| !e.is_empty()) {
219 args.push("--reasoning-effort".to_string());
220 args.push(reasoning_effort.to_string());
221 }
222
223 if !exec_config.allowed_tools.is_empty() {
224 args.push("--allowed-tools".to_string());
225 args.push(exec_config.allowed_tools.join(","));
226 }
227 if !exec_config.disallowed_tools.is_empty() {
228 args.push("--disallowed-tools".to_string());
229 args.push(exec_config.disallowed_tools.join(","));
230 }
231 if exec_config.max_turns > 0 {
232 args.push("--max-turns".to_string());
233 args.push(exec_config.max_turns.to_string());
234 }
235 if !exec_config.append_system_prompt.trim().is_empty() {
236 args.push("--append-system-prompt".to_string());
237 args.push(exec_config.append_system_prompt.clone());
238 }
239
240 if let Some(authority) = authority {
241 args.push("--tool-authority-json".to_string());
242 args.push(
243 serde_json::to_string(authority)
244 .expect("validated Fleet tool authority envelope must serialize"),
245 );
246 }
247
248 // The composed task prompt is the final positional argument.
249 args.push(task_prompt);
250
251 FleetWorkerCommand::new(codewhale_binary.to_string(), args)
252 }
253
254 /// Map one `codewhale exec` stream-json line into a fleet ledger event.
255 ///
256 /// Returns `None` for lines that don't correspond to a worker lifecycle
257 /// transition (e.g. `session_capture`, `metadata`). The exec event schema is
258 /// `{"type": "...", ...}` (see `ExecStreamEvent` in `main.rs`).
259 pub fn map_exec_stream_line(line: &str) -> Option<FleetWorkerEventPayload> {
260 let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
261 match value.get("type").and_then(serde_json::Value::as_str)? {
262 "tool_use" => {
263 let tool = value
264 .get("name")
265 .and_then(serde_json::Value::as_str)
266 .unwrap_or("tool")
267 .to_string();
268 let call_id = value
269 .get("id")
270 .and_then(serde_json::Value::as_str)
271 .map(str::to_string);
272 Some(FleetWorkerEventPayload::RunningTool { tool, call_id })
273 }
274 "workflow_event" => Some(FleetWorkerEventPayload::WorkflowEvent {
275 workflow_run_id: value.get("run_id")?.as_str()?.to_string(),
276 event: value.get("event")?.clone(),
277 }),
278 // Streaming model output / tool results / per-step usage receipts mean
279 // the worker is alive and making progress; surface a coarse Running
280 // heartbeat. `turn_usage` covers thinking-heavy model calls that
281 // produce no visible content between tool calls.
282 "content" | "tool_result" | "turn_usage" => Some(FleetWorkerEventPayload::Running),
283 "done" => Some(FleetWorkerEventPayload::Completed {
284 exit_code: Some(0),
285 summary: None,
286 }),
287 "error" => {
288 let reason = value
289 .get("error")
290 .and_then(serde_json::Value::as_str)
291 .unwrap_or("worker reported an error")
292 .to_string();
293 Some(FleetWorkerEventPayload::Failed {
294 reason,
295 recoverable: false,
296 })
297 }
298 _ => None,
299 }
300 }
301
302 #[derive(Debug)]
303 enum ParsedTerminalRoute {
304 NotTerminal,
305 Valid(FleetWorkerReportedRoute),
306 Invalid,
307 }
308
309 /// Parse one allowlisted, secret-free route identity from terminal exec
310 /// metadata. Once a line declares itself as a terminal receipt, malformed
311 /// route fields are distinct from ordinary non-terminal stream noise so a
312 /// prior valid record cannot survive contradictory evidence.
313 fn parse_exec_terminal_route(line: &str) -> ParsedTerminalRoute {
314 let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
315 return ParsedTerminalRoute::NotTerminal;
316 };
317 if value.get("type").and_then(serde_json::Value::as_str) != Some("metadata") {
318 return ParsedTerminalRoute::NotTerminal;
319 }
320 let Some(meta) = value.get("meta").and_then(serde_json::Value::as_object) else {
321 return ParsedTerminalRoute::NotTerminal;
322 };
323 if meta.get("receipt_kind").and_then(serde_json::Value::as_str) != Some("terminal") {
324 return ParsedTerminalRoute::NotTerminal;
325 }
326
327 let route = (|| {
328 let provider = meta.get("provider")?.as_str()?.trim();
329 let model = meta.get("model")?.as_str()?.trim();
330 if provider.is_empty() || model.is_empty() {
331 return None;
332 }
333 let provider_kind = crate::config::ApiProvider::parse(provider)?;
334 let provider_exact_id = match meta.get("provider_id") {
335 None => None,
336 Some(value) => {
337 let id = value.as_str()?.trim();
338 if id.is_empty() {
339 return None;
340 }
341 Some(id.to_string())
342 }
343 };
344 if provider_exact_id.is_some() && provider_kind != crate::config::ApiProvider::Custom {
345 return None;
346 }
347 Some(FleetWorkerReportedRoute {
348 provider: provider.to_string(),
349 provider_exact_id,
350 model: model.to_string(),
351 })
352 })();
353
354 route.map_or(ParsedTerminalRoute::Invalid, ParsedTerminalRoute::Valid)
355 }
356
357 #[cfg(test)]
358 fn map_exec_terminal_route(line: &str) -> Option<FleetWorkerReportedRoute> {
359 match parse_exec_terminal_route(line) {
360 ParsedTerminalRoute::Valid(route) => Some(route),
361 ParsedTerminalRoute::NotTerminal | ParsedTerminalRoute::Invalid => None,
362 }
363 }
364
365 /// Classify a worker process exit into a terminal fleet event.
366 ///
367 /// `stopped` means the operator stopped the worker (cancellation), which takes
368 /// precedence over the exit code.
369 pub fn classify_worker_exit(exit_code: Option<i32>, stopped: bool) -> FleetWorkerEventPayload {
370 if stopped {
371 return FleetWorkerEventPayload::Cancelled { cancelled_by: None };
372 }
373 match exit_code {
374 Some(0) => FleetWorkerEventPayload::Completed {
375 exit_code: Some(0),
376 summary: None,
377 },
378 Some(code) => FleetWorkerEventPayload::Failed {
379 reason: format!("worker exited with code {code}"),
380 recoverable: true,
381 },
382 None => FleetWorkerEventPayload::Failed {
383 reason: "worker exited without a status code".to_string(),
384 recoverable: true,
385 },
386 }
387 }
388
389 /// Drives fleet workers as real `codewhale exec` subprocesses on the local
390 /// host, incrementally draining each worker's stream-json output into fleet
391 /// ledger events.
392 ///
393 /// The caller (the `codewhale fleet run` loop / `FleetManager`) owns the
394 /// ledger; the executor owns the OS process boundary and the incremental log
395 /// parse. Because the worker is a separate process, its heavy runtime/tool
396 /// construction never touches the orchestrator — the parent only ingests a
397 /// compact event stream, which is what keeps it light at high fanout.
398 pub struct FleetExecutor {
399 workspace: std::path::PathBuf,
400 adapter: super::host::LocalProcessFleetHostAdapter,
401 ssh_adapters: std::collections::BTreeMap<String, super::host::SshFleetHostAdapter>,
402 streams: std::collections::BTreeMap<String, WorkerStream>,
403 }
404
405 /// Durable lease identity owned by one concrete host process.
406 #[derive(Debug, Clone, PartialEq, Eq)]
407 pub struct FleetExecutorAttempt {
408 pub run_id: codewhale_protocol::fleet::FleetRunId,
409 pub task_id: String,
410 pub attempt: u32,
411 }
412
413 struct WorkerStream {
414 log_path: std::path::PathBuf,
415 host: WorkerStreamHost,
416 attempt: Option<FleetExecutorAttempt>,
417 offset: u64,
418 // Keep incomplete stream frames as bytes. Decoding each read separately
419 // corrupts valid UTF-8 when a multibyte code point crosses a read boundary.
420 pending: Vec<u8>,
421 terminal: bool,
422 terminal_route: TerminalRouteEvidence,
423 }
424
425 #[derive(Debug, Clone, Default)]
426 enum TerminalRouteEvidence {
427 #[default]
428 Missing,
429 Valid(FleetWorkerReportedRoute),
430 InvalidOrAmbiguous,
431 }
432
433 impl TerminalRouteEvidence {
434 fn observe(&mut self, parsed: ParsedTerminalRoute) {
435 match parsed {
436 ParsedTerminalRoute::NotTerminal => {}
437 ParsedTerminalRoute::Invalid => *self = Self::InvalidOrAmbiguous,
438 ParsedTerminalRoute::Valid(route) => {
439 *self = if matches!(&*self, Self::Missing) {
440 Self::Valid(route)
441 } else {
442 // The stream contract emits exactly one terminal receipt.
443 // Any second record, even an identical one, is ambiguous
444 // provenance and must permanently fail closed.
445 Self::InvalidOrAmbiguous
446 };
447 }
448 }
449 }
450
451 fn reported_route(&self) -> Option<&FleetWorkerReportedRoute> {
452 match self {
453 Self::Valid(route) => Some(route),
454 Self::Missing | Self::InvalidOrAmbiguous => None,
455 }
456 }
457 }
458
459 fn observe_worker_stream_line(
460 terminal_route: &mut TerminalRouteEvidence,
461 line: &[u8],
462 ) -> Option<FleetWorkerEventPayload> {
463 let Ok(line) = std::str::from_utf8(line) else {
464 // stream-json is a UTF-8 contract. Never accept a lossy-decoded route
465 // receipt: replacement characters could turn corrupt provider/model
466 // bytes into apparently valid provenance.
467 terminal_route.observe(ParsedTerminalRoute::Invalid);
468 return None;
469 };
470 let line = line.trim_end();
471 terminal_route.observe(parse_exec_terminal_route(line));
472 map_exec_stream_line(line)
473 }
474
475 enum WorkerStreamHost {
476 Local,
477 Ssh(String),
478 }
479
480 #[derive(Debug, Clone)]
481 pub struct FleetWorkerReportedRoute {
482 pub provider: String,
483 pub provider_exact_id: Option<String>,
484 pub model: String,
485 }
486
487 #[derive(Debug, Clone)]
488 pub struct FleetWorkerTerminalEvent {
489 pub payload: FleetWorkerEventPayload,
490 pub exit_code: Option<i32>,
491 /// Non-terminal payloads discovered by the mandatory post-exit drain.
492 pub tail_payloads: Vec<FleetWorkerEventPayload>,
493 pub reported_route: Option<FleetWorkerReportedRoute>,
494 /// A real headless exec process must report its actual route. Callers use
495 /// this bit to distinguish a missing/invalid report (fail closed) from
496 /// pre-launch or simulated paths that only have declared route intent.
497 pub requires_reported_route: bool,
498 }
499
500 impl FleetExecutor {
501 pub fn new(workspace: impl AsRef<std::path::Path>) -> Self {
502 let workspace = workspace.as_ref().to_path_buf();
503 Self {
504 adapter: super::host::LocalProcessFleetHostAdapter::new(&workspace),
505 workspace,
506 ssh_adapters: std::collections::BTreeMap::new(),
507 streams: std::collections::BTreeMap::new(),
508 }
509 }
510
511 /// Start a worker process and begin tracking its event stream.
512 pub fn start_worker(
513 &mut self,
514 worker_id: &str,
515 command: FleetWorkerCommand,
516 cwd: Option<std::path::PathBuf>,
517 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
518 self.start_worker_on_host(worker_id, &FleetHostSpec::Local, command, cwd)
519 }
520
521 /// Start a worker on the requested fleet host.
522 pub fn start_worker_on_host(
523 &mut self,
524 worker_id: &str,
525 host: &FleetHostSpec,
526 command: FleetWorkerCommand,
527 cwd: Option<std::path::PathBuf>,
528 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
529 self.start_worker_on_host_inner(worker_id, host, command, cwd, None)
530 }
531
532 /// Start the concrete process for one exact durable Fleet lease.
533 pub fn start_worker_attempt_on_host(
534 &mut self,
535 worker_id: &str,
536 host: &FleetHostSpec,
537 command: FleetWorkerCommand,
538 cwd: Option<std::path::PathBuf>,
539 attempt: FleetExecutorAttempt,
540 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
541 self.start_worker_on_host_inner(worker_id, host, command, cwd, Some(attempt))
542 }
543
544 fn start_worker_on_host_inner(
545 &mut self,
546 worker_id: &str,
547 host: &FleetHostSpec,
548 command: FleetWorkerCommand,
549 cwd: Option<std::path::PathBuf>,
550 attempt: Option<FleetExecutorAttempt>,
551 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
552 let mut request = super::host::FleetWorkerStartRequest::new(worker_id, command);
553 request.cwd = cwd;
554 let (handle, host) = match host {
555 FleetHostSpec::Local => {
556 let handle = self.adapter.start_worker(request)?;
557 (handle, WorkerStreamHost::Local)
558 }
559 FleetHostSpec::Ssh { .. } => {
560 let config = super::host::SshFleetHostConfig::from_host_spec(host)?;
561 let key = worker_id.to_string();
562 let adapter = self.ssh_adapters.entry(key.clone()).or_insert(
563 super::host::SshFleetHostAdapter::new(&self.workspace, config)?,
564 );
565 let handle = adapter.start_worker(request)?;
566 (handle, WorkerStreamHost::Ssh(key))
567 }
568 FleetHostSpec::Docker { image, .. } => {
569 return Err(super::host::FleetHostError {
570 kind: super::host::FleetHostErrorKind::Configuration,
571 message: format!("docker fleet workers are not wired yet (image {image})"),
572 });
573 }
574 };
575 self.streams.insert(
576 worker_id.to_string(),
577 WorkerStream {
578 log_path: handle.log_path.clone(),
579 host,
580 attempt,
581 offset: 0,
582 pending: Vec::new(),
583 terminal: false,
584 terminal_route: TerminalRouteEvidence::default(),
585 },
586 );
587 Ok(handle)
588 }
589
590 pub fn is_tracking(&self, worker_id: &str) -> bool {
591 self.streams.contains_key(worker_id)
592 }
593
594 pub fn worker_ids(&self) -> Vec<String> {
595 self.streams.keys().cloned().collect()
596 }
597
598 pub fn tracked_attempt(&self, worker_id: &str) -> Option<FleetExecutorAttempt> {
599 self.streams
600 .get(worker_id)
601 .and_then(|stream| stream.attempt.clone())
602 }
603
604 /// Stop a tracked worker at the host boundary.
605 ///
606 /// Operator controls run in a separate process from the foreground Fleet
607 /// manager, so they communicate cancellation through the durable ledger.
608 /// The manager calls this method after observing that terminal state; the
609 /// executor is the only owner that can reliably reach the live local/SSH
610 /// adapter handle.
611 pub fn stop_worker(&mut self, worker_id: &str) -> Result<()> {
612 let ssh_key = match self.streams.get(worker_id).map(|stream| &stream.host) {
613 Some(WorkerStreamHost::Local) => None,
614 Some(WorkerStreamHost::Ssh(key)) => Some(key.clone()),
615 None => return Ok(()),
616 };
617 if let Some(key) = ssh_key {
618 let adapter = self.ssh_adapters.get_mut(&key).ok_or_else(|| {
619 anyhow::anyhow!("tracked SSH Fleet worker {worker_id} has no host adapter")
620 })?;
621 adapter.stop_worker(worker_id)?;
622 } else {
623 self.adapter.stop_worker(worker_id)?;
624 }
625 Ok(())
626 }
627
628 /// Stop tracking a terminal worker so the scheduler can reuse the same
629 /// logical worker id for the next queued task.
630 pub fn forget_worker(&mut self, worker_id: &str) {
631 let Some(stream) = self.streams.remove(worker_id) else {
632 return;
633 };
634 match stream.host {
635 WorkerStreamHost::Local => {
636 let _ = self.adapter.cleanup_worker(worker_id);
637 }
638 WorkerStreamHost::Ssh(key) => {
639 if let Some(adapter) = self.ssh_adapters.get_mut(&key) {
640 let _ = adapter.cleanup_worker(worker_id);
641 }
642 self.ssh_adapters.remove(&key);
643 }
644 }
645 }
646
647 /// Read any newly-written stream-json lines for a worker and map them to
648 /// fleet ledger events. Safe to call repeatedly; only new bytes are parsed,
649 /// and a trailing partial line is buffered until its newline arrives.
650 pub fn drain_events(&mut self, worker_id: &str) -> Vec<FleetWorkerEventPayload> {
651 let Some(stream) = self.streams.get_mut(worker_id) else {
652 return Vec::new();
653 };
654 let mut events = Vec::new();
655 let Ok(mut file) = std::fs::File::open(&stream.log_path) else {
656 return events;
657 };
658 use std::io::{Read, Seek, SeekFrom};
659 if file.seek(SeekFrom::Start(stream.offset)).is_err() {
660 return events;
661 }
662 let mut buf = Vec::new();
663 if let Ok(read) = file.read_to_end(&mut buf) {
664 stream.offset += read as u64;
665 stream.pending.extend_from_slice(&buf);
666 while let Some(idx) = stream.pending.iter().position(|byte| *byte == b'\n') {
667 let line: Vec<u8> = stream.pending.drain(..=idx).collect();
668 if let Some(event) = observe_worker_stream_line(&mut stream.terminal_route, &line) {
669 events.push(event);
670 }
671 }
672 }
673 events
674 }
675
676 /// Poll the worker process; once it exits, return the terminal event exactly
677 /// once. Returns `None` while the worker is still running or already
678 /// finalized.
679 pub fn poll_terminal(&mut self, worker_id: &str) -> Option<FleetWorkerEventPayload> {
680 self.poll_terminal_with_status(worker_id)
681 .map(|event| event.payload)
682 }
683
684 /// Poll the worker process and include the raw exit code for receipt
685 /// verification.
686 pub fn poll_terminal_with_status(
687 &mut self,
688 worker_id: &str,
689 ) -> Option<FleetWorkerTerminalEvent> {
690 if self.streams.get(worker_id).is_none_or(|s| s.terminal) {
691 return None;
692 }
693 let status = match self.streams.get(worker_id).map(|s| &s.host)? {
694 WorkerStreamHost::Local => self.adapter.read_status(worker_id).ok()?,
695 WorkerStreamHost::Ssh(key) => self
696 .ssh_adapters
697 .get_mut(key)
698 .and_then(|adapter| adapter.read_status(worker_id).ok())?,
699 };
700 let terminal = match status.state {
701 super::host::FleetHostWorkerState::Running
702 | super::host::FleetHostWorkerState::Draining
703 | super::host::FleetHostWorkerState::Unknown => return None,
704 super::host::FleetHostWorkerState::Stopped => {
705 classify_worker_exit(status.exit_code, true)
706 }
707 super::host::FleetHostWorkerState::Exited
708 | super::host::FleetHostWorkerState::Failed => {
709 classify_worker_exit(status.exit_code, false)
710 }
711 };
712 // Once status is terminal the worker can no longer append. Drain one
713 // final time before snapshotting route evidence so metadata written
714 // between the scheduler's ordinary drain and this status poll cannot
715 // be lost when the worker is forgotten.
716 let mut tail_payloads = self.drain_events(worker_id);
717 if let Some(stream) = self.streams.get_mut(worker_id) {
718 let trailing_line = std::mem::take(&mut stream.pending);
719 if trailing_line.iter().any(|byte| !byte.is_ascii_whitespace())
720 && let Some(payload) =
721 observe_worker_stream_line(&mut stream.terminal_route, &trailing_line)
722 {
723 tail_payloads.push(payload);
724 }
725 }
726 if let Some(stream) = self.streams.get_mut(worker_id) {
727 stream.terminal = true;
728 }
729 Some(FleetWorkerTerminalEvent {
730 payload: terminal,
731 exit_code: status.exit_code,
732 tail_payloads,
733 reported_route: self
734 .streams
735 .get(worker_id)
736 .and_then(|stream| stream.terminal_route.reported_route().cloned()),
737 requires_reported_route: true,
738 })
739 }
740
741 /// True once every started worker has reached a terminal state.
742 pub fn all_terminal(&self) -> bool {
743 !self.streams.is_empty() && self.streams.values().all(|s| s.terminal)
744 }
745 }
746
747 #[cfg(test)]
748 mod tests {
749 use super::*;
750 use codewhale_config::{
751 FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole,
752 FleetSlot,
753 };
754 use codewhale_protocol::fleet::{
755 FleetHostSpec, FleetTaskSpec, FleetTaskWorkerProfile, FleetWorkerSpec,
756 FleetWorkspaceRequirements,
757 };
758 use std::collections::BTreeMap;
759 use tempfile::TempDir;
760
761 fn task(instructions: &str) -> FleetTaskSpec {
762 FleetTaskSpec {
763 id: "t1".to_string(),
764 name: "Smoke".to_string(),
765 description: None,
766 objective: Some("prove it runs".to_string()),
767 instructions: instructions.to_string(),
768 worker: Some(FleetTaskWorkerProfile {
769 agent_profile: None,
770 role: Some("reviewer".to_string()),
771 loadout: None,
772 model_class: None,
773 model: None,
774 tool_profile: Some("read-only".to_string()),
775 tools: vec![],
776 capabilities: vec![],
777 }),
778 workspace: None,
779 input_files: vec![],
780 context: vec![],
781 budget: None,
782 tags: vec![],
783 expected_artifacts: vec![],
784 scorer: None,
785 retry_policy: None,
786 alert_policy: None,
787 timeout_seconds: None,
788 metadata: BTreeMap::new(),
789 }
790 }
791
792 fn agent_profile(id: &str, role: &str, instructions: &str) -> AgentProfile {
793 AgentProfile {
794 id: id.to_string(),
795 display_name: Some(format!("{role} profile")),
796 description: Some(format!("{role} description")),
797 profile: FleetProfile {
798 slot: FleetSlot::from_name(role),
799 role: FleetRole {
800 name: role.to_string(),
801 description: None,
802 instructions: Some(instructions.to_string()),
803 },
804 loadout: FleetLoadout::Inherit,
805 model: None,
806 provider: None,
807 reasoning_effort: None,
808 permissions: FleetProfilePermissions::default(),
809 delegation: FleetDelegationHints::default(),
810 },
811 source: std::path::PathBuf::from(format!("{id}.toml")),
812 origin: crate::fleet::roster::ProfileOrigin::Workspace,
813 }
814 }
815
816 fn launch_spec(task: &FleetTaskSpec, workspace: &std::path::Path) -> AgentWorkerSpec {
817 let worker = FleetWorkerSpec {
818 id: "worker-1".to_string(),
819 name: "Worker 1".to_string(),
820 host: FleetHostSpec::Local,
821 trust_level: None,
822 labels: BTreeMap::new(),
823 capabilities: Vec::new(),
824 max_concurrent_tasks: Some(1),
825 };
826 crate::fleet::worker_runtime::fleet_task_to_worker_spec_with_profiles(
827 "worker-1",
828 "run-1",
829 task,
830 &worker,
831 "auto",
832 workspace,
833 workspace,
834 &[],
835 None,
836 )
837 .unwrap()
838 }
839
840 fn track_test_stream(
841 executor: &mut FleetExecutor,
842 worker_id: &str,
843 log_path: std::path::PathBuf,
844 ) {
845 executor.streams.insert(
846 worker_id.to_string(),
847 WorkerStream {
848 log_path,
849 host: WorkerStreamHost::Local,
850 attempt: None,
851 offset: 0,
852 pending: Vec::new(),
853 terminal: false,
854 terminal_route: TerminalRouteEvidence::default(),
855 },
856 );
857 }
858
859 fn append_test_stream(path: &std::path::Path, bytes: &[u8]) {
860 use std::io::Write as _;
861
862 std::fs::OpenOptions::new()
863 .append(true)
864 .open(path)
865 .unwrap()
866 .write_all(bytes)
867 .unwrap();
868 }
869
870 #[test]
871 fn worker_command_is_a_headless_codewhale_exec_run() {
872 let exec = FleetExecConfig::default();
873 let cmd = build_worker_exec_command("codewhale", &task("read the file"), &exec, None);
874 assert_eq!(cmd.program, "codewhale");
875 assert_eq!(cmd.args[0], "exec");
876 assert!(cmd.args.contains(&"--auto".to_string()));
877 // stream-json so the executor can ingest the worker's event stream.
878 let joined = cmd.args.join(" ");
879 assert!(joined.contains("--output-format stream-json"));
880 // The task instructions ride in the positional prompt (last arg).
881 assert!(cmd.args.last().unwrap().contains("read the file"));
882 }
883
884 #[test]
885 fn worker_command_threads_exec_hardening_flags() {
886 let exec = FleetExecConfig {
887 allowed_tools: vec!["read_file".to_string(), "grep_files".to_string()],
888 disallowed_tools: vec!["exec_shell".to_string()],
889 max_turns: 40,
890 append_system_prompt: "never push to main".to_string(),
891 ..FleetExecConfig::default()
892 };
893 let cmd = build_worker_exec_command("codewhale", &task("audit"), &exec, Some("glm-5.1"));
894 let exec_idx = cmd
895 .args
896 .iter()
897 .position(|arg| arg == "exec")
898 .expect("worker command must contain exec");
899 let model_idx = cmd
900 .args
901 .iter()
902 .position(|arg| arg == "--model")
903 .expect("worker command must contain --model");
904 assert!(
905 model_idx < exec_idx,
906 "global --model must precede exec: {:?}",
907 cmd.args
908 );
909 let joined = cmd.args.join(" ");
910 assert!(joined.contains("--model glm-5.1"));
911 assert!(joined.contains("--allowed-tools read_file,grep_files"));
912 assert!(joined.contains("--disallowed-tools exec_shell"));
913 assert!(joined.contains("--max-turns 40"));
914 assert!(cmd.args.iter().any(|a| a == "never push to main"));
915 }
916
917 #[test]
918 fn worker_command_threads_agent_profile_prompt() {
919 let mut task = task("audit");
920 task.worker.as_mut().unwrap().agent_profile = Some("reviewer".to_string());
921 let cmd = build_worker_exec_command_with_profiles(
922 "codewhale",
923 &task,
924 &FleetExecConfig::default(),
925 None,
926 &[agent_profile(
927 "reviewer",
928 "reviewer",
929 "Focus on defects, regressions, and missing tests.",
930 )],
931 )
932 .unwrap();
933 let prompt = cmd.args.last().unwrap();
934
935 assert!(prompt.contains("Fleet profile: reviewer"));
936 assert!(prompt.contains("Focus on defects, regressions, and missing tests."));
937 }
938
939 #[test]
940 fn consultant_launch_spec_carries_network_off_with_read_only_authority() {
941 let tmp = TempDir::new().unwrap();
942 let mut task = task("advise on the release candidate");
943 task.worker.as_mut().unwrap().role = Some("consultant".to_string());
944 let launch_spec = launch_spec(&task, tmp.path());
945 assert_eq!(
946 launch_spec.agent_type,
947 crate::tools::subagent::FleetRole::Consultant
948 );
949 assert!(!launch_spec.runtime_profile.permissions.network);
950
951 let cmd = build_worker_exec_command_with_launch_spec(
952 "codewhale",
953 &task,
954 &launch_spec,
955 &FleetExecConfig::default(),
956 None,
957 &[],
958 )
959 .unwrap();
960
961 assert_eq!(cmd.args.last(), Some(&launch_spec.objective));
962 let authority_index = cmd
963 .args
964 .iter()
965 .position(|arg| arg == "--tool-authority-json")
966 .expect("launch command must carry machine-readable authority");
967 let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap();
968 assert_eq!(authority.owner, "worker-1");
969 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
970 assert_eq!(authority.network_access, Some(false));
971 assert!(authority.writable_roots.is_empty());
972 assert!(authority.writable_files.is_empty());
973 assert!(authority.coordination_contracts.is_empty());
974 }
975
976 #[test]
977 fn launch_spec_command_preserves_exact_write_scope() {
978 let tmp = TempDir::new().unwrap();
979 let mut task = task("edit the bounded source tree");
980 let worker = task.worker.as_mut().unwrap();
981 worker.role = Some("implementer".to_string());
982 worker.tool_profile = None;
983 task.workspace = Some(FleetWorkspaceRequirements {
984 writable_paths: vec![std::path::PathBuf::from("src")],
985 ..FleetWorkspaceRequirements::default()
986 });
987 let launch_spec = launch_spec(&task, tmp.path());
988
989 let cmd = build_worker_exec_command_with_launch_spec(
990 "codewhale",
991 &task,
992 &launch_spec,
993 &FleetExecConfig::default(),
994 None,
995 &[],
996 )
997 .unwrap();
998 let authority_index = cmd
999 .args
1000 .iter()
1001 .position(|arg| arg == "--tool-authority-json")
1002 .expect("launch command must carry machine-readable authority");
1003 let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap();
1004
1005 assert_eq!(authority.authority, ToolMutationAuthority::ScopedWrite);
1006 assert_eq!(authority.network_access, Some(true));
1007 assert_eq!(authority.writable_roots, ["src"]);
1008 assert!(authority.writable_files.is_empty());
1009 assert!(authority.coordination_contracts.is_empty());
1010 assert_eq!(cmd.args.last(), Some(&launch_spec.objective));
1011 }
1012
1013 /// #4093 AC #4 at the LAUNCH boundary (not just the receipt): a worker whose
1014 /// profile pins a DIFFERENT provider+model than the parent session must
1015 /// actually launch on the profile's route and saved reasoning tier. The
1016 /// parent session is DeepSeek here (`--model deepseek-v4-pro`); the profile
1017 /// pins OpenRouter + glm-5.2 + max thinking. The emitted argv must carry
1018 /// OpenRouter's id, the profile's model, and the profile's thinking tier as
1019 /// paired flag/values — never the parent's model. This is the gap the
1020 /// save→load→resolve receipt tests never covered.
1021 #[test]
1022 fn worker_command_launches_profile_bound_provider_and_model_not_the_parent() {
1023 let mut task = task("audit");
1024 task.worker.as_mut().unwrap().agent_profile = Some("cross".to_string());
1025
1026 let mut profile = agent_profile("cross", "scout", "Read first.");
1027 profile.profile.provider = Some("openrouter".to_string());
1028 profile.profile.model = Some("glm-5.2".to_string());
1029 profile.profile.reasoning_effort = Some("max".to_string());
1030
1031 let cmd = build_worker_exec_command_with_profiles(
1032 "codewhale",
1033 &task,
1034 &FleetExecConfig::default(),
1035 Some("deepseek-v4-pro"), // parent/session model on provider A.
1036 &[profile],
1037 )
1038 .unwrap();
1039
1040 // Assert the flag/value PAIRS, so the provider and model are proven to
1041 // ride together rather than merely appearing somewhere on the argv.
1042 let provider_idx = cmd
1043 .args
1044 .iter()
1045 .position(|a| a == "--provider")
1046 .expect("--provider must be threaded for a provider-pinned worker");
1047 let exec_idx = cmd
1048 .args
1049 .iter()
1050 .position(|a| a == "exec")
1051 .expect("worker command must contain exec");
1052 assert_eq!(
1053 cmd.args.get(provider_idx + 1).map(String::as_str),
1054 Some("openrouter"),
1055 "{:?}",
1056 cmd.args
1057 );
1058 assert!(
1059 provider_idx < exec_idx,
1060 "global --provider must precede exec: {:?}",
1061 cmd.args
1062 );
1063 let model_idx = cmd
1064 .args
1065 .iter()
1066 .position(|a| a == "--model")
1067 .expect("--model must be present");
1068 assert_eq!(
1069 cmd.args.get(model_idx + 1).map(String::as_str),
1070 Some("glm-5.2"),
1071 "{:?}",
1072 cmd.args
1073 );
1074 assert!(
1075 model_idx < exec_idx,
1076 "global --model must precede exec: {:?}",
1077 cmd.args
1078 );
1079 let reasoning_idx = cmd
1080 .args
1081 .iter()
1082 .position(|a| a == "--reasoning-effort")
1083 .expect("--reasoning-effort must be present for a thinking-pinned worker");
1084 assert_eq!(
1085 cmd.args.get(reasoning_idx + 1).map(String::as_str),
1086 Some("max"),
1087 "{:?}",
1088 cmd.args
1089 );
1090 assert!(
1091 reasoning_idx > exec_idx,
1092 "exec-only --reasoning-effort must follow exec: {:?}",
1093 cmd.args
1094 );
1095
1096 assert_eq!(
1097 &cmd.args[..exec_idx],
1098 ["--model", "glm-5.2", "--provider", "openrouter"],
1099 "route flags must form the complete global prefix: {:?}",
1100 cmd.args
1101 );
1102 assert_eq!(
1103 &cmd.args[exec_idx..exec_idx + 4],
1104 ["exec", "--auto", "--output-format", "stream-json"],
1105 "exec flags must remain behind the subcommand: {:?}",
1106 cmd.args
1107 );
1108
1109 // The parent/session model must NOT leak onto the argv.
1110 assert!(
1111 !cmd.args.iter().any(|a| a == "deepseek-v4-pro"),
1112 "parent model leaked into a profile-pinned worker's argv: {:?}",
1113 cmd.args
1114 );
1115 }
1116
1117 #[test]
1118 fn worker_command_threads_custom_profile_provider_name() {
1119 let mut task = task("format");
1120 task.worker.as_mut().unwrap().agent_profile = Some("local".to_string());
1121
1122 let mut profile = agent_profile("local", "formatter", "Keep edits tight.");
1123 profile.profile.provider = Some("lm-studio".to_string());
1124 profile.profile.model = Some("qwen-2.5-7b".to_string());
1125
1126 let cmd = build_worker_exec_command_with_profiles(
1127 "codewhale",
1128 &task,
1129 &FleetExecConfig::default(),
1130 Some("deepseek-v4-pro"),
1131 &[profile],
1132 )
1133 .unwrap();
1134
1135 let provider_idx = cmd
1136 .args
1137 .iter()
1138 .position(|a| a == "--provider")
1139 .expect("--provider must be threaded for a custom provider pin");
1140 assert_eq!(
1141 cmd.args.get(provider_idx + 1).map(String::as_str),
1142 Some("lm-studio"),
1143 "{:?}",
1144 cmd.args
1145 );
1146 let exec_idx = cmd
1147 .args
1148 .iter()
1149 .position(|a| a == "exec")
1150 .expect("worker command must contain exec");
1151 assert!(
1152 provider_idx < exec_idx,
1153 "global --provider must precede exec: {:?}",
1154 cmd.args
1155 );
1156 let model_idx = cmd
1157 .args
1158 .iter()
1159 .position(|a| a == "--model")
1160 .expect("--model must be present");
1161 assert_eq!(
1162 cmd.args.get(model_idx + 1).map(String::as_str),
1163 Some("qwen-2.5-7b"),
1164 "{:?}",
1165 cmd.args
1166 );
1167 assert!(
1168 model_idx < exec_idx,
1169 "global --model must precede exec: {:?}",
1170 cmd.args
1171 );
1172 }
1173
1174 /// A worker with no profile-bound provider preserves today's behavior: the
1175 /// run-level model on `--model`, and NO `--provider` (the worker keeps its
1176 /// own session default). Guards against regressing profile-less workers.
1177 #[test]
1178 fn worker_command_without_profile_provider_omits_provider_and_keeps_run_model() {
1179 let cmd = build_worker_exec_command_with_profiles(
1180 "codewhale",
1181 &task("read"),
1182 &FleetExecConfig::default(),
1183 Some("deepseek-v4-pro"),
1184 &[],
1185 )
1186 .unwrap();
1187
1188 assert!(
1189 !cmd.args.iter().any(|a| a == "--provider"),
1190 "profile-less worker must not carry --provider: {:?}",
1191 cmd.args
1192 );
1193 assert!(
1194 !cmd.args.iter().any(|a| a == "--reasoning-effort"),
1195 "profile-less worker must not carry --reasoning-effort: {:?}",
1196 cmd.args
1197 );
1198 let model_idx = cmd
1199 .args
1200 .iter()
1201 .position(|a| a == "--model")
1202 .expect("--model must be present");
1203 assert_eq!(
1204 cmd.args.get(model_idx + 1).map(String::as_str),
1205 Some("deepseek-v4-pro"),
1206 "{:?}",
1207 cmd.args
1208 );
1209 let exec_idx = cmd
1210 .args
1211 .iter()
1212 .position(|a| a == "exec")
1213 .expect("worker command must contain exec");
1214 assert!(
1215 model_idx < exec_idx,
1216 "global --model must precede exec: {:?}",
1217 cmd.args
1218 );
1219 }
1220
1221 #[test]
1222 fn zero_max_turns_is_not_passed() {
1223 // max_turns = 0 means "no cap"; --max-turns should not appear in the command.
1224 let exec = FleetExecConfig {
1225 max_turns: 0,
1226 ..Default::default()
1227 };
1228 let cmd = build_worker_exec_command("codewhale", &task("x"), &exec, None);
1229 assert!(!cmd.args.join(" ").contains("--max-turns"));
1230 }
1231
1232 #[test]
1233 fn default_max_turns_is_passed_as_bounded_flag() {
1234 // The default is now FLEET_DEFAULT_MAX_TURNS (500), so --max-turns IS passed
1235 // to ensure workers respect the finite budget (#3885).
1236 let exec = FleetExecConfig::default();
1237 let joined = build_worker_exec_command("codewhale", &task("x"), &exec, None)
1238 .args
1239 .join(" ");
1240 assert!(
1241 joined.contains("--max-turns"),
1242 "default finite budget should be forwarded to the subprocess: {joined}"
1243 );
1244 }
1245
1246 #[test]
1247 fn stream_line_maps_tool_use_to_running_tool() {
1248 let line = r#"{"type":"tool_use","name":"read_file","id":"call-7","input":{}}"#;
1249 match map_exec_stream_line(line) {
1250 Some(FleetWorkerEventPayload::RunningTool { tool, call_id }) => {
1251 assert_eq!(tool, "read_file");
1252 assert_eq!(call_id.as_deref(), Some("call-7"));
1253 }
1254 other => panic!("expected RunningTool, got {other:?}"),
1255 }
1256 }
1257
1258 #[test]
1259 fn stream_line_maps_done_and_error() {
1260 assert!(matches!(
1261 map_exec_stream_line(r#"{"type":"done"}"#),
1262 Some(FleetWorkerEventPayload::Completed { .. })
1263 ));
1264 match map_exec_stream_line(r#"{"type":"error","error":"boom"}"#) {
1265 Some(FleetWorkerEventPayload::Failed { reason, .. }) => assert_eq!(reason, "boom"),
1266 other => panic!("expected Failed, got {other:?}"),
1267 }
1268 }
1269
1270 #[test]
1271 fn stream_line_maps_workflow_receipt_to_typed_event() {
1272 let line =
1273 r#"{"type":"workflow_event","run_id":"workflow_1","event":{"type":"task_completed"}}"#;
1274 match map_exec_stream_line(line) {
1275 Some(FleetWorkerEventPayload::WorkflowEvent {
1276 workflow_run_id,
1277 event,
1278 }) => {
1279 assert_eq!(workflow_run_id, "workflow_1");
1280 assert_eq!(event["type"], "task_completed");
1281 }
1282 other => panic!("expected typed workflow receipt, got {other:?}"),
1283 }
1284 }
1285
1286 #[test]
1287 fn stream_line_ignores_noise_and_bad_json() {
1288 assert!(map_exec_stream_line(r#"{"type":"session_capture","content":"x"}"#).is_none());
1289 assert!(map_exec_stream_line("not json").is_none());
1290 assert!(map_exec_stream_line("").is_none());
1291 }
1292
1293 #[test]
1294 fn terminal_route_keeps_exact_literal_custom_distinct_from_idless_root_and_redacts() {
1295 let exact = map_exec_terminal_route(
1296 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"custom","model":"literal-model","base_url":"https://must-not-cross.invalid/v1","api_key":"sk-must-not-cross"}}"#,
1297 )
1298 .expect("literal custom terminal route");
1299 assert_eq!(exact.provider, "custom");
1300 assert_eq!(exact.provider_exact_id.as_deref(), Some("custom"));
1301 assert_eq!(exact.model, "literal-model");
1302
1303 let root = map_exec_terminal_route(
1304 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","model":"root-model"}}"#,
1305 )
1306 .expect("idless root custom terminal route");
1307 assert_eq!(root.provider, "custom");
1308 assert_eq!(root.provider_exact_id, None);
1309 assert_eq!(root.model, "root-model");
1310
1311 let named = map_exec_terminal_route(
1312 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"lm-studio","model":"local-model"}}"#,
1313 )
1314 .expect("named custom terminal route");
1315 assert_eq!(named.provider, "custom");
1316 assert_eq!(named.provider_exact_id.as_deref(), Some("lm-studio"));
1317 assert_eq!(named.model, "local-model");
1318
1319 let reported = format!("{exact:?}").to_ascii_lowercase();
1320 for forbidden in ["base_url", "https://", "api_key", "sk-must-not-cross"] {
1321 assert!(
1322 !reported.contains(forbidden),
1323 "allowlisted terminal route leaked {forbidden:?}: {reported}"
1324 );
1325 }
1326
1327 for malformed in [
1328 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"","model":"root-model"}}"#,
1329 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":" ","model":"root-model"}}"#,
1330 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":7,"model":"root-model"}}"#,
1331 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"deepseek","provider_id":"custom-x","model":"deepseek-v4-pro"}}"#,
1332 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"unknown-kind","model":"unknown-model"}}"#,
1333 ] {
1334 assert!(
1335 map_exec_terminal_route(malformed).is_none(),
1336 "malformed present exact id must not collapse to idless root: {malformed}"
1337 );
1338 }
1339 }
1340
1341 #[test]
1342 fn terminal_route_evidence_requires_exactly_one_valid_envelope() {
1343 let route_x = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model-x"}}"#;
1344 let route_y = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-y","model":"worker-model-y"}}"#;
1345 let malformed = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"","model":"worker-model-x"}}"#;
1346 let noise = r#"{"type":"content","delta":"progress"}"#;
1347
1348 let observe = |lines: &[&str]| {
1349 let mut evidence = TerminalRouteEvidence::default();
1350 for line in lines {
1351 evidence.observe(parse_exec_terminal_route(line));
1352 }
1353 evidence.reported_route().cloned()
1354 };
1355
1356 let only = observe(&[noise, route_x]).expect("one valid route");
1357 assert_eq!(only.provider_exact_id.as_deref(), Some("remote-x"));
1358 assert!(
1359 observe(&[route_x, malformed]).is_none(),
1360 "valid then malformed must invalidate stale evidence"
1361 );
1362 assert!(
1363 observe(&[malformed, route_x]).is_none(),
1364 "malformed then valid must remain invalid"
1365 );
1366 assert!(
1367 observe(&[route_x, route_y]).is_none(),
1368 "conflicting valid routes must be ambiguous"
1369 );
1370 assert!(
1371 observe(&[route_x, route_x]).is_none(),
1372 "even identical duplicates violate the exactly-one contract"
1373 );
1374 }
1375
1376 #[test]
1377 fn exit_classification() {
1378 assert!(matches!(
1379 classify_worker_exit(Some(0), false),
1380 FleetWorkerEventPayload::Completed { .. }
1381 ));
1382 assert!(matches!(
1383 classify_worker_exit(Some(1), false),
1384 FleetWorkerEventPayload::Failed {
1385 recoverable: true,
1386 ..
1387 }
1388 ));
1389 assert!(matches!(
1390 classify_worker_exit(Some(0), true),
1391 FleetWorkerEventPayload::Cancelled { .. }
1392 ));
1393 }
1394
1395 /// End-to-end: run a REAL subprocess that emits stream-json (standing in for
1396 /// `codewhale exec`), and prove the executor drains its events and terminal
1397 /// exit through the real host adapter — no codewhale binary needed. This is
1398 /// the verifiable proof that a fleet worker is an out-of-process exec run.
1399 #[cfg(unix)]
1400 #[test]
1401 fn executor_runs_real_process_and_drains_stream_json_into_ledger_events() {
1402 let tmp = tempfile::TempDir::new().unwrap();
1403 let mut exec = FleetExecutor::new(tmp.path());
1404 let script = r#"printf '{"type":"tool_use","name":"read_file","id":"c1","input":{}}\n'; printf '{"type":"done"}\n'"#;
1405 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
1406 exec.start_worker("w1", command, None).unwrap();
1407
1408 let mut events = Vec::new();
1409 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1410 loop {
1411 events.extend(exec.drain_events("w1"));
1412 if let Some(term) = exec.poll_terminal("w1") {
1413 events.extend(exec.drain_events("w1")); // final flush after exit
1414 events.push(term);
1415 break;
1416 }
1417 assert!(
1418 std::time::Instant::now() < deadline,
1419 "worker did not terminate; events so far: {events:?}"
1420 );
1421 std::thread::sleep(std::time::Duration::from_millis(20));
1422 }
1423
1424 assert!(
1425 events.iter().any(|e| matches!(
1426 e,
1427 FleetWorkerEventPayload::RunningTool { tool, .. } if tool == "read_file"
1428 )),
1429 "expected a RunningTool(read_file) event, got {events:?}"
1430 );
1431 assert!(
1432 events
1433 .iter()
1434 .any(|e| matches!(e, FleetWorkerEventPayload::Completed { .. })),
1435 "expected a terminal Completed event, got {events:?}"
1436 );
1437 assert!(exec.all_terminal());
1438 }
1439
1440 #[cfg(unix)]
1441 #[test]
1442 fn terminal_poll_final_drains_route_metadata_and_tail_payloads() {
1443 let tmp = tempfile::TempDir::new().unwrap();
1444 let mut exec = FleetExecutor::new(tmp.path());
1445 let script = r#"printf '%s\n' '{"type":"content","delta":"tail progress"}'; printf '%s' '{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model-x"}}'"#;
1446 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
1447 exec.start_worker("tail-worker", command, None).unwrap();
1448
1449 // Deliberately do not call the ordinary event drain. Poll only after
1450 // exit, reproducing the scheduler gap where the previous poll saw EOF
1451 // just before the worker wrote its terminal tail.
1452 std::thread::sleep(std::time::Duration::from_millis(100));
1453 let terminal = exec
1454 .poll_terminal_with_status("tail-worker")
1455 .expect("terminal worker");
1456 let route = terminal.reported_route.expect("final-drained route");
1457 assert_eq!(route.provider, "custom");
1458 assert_eq!(route.provider_exact_id.as_deref(), Some("remote-x"));
1459 assert_eq!(route.model, "worker-model-x");
1460 assert!(
1461 terminal
1462 .tail_payloads
1463 .iter()
1464 .any(|payload| matches!(payload, FleetWorkerEventPayload::Running))
1465 );
1466 }
1467
1468 #[cfg(unix)]
1469 #[test]
1470 fn terminal_poll_trailing_malformed_route_invalidates_prior_valid_route() {
1471 let tmp = tempfile::TempDir::new().unwrap();
1472 let mut exec = FleetExecutor::new(tmp.path());
1473 let script = r#"printf '%s\n' '{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model-x"}}'; printf '%s' '{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"","model":"worker-model-x"}}'"#;
1474 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
1475 exec.start_worker("ambiguous-tail-worker", command, None)
1476 .unwrap();
1477
1478 std::thread::sleep(std::time::Duration::from_millis(100));
1479 let terminal = exec
1480 .poll_terminal_with_status("ambiguous-tail-worker")
1481 .expect("terminal worker");
1482 assert!(
1483 terminal.reported_route.is_none(),
1484 "malformed trailing terminal evidence must invalidate the prior valid route"
1485 );
1486 }
1487
1488 /// Dogfood smoke (#3166): several concurrent exec-style workers with one
1489 /// injected failure. Proves the executor drives a small fleet to terminal
1490 /// outcomes and that a failing worker is classified distinctly from the
1491 /// passing ones — all without the codewhale binary.
1492 #[cfg(unix)]
1493 #[test]
1494 fn executor_drives_concurrent_workers_with_injected_failure() {
1495 let tmp = tempfile::TempDir::new().unwrap();
1496 let mut exec = FleetExecutor::new(tmp.path());
1497
1498 // Three healthy workers emit a tool_use + done; one injected-failure
1499 // worker emits an error event and exits non-zero.
1500 let ok = r#"printf '{"type":"tool_use","name":"grep_files","id":"c","input":{}}\n{"type":"done"}\n'"#;
1501 let bad = r#"printf '{"type":"error","error":"injected failure"}\n'; exit 7"#;
1502 for id in ["w1", "w2", "w3"] {
1503 exec.start_worker(
1504 id,
1505 FleetWorkerCommand::new("sh", vec!["-c".to_string(), ok.to_string()]),
1506 None,
1507 )
1508 .unwrap();
1509 }
1510 exec.start_worker(
1511 "w-fail",
1512 FleetWorkerCommand::new("sh", vec!["-c".to_string(), bad.to_string()]),
1513 None,
1514 )
1515 .unwrap();
1516
1517 let ids = ["w1", "w2", "w3", "w-fail"];
1518 let mut terminals: std::collections::BTreeMap<&str, FleetWorkerEventPayload> =
1519 std::collections::BTreeMap::new();
1520 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1521 while terminals.len() < ids.len() {
1522 for id in ids {
1523 let _ = exec.drain_events(id);
1524 if let Some(term) = exec.poll_terminal(id) {
1525 terminals.insert(id, term);
1526 }
1527 }
1528 assert!(
1529 std::time::Instant::now() < deadline,
1530 "not all workers terminated: {terminals:?}"
1531 );
1532 std::thread::sleep(std::time::Duration::from_millis(20));
1533 }
1534
1535 assert!(exec.all_terminal());
1536 for id in ["w1", "w2", "w3"] {
1537 assert!(
1538 matches!(terminals[id], FleetWorkerEventPayload::Completed { .. }),
1539 "{id} should pass, got {:?}",
1540 terminals[id]
1541 );
1542 }
1543 assert!(
1544 matches!(terminals["w-fail"], FleetWorkerEventPayload::Failed { .. }),
1545 "injected-failure worker should fail, got {:?}",
1546 terminals["w-fail"]
1547 );
1548 }
1549
1550 #[test]
1551 fn terminal_route_preserves_multibyte_identity_across_read_boundaries() {
1552 let tmp = tempfile::TempDir::new().unwrap();
1553 let log_path = tmp.path().join("split-utf8.jsonl");
1554 std::fs::write(&log_path, []).unwrap();
1555 let mut executor = FleetExecutor::new(tmp.path());
1556 track_test_stream(&mut executor, "split-utf8", log_path.clone());
1557
1558 let provider_id = "深海鲸-供应商";
1559 let model = "深潜-模型";
1560 let line = format!(
1561 "{{\"type\":\"metadata\",\"meta\":{{\"receipt_kind\":\"terminal\",\"provider\":\"custom\",\"provider_id\":\"{provider_id}\",\"model\":\"{model}\"}}}}\n"
1562 );
1563 let bytes = line.as_bytes();
1564 let provider_start = bytes
1565 .windows("鲸".len())
1566 .position(|window| window == "鲸".as_bytes())
1567 .unwrap();
1568 let model_start = bytes
1569 .windows("潜".len())
1570 .position(|window| window == "潜".as_bytes())
1571 .unwrap();
1572 let provider_split = provider_start + 1;
1573 let model_split = model_start + 2;
1574
1575 append_test_stream(&log_path, &bytes[..provider_split]);
1576 assert!(executor.drain_events("split-utf8").is_empty());
1577 append_test_stream(&log_path, &bytes[provider_split..model_split]);
1578 assert!(executor.drain_events("split-utf8").is_empty());
1579 append_test_stream(&log_path, &bytes[model_split..]);
1580 assert!(executor.drain_events("split-utf8").is_empty());
1581
1582 let route = executor
1583 .streams
1584 .get("split-utf8")
1585 .and_then(|stream| stream.terminal_route.reported_route())
1586 .expect("one exact terminal route");
1587 assert_eq!(route.provider, "custom");
1588 assert_eq!(route.provider_exact_id.as_deref(), Some(provider_id));
1589 assert_eq!(route.model, model);
1590 }
1591
1592 #[test]
1593 fn invalid_utf8_terminal_route_fails_closed_without_lossy_identity() {
1594 let tmp = tempfile::TempDir::new().unwrap();
1595 let log_path = tmp.path().join("invalid-utf8.jsonl");
1596 let mut line = br#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model"}}"#.to_vec();
1597 let invalid_at = line
1598 .windows(b"remote-x".len())
1599 .position(|window| window == b"remote-x")
1600 .unwrap()
1601 + 3;
1602 line[invalid_at] = 0xff;
1603 line.push(b'\n');
1604 std::fs::write(&log_path, line).unwrap();
1605
1606 let mut executor = FleetExecutor::new(tmp.path());
1607 track_test_stream(&mut executor, "invalid-utf8", log_path);
1608 assert!(executor.drain_events("invalid-utf8").is_empty());
1609 assert!(matches!(
1610 executor
1611 .streams
1612 .get("invalid-utf8")
1613 .map(|stream| &stream.terminal_route),
1614 Some(TerminalRouteEvidence::InvalidOrAmbiguous)
1615 ));
1616 }
1617
1618 #[test]
1619 fn invalid_utf8_nonterminal_line_cannot_synthesize_route_evidence() {
1620 let tmp = tempfile::TempDir::new().unwrap();
1621 let log_path = tmp.path().join("invalid-nonterminal.jsonl");
1622 let mut line = br#"{"type":"content","delta":"ordinary-output"}"#.to_vec();
1623 let invalid_at = line
1624 .windows(b"ordinary-output".len())
1625 .position(|window| window == b"ordinary-output")
1626 .unwrap()
1627 + 4;
1628 line[invalid_at] = 0xff;
1629 line.push(b'\n');
1630 std::fs::write(&log_path, line).unwrap();
1631
1632 let mut executor = FleetExecutor::new(tmp.path());
1633 track_test_stream(&mut executor, "invalid-nonterminal", log_path);
1634 assert!(executor.drain_events("invalid-nonterminal").is_empty());
1635 assert!(
1636 executor
1637 .streams
1638 .get("invalid-nonterminal")
1639 .and_then(|stream| stream.terminal_route.reported_route())
1640 .is_none()
1641 );
1642 }
1643 }
1644
1644 lines RUST