返回 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 //! Fleets 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::task_spec::FleetWorkerFinalAnswer;
30 use super::worker_runtime::{
31 fleet_task_prompt, fleet_task_prompt_with_profiles, fleet_worker_launch_reasoning_effort,
32 fleet_worker_launch_route,
33 };
34 use crate::tools::spec::{
35 ToolAuthorityEnvelope, ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority,
36 };
37 use crate::tools::subagent::{AgentWorkerSpec, FleetRole};
38
39 #[derive(Clone, Copy, Default)]
40 struct WorkerExecRoute<'a> {
41 model: Option<&'a str>,
42 provider: Option<&'a str>,
43 reasoning_effort: Option<&'a str>,
44 }
45
46 #[derive(Clone, Copy, Default)]
47 struct WorkerExecLimits {
48 max_turns: Option<u32>,
49 max_tool_calls: Option<u32>,
50 }
51
52 /// Resolve the executable used for Fleet worker subprocesses.
53 ///
54 /// Kept here so every long-lived surface (CLI and Runtime API) launches the
55 /// same configured worker binary instead of silently diverging.
56 pub fn configured_codewhale_binary() -> String {
57 std::env::var("CODEWHALE_FLEET_CODEWHALE_BINARY")
58 .ok()
59 .map(|value| value.trim().to_string())
60 .filter(|value| !value.is_empty())
61 .unwrap_or_else(|| "codewhale".to_string())
62 }
63
64 /// Build the `codewhale exec` argv that runs a fleet task headlessly.
65 ///
66 /// `--auto` is always passed: a headless worker has no human to approve tool
67 /// calls, so it runs with full (policy-gated) tool access. `--output-format
68 /// stream-json` makes the worker emit the NDJSON event stream this module
69 /// parses. A worker launched with the v0.9.1 machine-readable outer authority
70 /// cap is a truthful leaf (`max_spawn_depth = 0`): the nested-agent surface is
71 /// disabled until authority scopes can be intersected across
72 /// process/workspace boundaries.
73 ///
74 /// Secrets are NEVER placed on the argv: provider credentials are resolved by
75 /// the worker process from its own config/keyring exactly like an interactive
76 /// run. The host adapter additionally refuses secret-bearing env keys. The
77 /// `--provider` flag threaded by [`build_worker_exec_command_with_profiles`] is
78 /// a non-secret provider *identifier* only (#4093) — the worker still resolves
79 /// that provider's credentials from its own env/config, so this invariant
80 /// holds.
81 pub fn build_worker_exec_command(
82 codewhale_binary: &str,
83 task_spec: &FleetTaskSpec,
84 exec_config: &FleetExecConfig,
85 model: Option<&str>,
86 ) -> FleetWorkerCommand {
87 let max_turns = effective_task_max_turns(task_spec, exec_config);
88 let max_tool_calls = task_max_tool_calls(task_spec);
89 build_worker_exec_command_from_prompt(
90 codewhale_binary,
91 fleet_task_prompt(task_spec),
92 exec_config,
93 WorkerExecRoute {
94 model,
95 ..WorkerExecRoute::default()
96 },
97 None,
98 WorkerExecLimits {
99 max_turns,
100 max_tool_calls,
101 },
102 )
103 }
104
105 /// Build a worker command after resolving workspace Fleet profile input.
106 ///
107 /// The launched subprocess runs on the worker's RESOLVED route, not blindly on
108 /// the run-level session model (#4093 AC #4): the per-worker model+provider are
109 /// resolved from the task's agent profile via the same explicit-only path the
110 /// receipt uses ([`fleet_worker_launch_route`]). A worker whose profile pins
111 /// provider B thus launches on provider B's model even when the parent session
112 /// is on provider A. Workers with no profile-bound provider fall back to the
113 /// run-level model and emit no `--provider`, so the worker keeps its own
114 /// session default (today's behavior, unchanged).
115 pub fn build_worker_exec_command_with_profiles(
116 codewhale_binary: &str,
117 task_spec: &FleetTaskSpec,
118 exec_config: &FleetExecConfig,
119 model: Option<&str>,
120 agent_profiles: &[AgentProfile],
121 ) -> Result<FleetWorkerCommand> {
122 let (worker_model, worker_provider) =
123 fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default());
124 let worker_reasoning_effort = fleet_worker_launch_reasoning_effort(task_spec, agent_profiles);
125 let max_turns = effective_task_max_turns(task_spec, exec_config);
126 let max_tool_calls = task_max_tool_calls(task_spec);
127 Ok(build_worker_exec_command_from_prompt(
128 codewhale_binary,
129 fleet_task_prompt_with_profiles(task_spec, agent_profiles)?,
130 exec_config,
131 WorkerExecRoute {
132 model: Some(worker_model.as_str()),
133 provider: worker_provider.as_deref(),
134 reasoning_effort: worker_reasoning_effort.as_deref(),
135 },
136 None,
137 WorkerExecLimits {
138 max_turns,
139 max_tool_calls,
140 },
141 ))
142 }
143
144 /// Build the exact Fleet subprocess command from the coordination-registered
145 /// worker spec. Unlike the compatibility helpers above, production dispatch
146 /// uses the projected objective and carries a machine-readable outer authority
147 /// envelope into the child process.
148 pub fn build_worker_exec_command_with_launch_spec(
149 codewhale_binary: &str,
150 task_spec: &FleetTaskSpec,
151 launch_spec: &AgentWorkerSpec,
152 exec_config: &FleetExecConfig,
153 model: Option<&str>,
154 agent_profiles: &[AgentProfile],
155 ) -> Result<FleetWorkerCommand> {
156 let (worker_model, worker_provider) =
157 fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default());
158 let worker_reasoning_effort = fleet_worker_launch_reasoning_effort(task_spec, agent_profiles);
159 let authority = authority_envelope_for_worker(launch_spec, task_spec)?;
160 // Production dispatch receives an already-hardened launch spec from the
161 // Fleet manager. Its positive max_steps value has therefore already been
162 // intersected with a positive FleetExecConfig.max_turns; zero remains the
163 // explicit unbounded sentinel.
164 let max_turns = (launch_spec.max_steps > 0).then_some(launch_spec.max_steps);
165 let max_tool_calls = task_max_tool_calls(task_spec);
166 Ok(build_worker_exec_command_from_prompt(
167 codewhale_binary,
168 launch_spec.objective.clone(),
169 exec_config,
170 WorkerExecRoute {
171 model: Some(worker_model.as_str()),
172 provider: worker_provider.as_deref(),
173 reasoning_effort: worker_reasoning_effort.as_deref(),
174 },
175 Some(&authority),
176 WorkerExecLimits {
177 max_turns,
178 max_tool_calls,
179 },
180 ))
181 }
182
183 fn task_max_steps(task_spec: &FleetTaskSpec) -> Option<u32> {
184 task_spec
185 .budget
186 .as_ref()
187 .and_then(|budget| budget.max_steps)
188 .filter(|max_steps| *max_steps > 0)
189 }
190
191 fn task_max_tool_calls(task_spec: &FleetTaskSpec) -> Option<u32> {
192 task_spec
193 .budget
194 .as_ref()
195 .and_then(|budget| budget.max_tool_calls)
196 .filter(|max_tool_calls| *max_tool_calls > 0)
197 }
198
199 fn effective_task_max_turns(
200 task_spec: &FleetTaskSpec,
201 exec_config: &FleetExecConfig,
202 ) -> Option<u32> {
203 match (task_max_steps(task_spec), exec_config.max_turns) {
204 (Some(task_max_steps), fleet_max_turns) if fleet_max_turns > 0 => {
205 Some(task_max_steps.min(fleet_max_turns))
206 }
207 (Some(task_max_steps), _) => Some(task_max_steps),
208 (None, fleet_max_turns) if fleet_max_turns > 0 => Some(fleet_max_turns),
209 (None, _) => None,
210 }
211 }
212
213 pub(crate) fn authority_envelope_for_worker(
214 spec: &AgentWorkerSpec,
215 task_spec: &FleetTaskSpec,
216 ) -> Result<ToolAuthorityEnvelope> {
217 let (authority, writable_roots, writable_files, coordination_contracts) =
218 if spec.runtime_profile.permissions.write {
219 let manifest = spec.launch_manifest.as_ref().ok_or_else(|| {
220 anyhow::anyhow!(
221 "write-capable Fleet worker '{}' has no launch manifest",
222 spec.worker_id
223 )
224 })?;
225 (
226 ToolMutationAuthority::ScopedWrite,
227 super::worker_runtime::fleet_runtime_write_roots(task_spec)?,
228 manifest.writable_files.clone(),
229 manifest.coordination_contracts.clone(),
230 )
231 } else {
232 (
233 ToolMutationAuthority::ReadOnly,
234 Vec::new(),
235 Vec::new(),
236 Vec::new(),
237 )
238 };
239 let shell = if authority == ToolMutationAuthority::ReadOnly
240 && matches!(
241 &spec.agent_type,
242 FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner
243 )
244 && spec.runtime_profile.shell.allows_shell()
245 {
246 // Scout, Reviewer, and Planner get one classifier-bounded
247 // foreground shell. Verifiers keep the dedicated Run surface,
248 // consultants remain shell-less, and writers retain the historical
249 // subprocess contract.
250 ToolShellAuthority::ReadOnly
251 } else {
252 ToolShellAuthority::None
253 };
254 let verification = if authority == ToolMutationAuthority::ReadOnly
255 && matches!(&spec.agent_type, FleetRole::Verifier)
256 && spec.runtime_profile.shell.allows_shell()
257 {
258 ToolVerificationAuthority::Bounded
259 } else {
260 ToolVerificationAuthority::None
261 };
262 ToolAuthorityEnvelope {
263 schema_version: 1,
264 owner: spec.worker_id.clone(),
265 authority,
266 network_access: Some(spec.runtime_profile.permissions.network),
267 shell,
268 verification,
269 writable_roots,
270 writable_files,
271 coordination_contracts,
272 }
273 .normalized()
274 .map_err(anyhow::Error::msg)
275 }
276
277 fn build_worker_exec_command_from_prompt(
278 codewhale_binary: &str,
279 task_prompt: String,
280 exec_config: &FleetExecConfig,
281 route: WorkerExecRoute<'_>,
282 authority: Option<&ToolAuthorityEnvelope>,
283 limits: WorkerExecLimits,
284 ) -> FleetWorkerCommand {
285 let mut args: Vec<String> = Vec::new();
286
287 // The canonical `codewhale` dispatcher owns these route overrides as
288 // global flags and deliberately rejects them after `exec`. Keep them in
289 // front of the subcommand so Fleet commands work through the installed
290 // dispatcher as well as when a host points directly at `codewhale-tui`.
291 if let Some(model) = route.model.map(str::trim).filter(|m| !m.is_empty()) {
292 args.push("--model".to_string());
293 args.push(model.to_string());
294 }
295
296 // Non-secret provider identifier only (#4093): the worker resolves the
297 // provider's credentials from its own env/config. Emitted ONLY when the
298 // worker's profile explicitly pins a provider, so profile-less workers keep
299 // their own session default exactly as before.
300 if let Some(provider) = route.provider.map(str::trim).filter(|p| !p.is_empty()) {
301 args.push("--provider".to_string());
302 args.push(provider.to_string());
303 }
304
305 args.extend([
306 "exec".to_string(),
307 "--auto".to_string(),
308 "--output-format".to_string(),
309 "stream-json".to_string(),
310 // R7: the worker shuts itself down when the manager dies (stdin EOF).
311 "--parent-death-watch".to_string(),
312 ]);
313
314 // Non-secret thinking tier only (#4137). This is profile metadata and
315 // follows the same explicit-only policy as provider: omit it when the
316 // worker profile inherits the session/default reasoning setting.
317 if let Some(reasoning_effort) = route
318 .reasoning_effort
319 .map(str::trim)
320 .filter(|e| !e.is_empty())
321 {
322 args.push("--reasoning-effort".to_string());
323 args.push(reasoning_effort.to_string());
324 }
325
326 if !exec_config.allowed_tools.is_empty() {
327 args.push("--allowed-tools".to_string());
328 args.push(exec_config.allowed_tools.join(","));
329 }
330 if !exec_config.disallowed_tools.is_empty() {
331 args.push("--disallowed-tools".to_string());
332 args.push(exec_config.disallowed_tools.join(","));
333 }
334 if let Some(max_turns) = limits.max_turns {
335 args.push("--max-turns".to_string());
336 args.push(max_turns.to_string());
337 }
338 if let Some(max_tool_calls) = limits.max_tool_calls {
339 args.push("--max-tool-calls".to_string());
340 args.push(max_tool_calls.to_string());
341 }
342 if !exec_config.append_system_prompt.trim().is_empty() {
343 args.push("--append-system-prompt".to_string());
344 args.push(exec_config.append_system_prompt.clone());
345 }
346
347 if let Some(authority) = authority {
348 args.push("--tool-authority-json".to_string());
349 args.push(
350 serde_json::to_string(authority)
351 .expect("validated Fleet tool authority envelope must serialize"),
352 );
353 }
354
355 // The composed task prompt is the final positional argument.
356 args.push(task_prompt);
357
358 FleetWorkerCommand::new(codewhale_binary.to_string(), args)
359 }
360
361 /// Map one `codewhale exec` stream-json line into a fleet ledger event.
362 ///
363 /// Returns `None` for lines that don't correspond to a worker lifecycle
364 /// transition (e.g. `session_capture`, `metadata`). The exec event schema is
365 /// `{"type": "...", ...}` (see `ExecStreamEvent` in `main.rs`).
366 pub fn map_exec_stream_line(line: &str) -> Option<FleetWorkerEventPayload> {
367 let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
368 map_exec_stream_value(&value)
369 }
370
371 /// [`map_exec_stream_line`] on an already-parsed line, so the incremental
372 /// stream reader parses each frame exactly once.
373 fn map_exec_stream_value(value: &serde_json::Value) -> Option<FleetWorkerEventPayload> {
374 match value.get("type").and_then(serde_json::Value::as_str)? {
375 "tool_use" => {
376 let tool = value
377 .get("name")
378 .and_then(serde_json::Value::as_str)
379 .unwrap_or("tool")
380 .to_string();
381 let call_id = value
382 .get("id")
383 .and_then(serde_json::Value::as_str)
384 .map(str::to_string);
385 Some(FleetWorkerEventPayload::RunningTool { tool, call_id })
386 }
387 "workflow_event" => Some(FleetWorkerEventPayload::WorkflowEvent {
388 workflow_run_id: value.get("run_id")?.as_str()?.to_string(),
389 event: value.get("event")?.clone(),
390 }),
391 // Streaming model output / tool results mean the worker is alive and
392 // making progress; surface a coarse Running heartbeat.
393 "content" | "tool_result" => Some(FleetWorkerEventPayload::Running),
394 // Per-step usage receipts feed the run-level accumulator behind the
395 // fleet usage ceiling (R6, #5567); a malformed line still counts as
396 // liveness.
397 "turn_usage" => {
398 let tokens = |field: &str| {
399 value
400 .get(field)
401 .and_then(serde_json::Value::as_u64)
402 .unwrap_or(0)
403 };
404 let input_tokens = tokens("input_tokens");
405 let output_tokens = tokens("output_tokens");
406 if input_tokens == 0 && output_tokens == 0 {
407 Some(FleetWorkerEventPayload::Running)
408 } else {
409 Some(FleetWorkerEventPayload::UsageReport {
410 input_tokens,
411 output_tokens,
412 })
413 }
414 }
415 "done" => Some(FleetWorkerEventPayload::Completed {
416 exit_code: Some(0),
417 summary: None,
418 }),
419 "error" => {
420 let reason = value
421 .get("error")
422 .and_then(serde_json::Value::as_str)
423 .unwrap_or("worker reported an error")
424 .to_string();
425 Some(FleetWorkerEventPayload::Failed {
426 reason,
427 recoverable: false,
428 })
429 }
430 _ => None,
431 }
432 }
433
434 #[derive(Debug)]
435 enum ParsedTerminalRoute {
436 NotTerminal,
437 Valid(FleetWorkerReportedRoute),
438 Invalid,
439 }
440
441 /// The `meta` object of a terminal exec receipt, or `None` for every other
442 /// stream line.
443 fn exec_terminal_meta(
444 value: &serde_json::Value,
445 ) -> Option<&serde_json::Map<String, serde_json::Value>> {
446 if value.get("type").and_then(serde_json::Value::as_str) != Some("metadata") {
447 return None;
448 }
449 let meta = value.get("meta").and_then(serde_json::Value::as_object)?;
450 (meta.get("receipt_kind").and_then(serde_json::Value::as_str) == Some("terminal"))
451 .then_some(meta)
452 }
453
454 /// The worker's visible final answer from a terminal exec receipt: the
455 /// emitter already bounded and redacted `visible_final_answer_excerpt`, and
456 /// `visible_final_answer_chars` is the real pre-truncation length.
457 fn parse_exec_terminal_final_answer(value: &serde_json::Value) -> Option<FleetWorkerFinalAnswer> {
458 let meta = exec_terminal_meta(value)?;
459 let excerpt = meta
460 .get("visible_final_answer_excerpt")
461 .and_then(serde_json::Value::as_str)?
462 .trim();
463 if excerpt.is_empty() {
464 return None;
465 }
466 let chars = meta
467 .get("visible_final_answer_chars")
468 .and_then(serde_json::Value::as_u64)
469 .and_then(|chars| usize::try_from(chars).ok())
470 .unwrap_or_else(|| excerpt.chars().count());
471 Some(FleetWorkerFinalAnswer {
472 excerpt: crate::exec_stream_final_answer_excerpt(excerpt),
473 chars,
474 })
475 }
476
477 /// Parse one allowlisted, secret-free route identity from terminal exec
478 /// metadata. Once a line declares itself as a terminal receipt, malformed
479 /// route fields are distinct from ordinary non-terminal stream noise so a
480 /// prior valid record cannot survive contradictory evidence.
481 fn parse_exec_terminal_route(value: &serde_json::Value) -> ParsedTerminalRoute {
482 let Some(meta) = exec_terminal_meta(value) else {
483 return ParsedTerminalRoute::NotTerminal;
484 };
485
486 let route = (|| {
487 let provider = meta.get("provider")?.as_str()?.trim();
488 let model = meta.get("model")?.as_str()?.trim();
489 if provider.is_empty() || model.is_empty() {
490 return None;
491 }
492 let provider_kind = crate::config::ApiProvider::parse(provider)?;
493 let provider_exact_id = match meta.get("provider_id") {
494 None => None,
495 Some(value) => {
496 let id = value.as_str()?.trim();
497 if id.is_empty() {
498 return None;
499 }
500 Some(id.to_string())
501 }
502 };
503 if provider_exact_id.is_some() && provider_kind != crate::config::ApiProvider::Custom {
504 return None;
505 }
506 Some(FleetWorkerReportedRoute {
507 provider: provider.to_string(),
508 provider_exact_id,
509 model: model.to_string(),
510 })
511 })();
512
513 route.map_or(ParsedTerminalRoute::Invalid, ParsedTerminalRoute::Valid)
514 }
515
516 #[cfg(test)]
517 fn map_exec_terminal_route(line: &str) -> Option<FleetWorkerReportedRoute> {
518 let value: serde_json::Value = serde_json::from_str(line).ok()?;
519 match parse_exec_terminal_route(&value) {
520 ParsedTerminalRoute::Valid(route) => Some(route),
521 ParsedTerminalRoute::NotTerminal | ParsedTerminalRoute::Invalid => None,
522 }
523 }
524
525 /// Classify a worker process exit into a terminal fleet event.
526 ///
527 /// `stopped` means the operator stopped the worker (cancellation), which takes
528 /// precedence over the exit code.
529 pub fn classify_worker_exit(exit_code: Option<i32>, stopped: bool) -> FleetWorkerEventPayload {
530 if stopped {
531 return FleetWorkerEventPayload::Cancelled { cancelled_by: None };
532 }
533 match exit_code {
534 Some(0) => FleetWorkerEventPayload::Completed {
535 exit_code: Some(0),
536 summary: None,
537 },
538 Some(code) => FleetWorkerEventPayload::Failed {
539 reason: format!("worker exited with code {code}"),
540 recoverable: true,
541 },
542 None => FleetWorkerEventPayload::Failed {
543 reason: "worker exited without a status code".to_string(),
544 recoverable: true,
545 },
546 }
547 }
548
549 /// Drives fleet workers as real `codewhale exec` subprocesses on the local
550 /// host, incrementally draining each worker's stream-json output into fleet
551 /// ledger events.
552 ///
553 /// The caller (the `codewhale fleet run` loop / `FleetManager`) owns the
554 /// ledger; the executor owns the OS process boundary and the incremental log
555 /// parse. Because the worker is a separate process, its heavy runtime/tool
556 /// construction never touches the orchestrator — the parent only ingests a
557 /// compact event stream, which is what keeps it light at high fanout.
558 pub struct FleetExecutor {
559 workspace: std::path::PathBuf,
560 sessions_dir: Option<std::path::PathBuf>,
561 adapter: super::host::LocalProcessFleetHostAdapter,
562 ssh_adapters: std::collections::BTreeMap<String, super::host::SshFleetHostAdapter>,
563 streams: std::collections::BTreeMap<String, WorkerStream>,
564 }
565
566 /// Durable lease identity owned by one concrete host process.
567 #[derive(Debug, Clone, PartialEq, Eq)]
568 pub struct FleetExecutorAttempt {
569 pub run_id: codewhale_protocol::fleet::FleetRunId,
570 pub task_id: String,
571 pub attempt: u32,
572 }
573
574 struct WorkerStream {
575 log_path: std::path::PathBuf,
576 host: WorkerStreamHost,
577 attempt: Option<FleetExecutorAttempt>,
578 offset: u64,
579 // Keep incomplete stream frames as bytes. Decoding each read separately
580 // corrupts valid UTF-8 when a multibyte code point crosses a read boundary.
581 pending: Vec<u8>,
582 terminal: bool,
583 terminal_route: TerminalRouteEvidence,
584 /// When this worker process was started, for per-task wall-clock limits (R5).
585 started_at: std::time::Instant,
586 /// The worker's visible final answer from its terminal exec receipt. This
587 /// is the task's deliverable for report/summary work that produces no
588 /// file artifact; surfaced as `Completed.summary` and in the receipt note
589 /// so receipts stop reporting "no verifiable output" for a worker that
590 /// wrote a full report. Bounded by the emitter, so nothing accumulates
591 /// here.
592 final_answer: Option<FleetWorkerFinalAnswer>,
593 /// Saved exec session id reported by the worker's `session_capture` event.
594 /// Resolving it via `GET /v1/sessions/{id}` yields the full transcript
595 /// (the worker's final assistant reply).
596 saved_session_id: Option<String>,
597 /// Parent-owned capture identity in the Runtime's existing session store.
598 session_capture: Option<(String, std::path::PathBuf)>,
599 }
600
601 impl WorkerStream {
602 /// Observe one raw stream-json frame: record route evidence, the final
603 /// answer, and the saved-session id, and map it to a ledger payload. The
604 /// frame is parsed exactly once.
605 fn observe_line(&mut self, line: &[u8]) -> Option<FleetWorkerEventPayload> {
606 let Ok(line) = std::str::from_utf8(line) else {
607 // stream-json is a UTF-8 contract. Never accept a lossy-decoded route
608 // receipt: replacement characters could turn corrupt provider/model
609 // bytes into apparently valid provenance.
610 self.terminal_route.observe(ParsedTerminalRoute::Invalid);
611 return None;
612 };
613 let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
614 self.terminal_route
615 .observe(parse_exec_terminal_route(&value));
616 if exec_terminal_meta(&value).is_some() {
617 self.final_answer = parse_exec_terminal_final_answer(&value);
618 }
619 if value.get("type").and_then(serde_json::Value::as_str) == Some("session_capture")
620 && let Some(id) = value
621 .get("saved_session_id")
622 .and_then(serde_json::Value::as_str)
623 .map(str::trim)
624 .filter(|id| !id.is_empty())
625 {
626 if self
627 .session_capture
628 .as_ref()
629 .is_some_and(|(expected, _)| expected == id)
630 {
631 self.saved_session_id = Some(id.to_string());
632 } else {
633 // A worker log cannot select an unrelated local transcript.
634 self.session_capture = None;
635 self.saved_session_id = None;
636 }
637 }
638 map_exec_stream_value(&value)
639 }
640 }
641
642 #[derive(Debug, Clone, Default)]
643 enum TerminalRouteEvidence {
644 #[default]
645 Missing,
646 Valid(FleetWorkerReportedRoute),
647 InvalidOrAmbiguous,
648 }
649
650 impl TerminalRouteEvidence {
651 fn observe(&mut self, parsed: ParsedTerminalRoute) {
652 match parsed {
653 ParsedTerminalRoute::NotTerminal => {}
654 ParsedTerminalRoute::Invalid => *self = Self::InvalidOrAmbiguous,
655 ParsedTerminalRoute::Valid(route) => {
656 *self = if matches!(&*self, Self::Missing) {
657 Self::Valid(route)
658 } else {
659 // The stream contract emits exactly one terminal receipt.
660 // Any second record, even an identical one, is ambiguous
661 // provenance and must permanently fail closed.
662 Self::InvalidOrAmbiguous
663 };
664 }
665 }
666 }
667
668 fn reported_route(&self) -> Option<&FleetWorkerReportedRoute> {
669 match self {
670 Self::Valid(route) => Some(route),
671 Self::Missing | Self::InvalidOrAmbiguous => None,
672 }
673 }
674 }
675
676 enum WorkerStreamHost {
677 Local,
678 Ssh(String),
679 }
680
681 #[derive(Debug, Clone)]
682 pub struct FleetWorkerReportedRoute {
683 pub provider: String,
684 pub provider_exact_id: Option<String>,
685 pub model: String,
686 }
687
688 #[derive(Debug, Clone)]
689 pub struct FleetWorkerTerminalEvent {
690 pub payload: FleetWorkerEventPayload,
691 pub exit_code: Option<i32>,
692 /// Non-terminal payloads discovered by the mandatory post-exit drain.
693 pub tail_payloads: Vec<FleetWorkerEventPayload>,
694 pub reported_route: Option<FleetWorkerReportedRoute>,
695 /// The worker's visible final answer from its terminal exec receipt,
696 /// whatever the outcome: a worker that fails after writing most of a
697 /// report keeps the text on its receipt.
698 pub final_answer: Option<FleetWorkerFinalAnswer>,
699 /// Saved exec session id reported by the worker's `session_capture` event,
700 /// when one was persisted on completion.
701 pub saved_session_id: Option<String>,
702 /// A real headless exec process must report its actual route. Callers use
703 /// this bit to distinguish a missing/invalid report (fail closed) from
704 /// pre-launch or simulated paths that only have declared route intent.
705 pub requires_reported_route: bool,
706 }
707
708 impl FleetExecutor {
709 pub fn new(workspace: impl AsRef<std::path::Path>) -> Self {
710 let workspace = workspace.as_ref().to_path_buf();
711 Self {
712 adapter: super::host::LocalProcessFleetHostAdapter::new(&workspace),
713 workspace,
714 sessions_dir: None,
715 ssh_adapters: std::collections::BTreeMap::new(),
716 streams: std::collections::BTreeMap::new(),
717 }
718 }
719
720 /// Share the caller's SessionManager directory; never create a second store.
721 pub fn with_sessions_dir(mut self, sessions_dir: std::path::PathBuf) -> Self {
722 self.sessions_dir = Some(sessions_dir);
723 self
724 }
725
726 /// Start a worker process and begin tracking its event stream.
727 pub fn start_worker(
728 &mut self,
729 worker_id: &str,
730 command: FleetWorkerCommand,
731 cwd: Option<std::path::PathBuf>,
732 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
733 self.start_worker_on_host(worker_id, &FleetHostSpec::Local, command, cwd)
734 }
735
736 /// Start a worker on the requested fleet host.
737 pub fn start_worker_on_host(
738 &mut self,
739 worker_id: &str,
740 host: &FleetHostSpec,
741 command: FleetWorkerCommand,
742 cwd: Option<std::path::PathBuf>,
743 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
744 self.start_worker_on_host_inner(worker_id, host, command, cwd, None)
745 }
746
747 /// Start the concrete process for one exact durable Fleet lease.
748 pub fn start_worker_attempt_on_host(
749 &mut self,
750 worker_id: &str,
751 host: &FleetHostSpec,
752 command: FleetWorkerCommand,
753 cwd: Option<std::path::PathBuf>,
754 attempt: FleetExecutorAttempt,
755 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
756 self.start_worker_on_host_inner(worker_id, host, command, cwd, Some(attempt))
757 }
758
759 fn start_worker_on_host_inner(
760 &mut self,
761 worker_id: &str,
762 host: &FleetHostSpec,
763 command: FleetWorkerCommand,
764 cwd: Option<std::path::PathBuf>,
765 attempt: Option<FleetExecutorAttempt>,
766 ) -> super::host::FleetHostResult<super::host::FleetWorkerHandle> {
767 let mut request = super::host::FleetWorkerStartRequest::new(worker_id, command);
768 request.cwd = cwd;
769 let session_capture = if matches!(host, FleetHostSpec::Local) {
770 self.sessions_dir.as_ref().map(|dir| {
771 let id = uuid::Uuid::new_v4().to_string();
772 for (key, value) in [
773 ("CODEWHALE_FLEET_CAPTURE_ID", id.clone()),
774 (
775 "CODEWHALE_FLEET_CAPTURE_DIR",
776 dir.to_string_lossy().into_owned(),
777 ),
778 ] {
779 request.env.insert(key.to_string(), value);
780 request.env_allowlist.insert(key.to_string());
781 }
782 // Preserve an explicit local account/config root as well.
783 if let Some(home) = std::env::var_os("CODEWHALE_HOME") {
784 request
785 .env
786 .insert("CODEWHALE_HOME".into(), home.to_string_lossy().into_owned());
787 request.env_allowlist.insert("CODEWHALE_HOME".into());
788 }
789 (id, dir.clone())
790 })
791 } else {
792 // SSH transcripts live remotely and have no local retrieval link.
793 None
794 };
795 let (handle, host) = match host {
796 FleetHostSpec::Local => {
797 let handle = self.adapter.start_worker(request)?;
798 (handle, WorkerStreamHost::Local)
799 }
800 FleetHostSpec::Ssh { .. } => {
801 let config = super::host::SshFleetHostConfig::from_host_spec(host)?;
802 let key = worker_id.to_string();
803 let adapter = self.ssh_adapters.entry(key.clone()).or_insert(
804 super::host::SshFleetHostAdapter::new(&self.workspace, config)?,
805 );
806 let handle = adapter.start_worker(request)?;
807 (handle, WorkerStreamHost::Ssh(key))
808 }
809 FleetHostSpec::Docker { image, .. } => {
810 return Err(super::host::FleetHostError {
811 kind: super::host::FleetHostErrorKind::Configuration,
812 message: format!("docker Fleet workers are not wired yet (image {image})"),
813 });
814 }
815 };
816 self.streams.insert(
817 worker_id.to_string(),
818 WorkerStream {
819 log_path: handle.log_path.clone(),
820 host,
821 attempt,
822 offset: 0,
823 pending: Vec::new(),
824 terminal: false,
825 terminal_route: TerminalRouteEvidence::default(),
826 started_at: std::time::Instant::now(),
827 final_answer: None,
828 saved_session_id: None,
829 session_capture,
830 },
831 );
832 Ok(handle)
833 }
834
835 pub fn is_tracking(&self, worker_id: &str) -> bool {
836 self.streams.contains_key(worker_id)
837 }
838
839 pub fn worker_ids(&self) -> Vec<String> {
840 self.streams.keys().cloned().collect()
841 }
842
843 pub fn tracked_attempt(&self, worker_id: &str) -> Option<FleetExecutorAttempt> {
844 self.streams
845 .get(worker_id)
846 .and_then(|stream| stream.attempt.clone())
847 }
848
849 /// Wall-clock time this worker process has been running (R5). `None` when
850 /// the worker is not tracked.
851 #[must_use]
852 pub fn worker_running_for(&self, worker_id: &str) -> Option<std::time::Duration> {
853 self.streams
854 .get(worker_id)
855 .map(|stream| stream.started_at.elapsed())
856 }
857
858 /// Stop a tracked worker at the host boundary.
859 ///
860 /// Operator controls run in a separate process from the foreground Fleet
861 /// manager, so they communicate cancellation through the durable ledger.
862 /// The manager calls this method after observing that terminal state; the
863 /// executor is the only owner that can reliably reach the live local/SSH
864 /// adapter handle.
865 pub fn stop_worker(&mut self, worker_id: &str) -> Result<()> {
866 let ssh_key = match self.streams.get(worker_id).map(|stream| &stream.host) {
867 Some(WorkerStreamHost::Local) => None,
868 Some(WorkerStreamHost::Ssh(key)) => Some(key.clone()),
869 None => return Ok(()),
870 };
871 if let Some(key) = ssh_key {
872 let adapter = self.ssh_adapters.get_mut(&key).ok_or_else(|| {
873 anyhow::anyhow!("tracked SSH Fleet worker {worker_id} has no host adapter")
874 })?;
875 adapter.stop_worker(worker_id)?;
876 } else {
877 self.adapter.stop_worker(worker_id)?;
878 }
879 Ok(())
880 }
881
882 /// Stop tracking a terminal worker so the scheduler can reuse the same
883 /// logical worker id for the next queued task.
884 pub fn forget_worker(&mut self, worker_id: &str) {
885 let Some(stream) = self.streams.remove(worker_id) else {
886 return;
887 };
888 match stream.host {
889 WorkerStreamHost::Local => {
890 let _ = self.adapter.cleanup_worker(worker_id);
891 }
892 WorkerStreamHost::Ssh(key) => {
893 if let Some(adapter) = self.ssh_adapters.get_mut(&key) {
894 let _ = adapter.cleanup_worker(worker_id);
895 }
896 self.ssh_adapters.remove(&key);
897 }
898 }
899 }
900
901 /// Maximum bytes drained from a worker log per call, and the cap for the
902 /// buffered partial line. Event lines are small; the remainder stays for
903 /// the next drain.
904 const MAX_DRAIN_BYTES: u64 = 1024 * 1024;
905
906 /// Read any newly-written stream-json lines for a worker and map them to
907 /// fleet ledger events. Safe to call repeatedly; only new bytes are parsed,
908 /// and a trailing partial line is buffered until its newline arrives.
909 pub fn drain_events(&mut self, worker_id: &str) -> Vec<FleetWorkerEventPayload> {
910 let Some(stream) = self.streams.get_mut(worker_id) else {
911 return Vec::new();
912 };
913 let mut events = Vec::new();
914 let Ok(mut file) = std::fs::File::open(&stream.log_path) else {
915 return events;
916 };
917 use std::io::{Read, Seek, SeekFrom};
918 if file.seek(SeekFrom::Start(stream.offset)).is_err() {
919 return events;
920 }
921 let mut buf = Vec::new();
922 if let Ok(read) = file.take(Self::MAX_DRAIN_BYTES).read_to_end(&mut buf) {
923 stream.offset += read as u64;
924 stream.pending.extend_from_slice(&buf);
925 while let Some(idx) = stream.pending.iter().position(|byte| *byte == b'\n') {
926 let line: Vec<u8> = stream.pending.drain(..=idx).collect();
927 if let Some(event) = stream.observe_line(&line) {
928 events.push(event);
929 }
930 }
931 // Whatever remains has no newline; drop it rather than buffering
932 // a newline-free flood forever.
933 if stream.pending.len() as u64 > Self::MAX_DRAIN_BYTES {
934 tracing::debug!(
935 worker_id,
936 dropped_bytes = stream.pending.len(),
937 "fleet drain dropped a newline-free flood exceeding the per-call budget"
938 );
939 stream.pending.clear();
940 }
941 }
942 events
943 }
944
945 /// Poll the worker process; once it exits, return the terminal event exactly
946 /// once. Returns `None` while the worker is still running or already
947 /// finalized.
948 pub fn poll_terminal(&mut self, worker_id: &str) -> Option<FleetWorkerEventPayload> {
949 self.poll_terminal_with_status(worker_id)
950 .map(|event| event.payload)
951 }
952
953 /// Poll the worker process and include the raw exit code for receipt
954 /// verification.
955 pub fn poll_terminal_with_status(
956 &mut self,
957 worker_id: &str,
958 ) -> Option<FleetWorkerTerminalEvent> {
959 if self.streams.get(worker_id).is_none_or(|s| s.terminal) {
960 return None;
961 }
962 let status = match self.streams.get(worker_id).map(|s| &s.host)? {
963 WorkerStreamHost::Local => self.adapter.read_status(worker_id).ok()?,
964 WorkerStreamHost::Ssh(key) => self
965 .ssh_adapters
966 .get_mut(key)
967 .and_then(|adapter| adapter.read_status(worker_id).ok())?,
968 };
969 let mut terminal = match status.state {
970 super::host::FleetHostWorkerState::Running
971 | super::host::FleetHostWorkerState::Draining
972 | super::host::FleetHostWorkerState::Unknown => return None,
973 super::host::FleetHostWorkerState::Stopped => {
974 classify_worker_exit(status.exit_code, true)
975 }
976 super::host::FleetHostWorkerState::Exited
977 | super::host::FleetHostWorkerState::Failed => {
978 classify_worker_exit(status.exit_code, false)
979 }
980 };
981 // Once status is terminal the worker can no longer append. Drain one
982 // final time before snapshotting route evidence so metadata written
983 // between the scheduler's ordinary drain and this status poll cannot
984 // be lost when the worker is forgotten.
985 let mut tail_payloads = self.drain_events(worker_id);
986 let stream = self.streams.get_mut(worker_id)?;
987 let trailing_line = std::mem::take(&mut stream.pending);
988 if trailing_line.iter().any(|byte| !byte.is_ascii_whitespace())
989 && let Some(payload) = stream.observe_line(&trailing_line)
990 {
991 tail_payloads.push(payload);
992 }
993 stream.terminal = true;
994 let final_answer = stream.final_answer.take();
995 // Surface the visible final answer on a successful completion so
996 // report/summary tasks (no scorer, no file artifact) show their
997 // deliverable instead of "no verifiable output". `Failed` has no
998 // summary slot; the receipt keeps the text via `final_answer`.
999 if let (Some(answer), FleetWorkerEventPayload::Completed { summary, .. }) =
1000 (final_answer.as_ref(), &mut terminal)
1001 {
1002 *summary = Some(answer.excerpt.clone());
1003 }
1004 Some(FleetWorkerTerminalEvent {
1005 payload: terminal,
1006 exit_code: status.exit_code,
1007 tail_payloads,
1008 reported_route: stream.terminal_route.reported_route().cloned(),
1009 final_answer,
1010 saved_session_id: stream.saved_session_id.take().filter(|id| {
1011 stream
1012 .session_capture
1013 .as_ref()
1014 .is_some_and(|(expected, dir)| {
1015 id == expected
1016 && crate::session_manager::SessionManager::new(dir.clone())
1017 .and_then(|manager| manager.load_session(id))
1018 .is_ok()
1019 })
1020 }),
1021 requires_reported_route: true,
1022 })
1023 }
1024
1025 /// True once every started worker has reached a terminal state.
1026 pub fn all_terminal(&self) -> bool {
1027 !self.streams.is_empty() && self.streams.values().all(|s| s.terminal)
1028 }
1029 }
1030
1031 #[cfg(test)]
1032 mod tests {
1033 use super::*;
1034 use codewhale_config::{
1035 FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole,
1036 FleetSlot,
1037 };
1038 use codewhale_protocol::fleet::{
1039 FleetHostSpec, FleetTaskBudget, FleetTaskSpec, FleetTaskWorkerProfile, FleetWorkerSpec,
1040 FleetWorkspaceRequirements,
1041 };
1042 use std::collections::BTreeMap;
1043 use tempfile::TempDir;
1044
1045 fn task(instructions: &str) -> FleetTaskSpec {
1046 FleetTaskSpec {
1047 id: "t1".to_string(),
1048 name: "Smoke".to_string(),
1049 description: None,
1050 objective: Some("prove it runs".to_string()),
1051 instructions: instructions.to_string(),
1052 worker: Some(FleetTaskWorkerProfile {
1053 agent_profile: None,
1054 role: Some("reviewer".to_string()),
1055 loadout: None,
1056 model_class: None,
1057 model: None,
1058 tool_profile: Some("read-only".to_string()),
1059 tools: vec![],
1060 capabilities: vec![],
1061 }),
1062 workspace: None,
1063 input_files: vec![],
1064 context: vec![],
1065 budget: None,
1066 tags: vec![],
1067 expected_artifacts: vec![],
1068 scorer: None,
1069 retry_policy: None,
1070 alert_policy: None,
1071 timeout_seconds: None,
1072 metadata: BTreeMap::new(),
1073 }
1074 }
1075
1076 fn agent_profile(id: &str, role: &str, instructions: &str) -> AgentProfile {
1077 AgentProfile {
1078 id: id.to_string(),
1079 display_name: Some(format!("{role} profile")),
1080 description: Some(format!("{role} description")),
1081 requires: Vec::new(),
1082 profile: FleetProfile {
1083 slot: FleetSlot::from_name(role),
1084 role: FleetRole {
1085 name: role.to_string(),
1086 description: None,
1087 instructions: Some(instructions.to_string()),
1088 },
1089 loadout: FleetLoadout::Inherit,
1090 model: None,
1091 provider: None,
1092 reasoning_effort: None,
1093 permissions: FleetProfilePermissions::default(),
1094 delegation: FleetDelegationHints::default(),
1095 },
1096 source: std::path::PathBuf::from(format!("{id}.toml")),
1097 origin: crate::fleet::roster::ProfileOrigin::Workspace,
1098 plugin_authority: None,
1099 }
1100 }
1101
1102 fn launch_spec(task: &FleetTaskSpec, workspace: &std::path::Path) -> AgentWorkerSpec {
1103 let worker = FleetWorkerSpec {
1104 id: "worker-1".to_string(),
1105 name: "Worker 1".to_string(),
1106 host: FleetHostSpec::Local,
1107 trust_level: None,
1108 labels: BTreeMap::new(),
1109 capabilities: Vec::new(),
1110 max_concurrent_tasks: Some(1),
1111 };
1112 crate::fleet::worker_runtime::fleet_task_to_worker_spec_with_profiles(
1113 "worker-1",
1114 "run-1",
1115 task,
1116 &worker,
1117 "auto",
1118 workspace,
1119 workspace,
1120 &[],
1121 None,
1122 )
1123 .unwrap()
1124 }
1125
1126 fn track_test_stream(
1127 executor: &mut FleetExecutor,
1128 worker_id: &str,
1129 log_path: std::path::PathBuf,
1130 ) {
1131 executor.streams.insert(
1132 worker_id.to_string(),
1133 WorkerStream {
1134 log_path,
1135 host: WorkerStreamHost::Local,
1136 attempt: None,
1137 offset: 0,
1138 pending: Vec::new(),
1139 terminal: false,
1140 terminal_route: TerminalRouteEvidence::default(),
1141 started_at: std::time::Instant::now(),
1142 final_answer: None,
1143 saved_session_id: None,
1144 session_capture: None,
1145 },
1146 );
1147 }
1148
1149 fn append_test_stream(path: &std::path::Path, bytes: &[u8]) {
1150 use std::io::Write as _;
1151
1152 std::fs::OpenOptions::new()
1153 .append(true)
1154 .open(path)
1155 .unwrap()
1156 .write_all(bytes)
1157 .unwrap();
1158 }
1159
1160 #[test]
1161 fn worker_command_is_a_headless_codewhale_exec_run() {
1162 let exec = FleetExecConfig::default();
1163 let cmd = build_worker_exec_command("codewhale", &task("read the file"), &exec, None);
1164 assert_eq!(cmd.program, "codewhale");
1165 assert_eq!(cmd.args[0], "exec");
1166 assert!(cmd.args.contains(&"--auto".to_string()));
1167 // stream-json so the executor can ingest the worker's event stream.
1168 let joined = cmd.args.join(" ");
1169 assert!(joined.contains("--output-format stream-json"));
1170 // The task instructions ride in the positional prompt (last arg).
1171 assert!(cmd.args.last().unwrap().contains("read the file"));
1172 }
1173
1174 #[test]
1175 fn worker_command_threads_exec_hardening_flags() {
1176 let exec = FleetExecConfig {
1177 allowed_tools: vec!["read_file".to_string(), "grep_files".to_string()],
1178 disallowed_tools: vec!["exec_shell".to_string()],
1179 max_turns: 40,
1180 append_system_prompt: "never push to main".to_string(),
1181 ..FleetExecConfig::default()
1182 };
1183 let cmd = build_worker_exec_command("codewhale", &task("audit"), &exec, Some("glm-5.1"));
1184 let exec_idx = cmd
1185 .args
1186 .iter()
1187 .position(|arg| arg == "exec")
1188 .expect("worker command must contain exec");
1189 let model_idx = cmd
1190 .args
1191 .iter()
1192 .position(|arg| arg == "--model")
1193 .expect("worker command must contain --model");
1194 assert!(
1195 model_idx < exec_idx,
1196 "global --model must precede exec: {:?}",
1197 cmd.args
1198 );
1199 let joined = cmd.args.join(" ");
1200 assert!(joined.contains("--model glm-5.1"));
1201 assert!(joined.contains("--allowed-tools read_file,grep_files"));
1202 assert!(joined.contains("--disallowed-tools exec_shell"));
1203 assert!(joined.contains("--max-turns 40"));
1204 assert!(cmd.args.iter().any(|a| a == "never push to main"));
1205 }
1206
1207 #[test]
1208 fn worker_command_threads_positive_task_budgets_and_caps_steps() {
1209 let mut task = task("audit");
1210 task.budget = Some(FleetTaskBudget {
1211 max_steps: Some(75),
1212 max_tool_calls: Some(11),
1213 ..FleetTaskBudget::default()
1214 });
1215 let exec = FleetExecConfig {
1216 max_turns: 40,
1217 ..FleetExecConfig::default()
1218 };
1219
1220 let cmd = build_worker_exec_command("codewhale", &task, &exec, None);
1221 let max_turns_idx = cmd
1222 .args
1223 .iter()
1224 .position(|arg| arg == "--max-turns")
1225 .expect("positive max_steps must reach exec");
1226 let max_tool_calls_idx = cmd
1227 .args
1228 .iter()
1229 .position(|arg| arg == "--max-tool-calls")
1230 .expect("positive max_tool_calls must reach exec");
1231
1232 assert_eq!(cmd.args[max_turns_idx + 1], "40");
1233 assert_eq!(cmd.args[max_tool_calls_idx + 1], "11");
1234 }
1235
1236 #[test]
1237 fn production_worker_command_uses_hardened_launch_spec_steps() {
1238 let tmp = TempDir::new().unwrap();
1239 let mut task = task("audit");
1240 task.budget = Some(FleetTaskBudget {
1241 max_steps: Some(75),
1242 max_tool_calls: Some(11),
1243 ..FleetTaskBudget::default()
1244 });
1245 let mut launch_spec = launch_spec(&task, tmp.path());
1246 // The manager owns this hardening step. A different value here proves
1247 // production argv comes from the registered spec, not a second budget
1248 // projection from the task document.
1249 launch_spec.max_steps = 13;
1250
1251 let cmd = build_worker_exec_command_with_launch_spec(
1252 "codewhale",
1253 &task,
1254 &launch_spec,
1255 &FleetExecConfig {
1256 max_turns: 40,
1257 ..FleetExecConfig::default()
1258 },
1259 None,
1260 &[],
1261 )
1262 .unwrap();
1263 let max_turns_idx = cmd
1264 .args
1265 .iter()
1266 .position(|arg| arg == "--max-turns")
1267 .expect("hardened launch max_steps must reach exec");
1268 let max_tool_calls_idx = cmd
1269 .args
1270 .iter()
1271 .position(|arg| arg == "--max-tool-calls")
1272 .expect("task max_tool_calls must reach exec");
1273
1274 assert_eq!(cmd.args[max_turns_idx + 1], "13");
1275 assert_eq!(cmd.args[max_tool_calls_idx + 1], "11");
1276 }
1277
1278 #[test]
1279 fn worker_command_threads_agent_profile_prompt() {
1280 let mut task = task("audit");
1281 task.worker.as_mut().unwrap().agent_profile = Some("reviewer".to_string());
1282 let cmd = build_worker_exec_command_with_profiles(
1283 "codewhale",
1284 &task,
1285 &FleetExecConfig::default(),
1286 None,
1287 &[agent_profile(
1288 "reviewer",
1289 "reviewer",
1290 "Focus on defects, regressions, and missing tests.",
1291 )],
1292 )
1293 .unwrap();
1294 let prompt = cmd.args.last().unwrap();
1295
1296 assert!(prompt.contains("Fleet profile: reviewer"));
1297 assert!(prompt.contains("Focus on defects, regressions, and missing tests."));
1298 }
1299
1300 #[test]
1301 fn consultant_launch_spec_carries_read_only_authority_with_network_reads() {
1302 let tmp = TempDir::new().unwrap();
1303 let mut task = task("advise on the release candidate");
1304 task.worker.as_mut().unwrap().role = Some("consultant".to_string());
1305 let launch_spec = launch_spec(&task, tmp.path());
1306 assert_eq!(
1307 launch_spec.agent_type,
1308 crate::tools::subagent::FleetRole::Consultant
1309 );
1310 // Counsel only: never writes the workspace, but may read the web.
1311 assert!(!launch_spec.runtime_profile.permissions.write);
1312 assert!(launch_spec.runtime_profile.permissions.network);
1313
1314 let cmd = build_worker_exec_command_with_launch_spec(
1315 "codewhale",
1316 &task,
1317 &launch_spec,
1318 &FleetExecConfig::default(),
1319 None,
1320 &[],
1321 )
1322 .unwrap();
1323
1324 assert_eq!(cmd.args.last(), Some(&launch_spec.objective));
1325 let authority_index = cmd
1326 .args
1327 .iter()
1328 .position(|arg| arg == "--tool-authority-json")
1329 .expect("launch command must carry machine-readable authority");
1330 let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap();
1331 assert_eq!(authority.owner, "worker-1");
1332 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
1333 assert_eq!(authority.network_access, Some(true));
1334 assert_eq!(authority.shell, ToolShellAuthority::None);
1335 assert_eq!(authority.verification, ToolVerificationAuthority::None);
1336 assert!(authority.writable_roots.is_empty());
1337 assert!(authority.writable_files.is_empty());
1338 assert!(authority.coordination_contracts.is_empty());
1339 }
1340
1341 #[test]
1342 fn scout_reviewer_and_planner_launches_carry_only_read_only_shell_authority() {
1343 let tmp = TempDir::new().unwrap();
1344 for role in ["scout", "reviewer", "planner"] {
1345 let mut task = task("inspect repository and GitHub state");
1346 task.worker.as_mut().unwrap().role = Some(role.to_string());
1347 let launch_spec = launch_spec(&task, tmp.path());
1348 let cmd = build_worker_exec_command_with_launch_spec(
1349 "codewhale",
1350 &task,
1351 &launch_spec,
1352 &FleetExecConfig::default(),
1353 None,
1354 &[],
1355 )
1356 .unwrap();
1357 let authority_index = cmd
1358 .args
1359 .iter()
1360 .position(|arg| arg == "--tool-authority-json")
1361 .expect("launch command must carry machine-readable authority");
1362 let authority =
1363 ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap();
1364
1365 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
1366 assert_eq!(authority.network_access, Some(true));
1367 assert_eq!(authority.shell, ToolShellAuthority::ReadOnly);
1368 assert_eq!(authority.verification, ToolVerificationAuthority::None);
1369 }
1370 }
1371
1372 #[test]
1373 fn verifier_launch_carries_only_bounded_verification_process_authority() {
1374 let tmp = TempDir::new().unwrap();
1375 let mut task = task("run the focused release checks");
1376 task.worker.as_mut().unwrap().role = Some("verifier".to_string());
1377 let mut launch_spec = launch_spec(&task, tmp.path());
1378 let authority = authority_envelope_for_worker(&launch_spec, &task).unwrap();
1379
1380 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
1381 assert_eq!(authority.shell, ToolShellAuthority::None);
1382 assert_eq!(authority.verification, ToolVerificationAuthority::Bounded);
1383
1384 launch_spec.runtime_profile.shell = crate::worker_profile::ShellPolicy::None;
1385 let shellless = authority_envelope_for_worker(&launch_spec, &task).unwrap();
1386 assert_eq!(
1387 shellless.verification,
1388 ToolVerificationAuthority::None,
1389 "the parent shell ceiling also removes bounded verification process authority"
1390 );
1391 }
1392
1393 #[test]
1394 fn launch_spec_command_preserves_exact_write_scope() {
1395 let tmp = TempDir::new().unwrap();
1396 let mut task = task("edit the bounded source tree");
1397 let worker = task.worker.as_mut().unwrap();
1398 worker.role = Some("implementer".to_string());
1399 worker.tool_profile = None;
1400 task.workspace = Some(FleetWorkspaceRequirements {
1401 writable_paths: vec![std::path::PathBuf::from("src")],
1402 ..FleetWorkspaceRequirements::default()
1403 });
1404 let launch_spec = launch_spec(&task, tmp.path());
1405
1406 let cmd = build_worker_exec_command_with_launch_spec(
1407 "codewhale",
1408 &task,
1409 &launch_spec,
1410 &FleetExecConfig::default(),
1411 None,
1412 &[],
1413 )
1414 .unwrap();
1415 let authority_index = cmd
1416 .args
1417 .iter()
1418 .position(|arg| arg == "--tool-authority-json")
1419 .expect("launch command must carry machine-readable authority");
1420 let authority = ToolAuthorityEnvelope::from_json(&cmd.args[authority_index + 1]).unwrap();
1421
1422 assert_eq!(authority.authority, ToolMutationAuthority::ScopedWrite);
1423 assert_eq!(authority.network_access, Some(true));
1424 assert_eq!(authority.shell, ToolShellAuthority::None);
1425 assert_eq!(authority.verification, ToolVerificationAuthority::None);
1426 assert_eq!(authority.writable_roots, ["src"]);
1427 assert!(authority.writable_files.is_empty());
1428 assert!(authority.coordination_contracts.is_empty());
1429 assert_eq!(cmd.args.last(), Some(&launch_spec.objective));
1430 }
1431
1432 /// #4093 AC #4 at the LAUNCH boundary (not just the receipt): a worker whose
1433 /// profile pins a DIFFERENT provider+model than the parent session must
1434 /// actually launch on the profile's route and saved reasoning tier. The
1435 /// parent session is DeepSeek here (`--model deepseek-v4-pro`); the profile
1436 /// pins OpenRouter + glm-5.2 + max thinking. The emitted argv must carry
1437 /// OpenRouter's id, the profile's model, and the profile's thinking tier as
1438 /// paired flag/values — never the parent's model. This is the gap the
1439 /// save→load→resolve receipt tests never covered.
1440 #[test]
1441 fn worker_command_launches_profile_bound_provider_and_model_not_the_parent() {
1442 let mut task = task("audit");
1443 task.worker.as_mut().unwrap().agent_profile = Some("cross".to_string());
1444
1445 let mut profile = agent_profile("cross", "scout", "Read first.");
1446 profile.profile.provider = Some("openrouter".to_string());
1447 profile.profile.model = Some("glm-5.2".to_string());
1448 profile.profile.reasoning_effort = Some("max".to_string());
1449
1450 let cmd = build_worker_exec_command_with_profiles(
1451 "codewhale",
1452 &task,
1453 &FleetExecConfig::default(),
1454 Some("deepseek-v4-pro"), // parent/session model on provider A.
1455 &[profile],
1456 )
1457 .unwrap();
1458
1459 // Assert the flag/value PAIRS, so the provider and model are proven to
1460 // ride together rather than merely appearing somewhere on the argv.
1461 let provider_idx = cmd
1462 .args
1463 .iter()
1464 .position(|a| a == "--provider")
1465 .expect("--provider must be threaded for a provider-pinned worker");
1466 let exec_idx = cmd
1467 .args
1468 .iter()
1469 .position(|a| a == "exec")
1470 .expect("worker command must contain exec");
1471 assert_eq!(
1472 cmd.args.get(provider_idx + 1).map(String::as_str),
1473 Some("openrouter"),
1474 "{:?}",
1475 cmd.args
1476 );
1477 assert!(
1478 provider_idx < exec_idx,
1479 "global --provider must precede exec: {:?}",
1480 cmd.args
1481 );
1482 let model_idx = cmd
1483 .args
1484 .iter()
1485 .position(|a| a == "--model")
1486 .expect("--model must be present");
1487 assert_eq!(
1488 cmd.args.get(model_idx + 1).map(String::as_str),
1489 Some("glm-5.2"),
1490 "{:?}",
1491 cmd.args
1492 );
1493 assert!(
1494 model_idx < exec_idx,
1495 "global --model must precede exec: {:?}",
1496 cmd.args
1497 );
1498 let reasoning_idx = cmd
1499 .args
1500 .iter()
1501 .position(|a| a == "--reasoning-effort")
1502 .expect("--reasoning-effort must be present for a thinking-pinned worker");
1503 assert_eq!(
1504 cmd.args.get(reasoning_idx + 1).map(String::as_str),
1505 Some("max"),
1506 "{:?}",
1507 cmd.args
1508 );
1509 assert!(
1510 reasoning_idx > exec_idx,
1511 "exec-only --reasoning-effort must follow exec: {:?}",
1512 cmd.args
1513 );
1514
1515 assert_eq!(
1516 &cmd.args[..exec_idx],
1517 ["--model", "glm-5.2", "--provider", "openrouter"],
1518 "route flags must form the complete global prefix: {:?}",
1519 cmd.args
1520 );
1521 assert_eq!(
1522 &cmd.args[exec_idx..exec_idx + 5],
1523 [
1524 "exec",
1525 "--auto",
1526 "--output-format",
1527 "stream-json",
1528 "--parent-death-watch"
1529 ],
1530 "exec flags must remain behind the subcommand: {:?}",
1531 cmd.args
1532 );
1533
1534 // The parent/session model must NOT leak onto the argv.
1535 assert!(
1536 !cmd.args.iter().any(|a| a == "deepseek-v4-pro"),
1537 "parent model leaked into a profile-pinned worker's argv: {:?}",
1538 cmd.args
1539 );
1540 }
1541
1542 #[test]
1543 fn worker_command_threads_custom_profile_provider_name() {
1544 let mut task = task("format");
1545 task.worker.as_mut().unwrap().agent_profile = Some("local".to_string());
1546
1547 let mut profile = agent_profile("local", "formatter", "Keep edits tight.");
1548 profile.profile.provider = Some("lm-studio".to_string());
1549 profile.profile.model = Some("qwen-2.5-7b".to_string());
1550
1551 let cmd = build_worker_exec_command_with_profiles(
1552 "codewhale",
1553 &task,
1554 &FleetExecConfig::default(),
1555 Some("deepseek-v4-pro"),
1556 &[profile],
1557 )
1558 .unwrap();
1559
1560 let provider_idx = cmd
1561 .args
1562 .iter()
1563 .position(|a| a == "--provider")
1564 .expect("--provider must be threaded for a custom provider pin");
1565 assert_eq!(
1566 cmd.args.get(provider_idx + 1).map(String::as_str),
1567 Some("lm-studio"),
1568 "{:?}",
1569 cmd.args
1570 );
1571 let exec_idx = cmd
1572 .args
1573 .iter()
1574 .position(|a| a == "exec")
1575 .expect("worker command must contain exec");
1576 assert!(
1577 provider_idx < exec_idx,
1578 "global --provider must precede exec: {:?}",
1579 cmd.args
1580 );
1581 let model_idx = cmd
1582 .args
1583 .iter()
1584 .position(|a| a == "--model")
1585 .expect("--model must be present");
1586 assert_eq!(
1587 cmd.args.get(model_idx + 1).map(String::as_str),
1588 Some("qwen-2.5-7b"),
1589 "{:?}",
1590 cmd.args
1591 );
1592 assert!(
1593 model_idx < exec_idx,
1594 "global --model must precede exec: {:?}",
1595 cmd.args
1596 );
1597 }
1598
1599 /// A worker with no profile-bound provider preserves today's behavior: the
1600 /// run-level model on `--model`, and NO `--provider` (the worker keeps its
1601 /// own session default). Guards against regressing profile-less workers.
1602 #[test]
1603 fn worker_command_without_profile_provider_omits_provider_and_keeps_run_model() {
1604 let cmd = build_worker_exec_command_with_profiles(
1605 "codewhale",
1606 &task("read"),
1607 &FleetExecConfig::default(),
1608 Some("deepseek-v4-pro"),
1609 &[],
1610 )
1611 .unwrap();
1612
1613 assert!(
1614 !cmd.args.iter().any(|a| a == "--provider"),
1615 "profile-less worker must not carry --provider: {:?}",
1616 cmd.args
1617 );
1618 assert!(
1619 !cmd.args.iter().any(|a| a == "--reasoning-effort"),
1620 "profile-less worker must not carry --reasoning-effort: {:?}",
1621 cmd.args
1622 );
1623 let model_idx = cmd
1624 .args
1625 .iter()
1626 .position(|a| a == "--model")
1627 .expect("--model must be present");
1628 assert_eq!(
1629 cmd.args.get(model_idx + 1).map(String::as_str),
1630 Some("deepseek-v4-pro"),
1631 "{:?}",
1632 cmd.args
1633 );
1634 let exec_idx = cmd
1635 .args
1636 .iter()
1637 .position(|a| a == "exec")
1638 .expect("worker command must contain exec");
1639 assert!(
1640 model_idx < exec_idx,
1641 "global --model must precede exec: {:?}",
1642 cmd.args
1643 );
1644 }
1645
1646 #[test]
1647 fn zero_max_turns_is_not_passed() {
1648 // Zero task budgets and max_turns mean "no cap"; neither flag should
1649 // appear in the command.
1650 let exec = FleetExecConfig {
1651 max_turns: 0,
1652 ..Default::default()
1653 };
1654 let mut task = task("x");
1655 task.budget = Some(FleetTaskBudget {
1656 max_steps: Some(0),
1657 max_tool_calls: Some(0),
1658 ..FleetTaskBudget::default()
1659 });
1660 let cmd = build_worker_exec_command("codewhale", &task, &exec, None);
1661 assert!(!cmd.args.join(" ").contains("--max-turns"));
1662 assert!(!cmd.args.join(" ").contains("--max-tool-calls"));
1663 }
1664
1665 #[test]
1666 fn default_max_turns_does_not_add_a_hidden_worker_cap() {
1667 use clap::Parser;
1668
1669 let exec = FleetExecConfig::default();
1670 let workspace = TempDir::new().unwrap();
1671 for requested_steps in [None, Some(0), Some(13)] {
1672 let mut task = task("x");
1673 task.budget = requested_steps.map(|max_steps| FleetTaskBudget {
1674 max_steps: Some(max_steps),
1675 ..FleetTaskBudget::default()
1676 });
1677 let spec = launch_spec(&task, workspace.path());
1678 let cmd = build_worker_exec_command_with_launch_spec(
1679 "codewhale",
1680 &task,
1681 &spec,
1682 &exec,
1683 None,
1684 &[],
1685 )
1686 .expect("production worker command");
1687 let cli = crate::Cli::try_parse_from(
1688 std::iter::once("codewhale").chain(cmd.args.iter().map(String::as_str)),
1689 )
1690 .expect("production worker args must parse");
1691 let Some(crate::Commands::Exec(args)) = cli.command else {
1692 panic!("expected worker exec command");
1693 };
1694 let expected = requested_steps.filter(|steps| *steps > 0);
1695 assert_eq!(args.max_turns, expected);
1696 assert_eq!(args.max_tool_calls, None);
1697 // Follow omission beyond argv through the real CLI resolver. This
1698 // previously installed 200 despite correct unbounded launch args.
1699 let turn = crate::core::turn::TurnContext::new(crate::exec_max_steps(args.max_turns));
1700 assert_eq!(turn.step_limit(), expected);
1701 assert_eq!(turn.stop_diagnostics.effective_max_steps, expected);
1702 }
1703 }
1704
1705 #[test]
1706 fn stream_line_maps_tool_use_to_running_tool() {
1707 let line = r#"{"type":"tool_use","name":"read_file","id":"call-7","input":{}}"#;
1708 match map_exec_stream_line(line) {
1709 Some(FleetWorkerEventPayload::RunningTool { tool, call_id }) => {
1710 assert_eq!(tool, "read_file");
1711 assert_eq!(call_id.as_deref(), Some("call-7"));
1712 }
1713 other => panic!("expected RunningTool, got {other:?}"),
1714 }
1715 }
1716
1717 #[test]
1718 fn stream_line_maps_done_and_error() {
1719 assert!(matches!(
1720 map_exec_stream_line(r#"{"type":"done"}"#),
1721 Some(FleetWorkerEventPayload::Completed { .. })
1722 ));
1723 match map_exec_stream_line(r#"{"type":"error","error":"boom"}"#) {
1724 Some(FleetWorkerEventPayload::Failed { reason, .. }) => assert_eq!(reason, "boom"),
1725 other => panic!("expected Failed, got {other:?}"),
1726 }
1727 }
1728
1729 #[test]
1730 fn stream_line_maps_workflow_receipt_to_typed_event() {
1731 let line =
1732 r#"{"type":"workflow_event","run_id":"workflow_1","event":{"type":"task_completed"}}"#;
1733 match map_exec_stream_line(line) {
1734 Some(FleetWorkerEventPayload::WorkflowEvent {
1735 workflow_run_id,
1736 event,
1737 }) => {
1738 assert_eq!(workflow_run_id, "workflow_1");
1739 assert_eq!(event["type"], "task_completed");
1740 }
1741 other => panic!("expected typed workflow receipt, got {other:?}"),
1742 }
1743 }
1744
1745 #[test]
1746 fn stream_line_ignores_noise_and_bad_json() {
1747 assert!(map_exec_stream_line(r#"{"type":"session_capture","content":"x"}"#).is_none());
1748 assert!(map_exec_stream_line("not json").is_none());
1749 assert!(map_exec_stream_line("").is_none());
1750 }
1751
1752 #[test]
1753 fn terminal_route_keeps_exact_literal_custom_distinct_from_idless_root_and_redacts() {
1754 let exact = map_exec_terminal_route(
1755 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"}}"#,
1756 )
1757 .expect("literal custom terminal route");
1758 assert_eq!(exact.provider, "custom");
1759 assert_eq!(exact.provider_exact_id.as_deref(), Some("custom"));
1760 assert_eq!(exact.model, "literal-model");
1761
1762 let root = map_exec_terminal_route(
1763 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","model":"root-model"}}"#,
1764 )
1765 .expect("idless root custom terminal route");
1766 assert_eq!(root.provider, "custom");
1767 assert_eq!(root.provider_exact_id, None);
1768 assert_eq!(root.model, "root-model");
1769
1770 let named = map_exec_terminal_route(
1771 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"lm-studio","model":"local-model"}}"#,
1772 )
1773 .expect("named custom terminal route");
1774 assert_eq!(named.provider, "custom");
1775 assert_eq!(named.provider_exact_id.as_deref(), Some("lm-studio"));
1776 assert_eq!(named.model, "local-model");
1777
1778 let reported = format!("{exact:?}").to_ascii_lowercase();
1779 for forbidden in ["base_url", "https://", "api_key", "sk-must-not-cross"] {
1780 assert!(
1781 !reported.contains(forbidden),
1782 "allowlisted terminal route leaked {forbidden:?}: {reported}"
1783 );
1784 }
1785
1786 for malformed in [
1787 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"","model":"root-model"}}"#,
1788 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":" ","model":"root-model"}}"#,
1789 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":7,"model":"root-model"}}"#,
1790 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"deepseek","provider_id":"custom-x","model":"deepseek-v4-pro"}}"#,
1791 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"unknown-kind","model":"unknown-model"}}"#,
1792 ] {
1793 assert!(
1794 map_exec_terminal_route(malformed).is_none(),
1795 "malformed present exact id must not collapse to idless root: {malformed}"
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn terminal_route_evidence_requires_exactly_one_valid_envelope() {
1802 let route_x = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model-x"}}"#;
1803 let route_y = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-y","model":"worker-model-y"}}"#;
1804 let malformed = r#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"","model":"worker-model-x"}}"#;
1805 let noise = r#"{"type":"content","delta":"progress"}"#;
1806
1807 let observe = |lines: &[&str]| {
1808 let mut evidence = TerminalRouteEvidence::default();
1809 for line in lines {
1810 let value: serde_json::Value = serde_json::from_str(line).unwrap();
1811 evidence.observe(parse_exec_terminal_route(&value));
1812 }
1813 evidence.reported_route().cloned()
1814 };
1815
1816 let only = observe(&[noise, route_x]).expect("one valid route");
1817 assert_eq!(only.provider_exact_id.as_deref(), Some("remote-x"));
1818 assert!(
1819 observe(&[route_x, malformed]).is_none(),
1820 "valid then malformed must invalidate stale evidence"
1821 );
1822 assert!(
1823 observe(&[malformed, route_x]).is_none(),
1824 "malformed then valid must remain invalid"
1825 );
1826 assert!(
1827 observe(&[route_x, route_y]).is_none(),
1828 "conflicting valid routes must be ambiguous"
1829 );
1830 assert!(
1831 observe(&[route_x, route_x]).is_none(),
1832 "even identical duplicates violate the exactly-one contract"
1833 );
1834 }
1835
1836 #[test]
1837 fn exit_classification() {
1838 assert!(matches!(
1839 classify_worker_exit(Some(0), false),
1840 FleetWorkerEventPayload::Completed { .. }
1841 ));
1842 assert!(matches!(
1843 classify_worker_exit(Some(1), false),
1844 FleetWorkerEventPayload::Failed {
1845 recoverable: true,
1846 ..
1847 }
1848 ));
1849 assert!(matches!(
1850 classify_worker_exit(Some(0), true),
1851 FleetWorkerEventPayload::Cancelled { .. }
1852 ));
1853 }
1854
1855 /// End-to-end: run a REAL subprocess that emits stream-json (standing in for
1856 /// `codewhale exec`), and prove the executor drains its events and terminal
1857 /// exit through the real host adapter — no codewhale binary needed. This is
1858 /// the verifiable proof that a fleet worker is an out-of-process exec run.
1859 #[cfg(unix)]
1860 #[test]
1861 fn executor_runs_real_process_and_drains_stream_json_into_ledger_events() {
1862 let tmp = tempfile::TempDir::new().unwrap();
1863 let mut exec = FleetExecutor::new(tmp.path());
1864 let script = r#"printf '{"type":"tool_use","name":"read_file","id":"c1","input":{}}\n'; printf '{"type":"done"}\n'"#;
1865 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
1866 exec.start_worker("w1", command, None).unwrap();
1867
1868 let mut events = Vec::new();
1869 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1870 loop {
1871 events.extend(exec.drain_events("w1"));
1872 if let Some(term) = exec.poll_terminal("w1") {
1873 events.extend(exec.drain_events("w1")); // final flush after exit
1874 events.push(term);
1875 break;
1876 }
1877 assert!(
1878 std::time::Instant::now() < deadline,
1879 "worker did not terminate; events so far: {events:?}"
1880 );
1881 std::thread::sleep(std::time::Duration::from_millis(20));
1882 }
1883
1884 assert!(
1885 events.iter().any(|e| matches!(
1886 e,
1887 FleetWorkerEventPayload::RunningTool { tool, .. } if tool == "read_file"
1888 )),
1889 "expected a RunningTool(read_file) event, got {events:?}"
1890 );
1891 assert!(
1892 events
1893 .iter()
1894 .any(|e| matches!(e, FleetWorkerEventPayload::Completed { .. })),
1895 "expected a terminal Completed event, got {events:?}"
1896 );
1897 assert!(exec.all_terminal());
1898 }
1899
1900 #[cfg(unix)]
1901 fn run_worker_to_terminal(script: &str, worker_id: &str) -> FleetWorkerTerminalEvent {
1902 let tmp = tempfile::TempDir::new().unwrap();
1903 let mut exec = FleetExecutor::new(tmp.path());
1904 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
1905 exec.start_worker(worker_id, command, None).unwrap();
1906
1907 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1908 loop {
1909 exec.drain_events(worker_id);
1910 if let Some(term) = exec.poll_terminal_with_status(worker_id) {
1911 break term;
1912 }
1913 assert!(
1914 std::time::Instant::now() < deadline,
1915 "worker did not terminate in time"
1916 );
1917 std::thread::sleep(std::time::Duration::from_millis(20));
1918 }
1919 }
1920
1921 #[cfg(unix)]
1922 #[test]
1923 fn completed_worker_surfaces_terminal_final_answer_as_summary() {
1924 // Report/summary tasks produce their deliverable as the final
1925 // assistant reply, not a file artifact. The exec side emits a bounded
1926 // excerpt plus the real length on its terminal receipt; the executor
1927 // reads that (never the streamed `content` deltas, which are the run
1928 // thinking out loud) and attaches it to `Completed.summary` and the
1929 // terminal event so a receipt can show the actual result.
1930 let script = r#"printf '%s\n' '{"type":"content","content":"let me look first"}' '{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model","visible_final_answer_chars":9000,"visible_final_answer_excerpt":"the report..."}}' '{"type":"done"}'"#;
1931 let terminal = run_worker_to_terminal(script, "w1");
1932
1933 match &terminal.payload {
1934 FleetWorkerEventPayload::Completed { summary, .. } => {
1935 assert_eq!(summary.as_deref(), Some("the report..."));
1936 }
1937 other => panic!("expected Completed, got {other:?}"),
1938 }
1939 assert_eq!(
1940 terminal.final_answer,
1941 Some(FleetWorkerFinalAnswer {
1942 excerpt: "the report...".to_string(),
1943 chars: 9000,
1944 })
1945 );
1946 }
1947
1948 #[cfg(unix)]
1949 #[test]
1950 fn failed_worker_keeps_terminal_final_answer_on_terminal_event() {
1951 // A worker that fails after writing most of a report still reports
1952 // its visible answer on the terminal receipt; the executor keeps it
1953 // on the terminal event so the receipt can retain the text.
1954 let script = r#"printf '%s\n' '{"type":"error","error":"boom"}' '{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model","visible_final_answer_chars":12,"visible_final_answer_excerpt":"partial text"}}'; exit 1"#;
1955 let terminal = run_worker_to_terminal(script, "w-failed");
1956
1957 assert!(
1958 matches!(terminal.payload, FleetWorkerEventPayload::Failed { .. }),
1959 "{:?}",
1960 terminal.payload
1961 );
1962 assert_eq!(
1963 terminal
1964 .final_answer
1965 .as_ref()
1966 .map(|answer| answer.excerpt.as_str()),
1967 Some("partial text")
1968 );
1969 }
1970
1971 #[cfg(unix)]
1972 #[test]
1973 fn unbound_worker_session_claim_is_not_advertised() {
1974 // The worker persists its full transcript as a saved session and
1975 // reports the recoverable id via `session_capture.saved_session_id`.
1976 // The executor must capture that id on the terminal event so a caller
1977 // can resolve the final assistant reply through `GET /v1/sessions/{id}`.
1978 let script = r#"printf '%s\n' '{"type":"session_capture","content":"<redacted:log-only>","saved_session_id":"session-abc"}' '{"type":"done"}'"#;
1979 let terminal = run_worker_to_terminal(script, "w-session");
1980
1981 assert!(terminal.saved_session_id.is_none());
1982 assert!(terminal.final_answer.is_none());
1983 }
1984
1985 #[cfg(unix)]
1986 #[test]
1987 fn worker_session_capture_requires_the_parent_id_and_a_saved_local_transcript() {
1988 for (persist, forged) in [(true, false), (false, false), (true, true)] {
1989 let tmp = tempfile::TempDir::new().unwrap();
1990 let sessions_dir = tmp.path().join("runtime-sessions");
1991 let manager =
1992 crate::session_manager::SessionManager::new(sessions_dir.clone()).unwrap();
1993 let mut executor = FleetExecutor::new(tmp.path()).with_sessions_dir(sessions_dir);
1994 let script = if forged {
1995 r#"printf '{"type":"session_capture","saved_session_id":"%s"}\n' "$CODEWHALE_FLEET_CAPTURE_ID"; printf '%s\n' '{"type":"session_capture","saved_session_id":"unrelated-session"}'"#
1996 } else {
1997 r#"printf '{"type":"session_capture","saved_session_id":"%s"}\n' "$CODEWHALE_FLEET_CAPTURE_ID""#
1998 };
1999 executor
2000 .start_worker(
2001 "capture-worker",
2002 FleetWorkerCommand::new("sh", ["-c", script]),
2003 None,
2004 )
2005 .unwrap();
2006 let expected = executor.streams["capture-worker"]
2007 .session_capture
2008 .as_ref()
2009 .unwrap()
2010 .0
2011 .clone();
2012 if persist {
2013 let saved = crate::session_manager::create_saved_session_with_id_and_mode(
2014 expected.clone(),
2015 &[],
2016 "fixture-model",
2017 tmp.path(),
2018 0,
2019 None,
2020 Some("exec"),
2021 );
2022 manager.save_session(&saved).unwrap();
2023 }
2024 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2025 let terminal = loop {
2026 if let Some(terminal) = executor.poll_terminal_with_status("capture-worker") {
2027 break terminal;
2028 }
2029 assert!(std::time::Instant::now() < deadline);
2030 std::thread::sleep(std::time::Duration::from_millis(20));
2031 };
2032 assert_eq!(
2033 terminal.saved_session_id,
2034 (persist && !forged).then_some(expected)
2035 );
2036 }
2037 }
2038
2039 #[test]
2040 fn terminal_final_answer_is_bounded_and_redacted_at_the_worker_boundary() {
2041 let answer = parse_exec_terminal_final_answer(&serde_json::json!({
2042 "type": "metadata", "meta": { "receipt_kind": "terminal",
2043 "visible_final_answer_excerpt": format!("sk-ant-must-not-leak-1234567890 {}", "x".repeat(8000)),
2044 "visible_final_answer_chars": 9000,
2045 }
2046 })).unwrap();
2047 assert!(!answer.excerpt.contains("sk-ant-must-not-leak"));
2048 assert!(
2049 answer.excerpt.chars().count() <= crate::EXEC_STREAM_FINAL_ANSWER_EXCERPT_CHARS + 3
2050 );
2051 assert_eq!(answer.chars, 9000);
2052 }
2053
2054 #[test]
2055 fn terminal_final_answer_ignores_empty_and_nonterminal_receipts() {
2056 let parse =
2057 |line: &str| parse_exec_terminal_final_answer(&serde_json::from_str(line).unwrap());
2058 assert!(parse(r#"{"type":"content","content":"streamed"}"#).is_none());
2059 assert!(
2060 parse(r#"{"type":"metadata","meta":{"receipt_kind":"turn","visible_final_answer_excerpt":"x"}}"#)
2061 .is_none()
2062 );
2063 assert!(
2064 parse(r#"{"type":"metadata","meta":{"receipt_kind":"terminal","visible_final_answer_excerpt":" "}}"#)
2065 .is_none()
2066 );
2067 // A receipt without the count falls back to the excerpt length.
2068 assert_eq!(
2069 parse(
2070 r#"{"type":"metadata","meta":{"receipt_kind":"terminal","visible_final_answer_excerpt":"héllo"}}"#
2071 ),
2072 Some(FleetWorkerFinalAnswer {
2073 excerpt: "héllo".to_string(),
2074 chars: 5,
2075 })
2076 );
2077 }
2078
2079 #[cfg(unix)]
2080 #[test]
2081 fn terminal_poll_final_drains_route_metadata_and_tail_payloads() {
2082 let tmp = tempfile::TempDir::new().unwrap();
2083 let mut exec = FleetExecutor::new(tmp.path());
2084 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"}}'"#;
2085 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
2086 exec.start_worker("tail-worker", command, None).unwrap();
2087
2088 // Deliberately do not call the ordinary event drain. Poll only after
2089 // exit, reproducing the scheduler gap where the previous poll saw EOF
2090 // just before the worker wrote its terminal tail.
2091 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2092 let terminal = loop {
2093 if let Some(terminal) = exec.poll_terminal_with_status("tail-worker") {
2094 break terminal;
2095 }
2096 assert!(std::time::Instant::now() < deadline, "terminal worker");
2097 std::thread::sleep(std::time::Duration::from_millis(10));
2098 };
2099 let route = terminal.reported_route.expect("final-drained route");
2100 assert_eq!(route.provider, "custom");
2101 assert_eq!(route.provider_exact_id.as_deref(), Some("remote-x"));
2102 assert_eq!(route.model, "worker-model-x");
2103 assert!(
2104 terminal
2105 .tail_payloads
2106 .iter()
2107 .any(|payload| matches!(payload, FleetWorkerEventPayload::Running))
2108 );
2109 }
2110
2111 #[cfg(unix)]
2112 #[test]
2113 fn terminal_poll_trailing_malformed_route_invalidates_prior_valid_route() {
2114 let tmp = tempfile::TempDir::new().unwrap();
2115 let mut exec = FleetExecutor::new(tmp.path());
2116 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"}}'"#;
2117 let command = FleetWorkerCommand::new("sh", vec!["-c".to_string(), script.to_string()]);
2118 exec.start_worker("ambiguous-tail-worker", command, None)
2119 .unwrap();
2120
2121 // Keep all output for the terminal drain, but wait for real worker
2122 // exit instead of assuming the shell finishes within one timer tick.
2123 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2124 let terminal = loop {
2125 if let Some(terminal) = exec.poll_terminal_with_status("ambiguous-tail-worker") {
2126 break terminal;
2127 }
2128 assert!(std::time::Instant::now() < deadline, "terminal worker");
2129 std::thread::sleep(std::time::Duration::from_millis(10));
2130 };
2131 assert!(
2132 terminal.reported_route.is_none(),
2133 "malformed trailing terminal evidence must invalidate the prior valid route"
2134 );
2135 }
2136
2137 /// Dogfood smoke (#3166): several concurrent exec-style workers with one
2138 /// injected failure. Proves the executor drives a small fleet to terminal
2139 /// outcomes and that a failing worker is classified distinctly from the
2140 /// passing ones — all without the codewhale binary.
2141 #[cfg(unix)]
2142 #[test]
2143 fn executor_drives_concurrent_workers_with_injected_failure() {
2144 let tmp = tempfile::TempDir::new().unwrap();
2145 let mut exec = FleetExecutor::new(tmp.path());
2146
2147 // Three healthy workers emit a tool_use + done; one injected-failure
2148 // worker emits an error event and exits non-zero.
2149 let ok = r#"printf '{"type":"tool_use","name":"grep_files","id":"c","input":{}}\n{"type":"done"}\n'"#;
2150 let bad = r#"printf '{"type":"error","error":"injected failure"}\n'; exit 7"#;
2151 for id in ["w1", "w2", "w3"] {
2152 exec.start_worker(
2153 id,
2154 FleetWorkerCommand::new("sh", vec!["-c".to_string(), ok.to_string()]),
2155 None,
2156 )
2157 .unwrap();
2158 }
2159 exec.start_worker(
2160 "w-fail",
2161 FleetWorkerCommand::new("sh", vec!["-c".to_string(), bad.to_string()]),
2162 None,
2163 )
2164 .unwrap();
2165
2166 let ids = ["w1", "w2", "w3", "w-fail"];
2167 let mut terminals: std::collections::BTreeMap<&str, FleetWorkerEventPayload> =
2168 std::collections::BTreeMap::new();
2169 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
2170 while terminals.len() < ids.len() {
2171 for id in ids {
2172 let _ = exec.drain_events(id);
2173 if let Some(term) = exec.poll_terminal(id) {
2174 terminals.insert(id, term);
2175 }
2176 }
2177 assert!(
2178 std::time::Instant::now() < deadline,
2179 "not all workers terminated: {terminals:?}"
2180 );
2181 std::thread::sleep(std::time::Duration::from_millis(20));
2182 }
2183
2184 assert!(exec.all_terminal());
2185 for id in ["w1", "w2", "w3"] {
2186 assert!(
2187 matches!(terminals[id], FleetWorkerEventPayload::Completed { .. }),
2188 "{id} should pass, got {:?}",
2189 terminals[id]
2190 );
2191 }
2192 assert!(
2193 matches!(terminals["w-fail"], FleetWorkerEventPayload::Failed { .. }),
2194 "injected-failure worker should fail, got {:?}",
2195 terminals["w-fail"]
2196 );
2197 }
2198
2199 #[test]
2200 fn terminal_route_preserves_multibyte_identity_across_read_boundaries() {
2201 let tmp = tempfile::TempDir::new().unwrap();
2202 let log_path = tmp.path().join("split-utf8.jsonl");
2203 std::fs::write(&log_path, []).unwrap();
2204 let mut executor = FleetExecutor::new(tmp.path());
2205 track_test_stream(&mut executor, "split-utf8", log_path.clone());
2206
2207 let provider_id = "深海鲸-供应商";
2208 let model = "深潜-模型";
2209 let line = format!(
2210 "{{\"type\":\"metadata\",\"meta\":{{\"receipt_kind\":\"terminal\",\"provider\":\"custom\",\"provider_id\":\"{provider_id}\",\"model\":\"{model}\"}}}}\n"
2211 );
2212 let bytes = line.as_bytes();
2213 let provider_start = bytes
2214 .windows("鲸".len())
2215 .position(|window| window == "鲸".as_bytes())
2216 .unwrap();
2217 let model_start = bytes
2218 .windows("潜".len())
2219 .position(|window| window == "潜".as_bytes())
2220 .unwrap();
2221 let provider_split = provider_start + 1;
2222 let model_split = model_start + 2;
2223
2224 append_test_stream(&log_path, &bytes[..provider_split]);
2225 assert!(executor.drain_events("split-utf8").is_empty());
2226 append_test_stream(&log_path, &bytes[provider_split..model_split]);
2227 assert!(executor.drain_events("split-utf8").is_empty());
2228 append_test_stream(&log_path, &bytes[model_split..]);
2229 assert!(executor.drain_events("split-utf8").is_empty());
2230
2231 let route = executor
2232 .streams
2233 .get("split-utf8")
2234 .and_then(|stream| stream.terminal_route.reported_route())
2235 .expect("one exact terminal route");
2236 assert_eq!(route.provider, "custom");
2237 assert_eq!(route.provider_exact_id.as_deref(), Some(provider_id));
2238 assert_eq!(route.model, model);
2239 }
2240
2241 #[test]
2242 fn invalid_utf8_terminal_route_fails_closed_without_lossy_identity() {
2243 let tmp = tempfile::TempDir::new().unwrap();
2244 let log_path = tmp.path().join("invalid-utf8.jsonl");
2245 let mut line = br#"{"type":"metadata","meta":{"receipt_kind":"terminal","provider":"custom","provider_id":"remote-x","model":"worker-model"}}"#.to_vec();
2246 let invalid_at = line
2247 .windows(b"remote-x".len())
2248 .position(|window| window == b"remote-x")
2249 .unwrap()
2250 + 3;
2251 line[invalid_at] = 0xff;
2252 line.push(b'\n');
2253 std::fs::write(&log_path, line).unwrap();
2254
2255 let mut executor = FleetExecutor::new(tmp.path());
2256 track_test_stream(&mut executor, "invalid-utf8", log_path);
2257 assert!(executor.drain_events("invalid-utf8").is_empty());
2258 assert!(matches!(
2259 executor
2260 .streams
2261 .get("invalid-utf8")
2262 .map(|stream| &stream.terminal_route),
2263 Some(TerminalRouteEvidence::InvalidOrAmbiguous)
2264 ));
2265 }
2266
2267 #[test]
2268 fn invalid_utf8_nonterminal_line_cannot_synthesize_route_evidence() {
2269 let tmp = tempfile::TempDir::new().unwrap();
2270 let log_path = tmp.path().join("invalid-nonterminal.jsonl");
2271 let mut line = br#"{"type":"content","delta":"ordinary-output"}"#.to_vec();
2272 let invalid_at = line
2273 .windows(b"ordinary-output".len())
2274 .position(|window| window == b"ordinary-output")
2275 .unwrap()
2276 + 4;
2277 line[invalid_at] = 0xff;
2278 line.push(b'\n');
2279 std::fs::write(&log_path, line).unwrap();
2280
2281 let mut executor = FleetExecutor::new(tmp.path());
2282 track_test_stream(&mut executor, "invalid-nonterminal", log_path);
2283 assert!(executor.drain_events("invalid-nonterminal").is_empty());
2284 assert!(
2285 executor
2286 .streams
2287 .get("invalid-nonterminal")
2288 .and_then(|stream| stream.terminal_route.reported_route())
2289 .is_none()
2290 );
2291 }
2292 }
2293
2293 lines RUST