返回 CodeWhale
worker_runtime.rs
根目录 / crates / tui / src / fleet / worker_runtime.rs
1 //! Fleet worker runtime — bridges Fleet task specs to headless sub-agent execution.
2 //!
3 //! This module makes Fleet workers real: instead of simulating task completion,
4 //! each Fleet worker spawns a headless sub-agent that runs the task instructions
5 //! and streams progress back into the Fleet ledger.
6 //!
7 //! Architecture:
8 //! - `FleetTaskSpec` + `FleetWorkerSpec` → `AgentWorkerSpec`
9 //! - `SubAgentManager::register_worker()` tracks the worker
10 //! - Sub-agent spawn happens through the existing `agent` machinery
11 //! - Mailbox events stream into the Fleet ledger as `FleetWorkerEventPayload`
12 //! - `FleetWorkerInspection` reads both ledger state and sub-agent worker records
13
14 #![allow(dead_code)]
15
16 use std::borrow::Cow;
17 use std::path::PathBuf;
18
19 use anyhow::{Result, bail};
20 use codewhale_protocol::fleet::{
21 FleetEffectivePermissions, FleetResolvedRoute, FleetTaskSpec, FleetTaskWorkerProfile,
22 FleetWorkerSpec,
23 };
24 use serde::{Deserialize, Serialize};
25
26 use super::identity::{FleetSelectorError, resolve_member_in_profiles};
27 use super::profile::{
28 AgentProfile, FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions,
29 FleetRole as FleetProfileRole, FleetSlot, ProfileOrigin, canonical_public_role_name,
30 };
31 use super::role::runtime_role_for_member;
32 use crate::config::{Config, ProviderKind};
33 use crate::route_runtime::resolve_route_candidate;
34 use crate::tools::subagent::{AgentWorkerSpec, AgentWorkerToolProfile, FleetRole};
35 use crate::worker_profile::{ChildLaunchManifest, ModelRoute, ToolScope, WorkerRuntimeProfile};
36
37 /// Reserved durable task metadata written after author input is validated.
38 ///
39 /// Keeping the selected identity snapshot inside the already-durable task
40 /// record prevents a queued run or retry from silently changing member/model
41 /// when a profile file is edited after run creation.
42 pub(crate) const FROZEN_FLEET_MEMBER_METADATA_KEY: &str = "_codewhale.frozen_fleet_member.v1";
43
44 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45 struct FrozenFleetMember {
46 schema_version: u32,
47 id: String,
48 display_name: Option<String>,
49 description: Option<String>,
50 requires: Vec<String>,
51 slot: String,
52 role: String,
53 role_description: Option<String>,
54 role_instructions: Option<String>,
55 loadout: String,
56 provider: Option<String>,
57 model: Option<String>,
58 reasoning_effort: Option<String>,
59 max_spawn_depth: Option<u32>,
60 origin: ProfileOrigin,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 native_preset: Option<crate::extension_host::composition_scope::NativePresetRef>,
63 }
64
65 impl FrozenFleetMember {
66 fn from_profile(profile: &AgentProfile) -> Self {
67 Self {
68 schema_version: 1,
69 id: profile.id.clone(),
70 display_name: profile.display_name.clone(),
71 description: profile.description.clone(),
72 requires: profile.requires.clone(),
73 slot: profile.profile.slot.as_str().to_string(),
74 role: canonical_public_role_name(&profile.profile.role.name),
75 role_description: profile.profile.role.description.clone(),
76 role_instructions: profile.profile.role.instructions.clone(),
77 loadout: profile.profile.loadout.as_str().to_string(),
78 provider: profile.profile.provider.clone(),
79 model: profile.profile.model.clone(),
80 reasoning_effort: profile.profile.reasoning_effort.clone(),
81 max_spawn_depth: profile.profile.delegation.max_spawn_depth,
82 origin: profile.origin,
83 native_preset: profile.native_preset.clone(),
84 }
85 }
86
87 fn into_profile(self) -> Result<AgentProfile> {
88 if self.schema_version != 1 || self.id.trim().is_empty() || self.role.trim().is_empty() {
89 bail!("invalid frozen Fleet member snapshot");
90 }
91 Ok(AgentProfile {
92 id: self.id,
93 native_preset: self.native_preset,
94 display_name: self.display_name,
95 description: self.description,
96 requires: self.requires,
97 profile: FleetProfile {
98 slot: FleetSlot::from_name(&self.slot),
99 role: FleetProfileRole {
100 name: self.role,
101 description: self.role_description,
102 instructions: self.role_instructions,
103 },
104 loadout: FleetLoadout::from_name(&self.loadout),
105 model: self.model,
106 provider: self.provider,
107 reasoning_effort: self.reasoning_effort,
108 // Authority is always derived from live Runtime policy. A
109 // Fleet identity snapshot cannot persist or grant it.
110 permissions: FleetProfilePermissions::default(),
111 delegation: FleetDelegationHints {
112 max_spawn_depth: self.max_spawn_depth,
113 max_concurrency: None,
114 },
115 },
116 source: PathBuf::from("<durable-fleet-member>"),
117 origin: self.origin,
118 plugin_authority: None,
119 })
120 }
121 }
122
123 /// Validate that every task referencing a workspace agent profile can resolve it.
124 ///
125 /// This is intended to run at Fleet run creation time, before leasing any
126 /// worker or appending lifecycle events.
127 pub fn validate_task_agent_profiles(
128 tasks: &[FleetTaskSpec],
129 agent_profiles: &[AgentProfile],
130 ) -> Result<()> {
131 for task in tasks {
132 resolve_task_agent_profile(task, agent_profiles)?;
133 }
134 Ok(())
135 }
136
137 /// Resolve and freeze every deterministic member selection before persistence.
138 ///
139 /// Author-facing selectors may be ids, names, semantic roles, model labels, or
140 /// exact routes. The durable task stores the selected member's canonical id and
141 /// the identity/route inputs used for launch. Legacy `worker.role` remains a
142 /// posture when it does not name exactly one member; explicit
143 /// `worker.agent_profile` selectors fail closed when unknown or ambiguous, and
144 /// when the task's `worker.role` names a posture other than the selected
145 /// member's role.
146 pub(crate) fn freeze_fleet_task_members(
147 tasks: &mut [FleetTaskSpec],
148 agent_profiles: &[AgentProfile],
149 require_exact_member: bool,
150 ) -> Result<()> {
151 for task in tasks {
152 let Some(worker) = task.worker.as_ref() else {
153 if require_exact_member {
154 bail!(
155 "Fleet task {} must name one member from the explicitly selected Fleet",
156 task.id
157 );
158 }
159 continue;
160 };
161 let explicit_selector = worker
162 .agent_profile
163 .as_deref()
164 .map(str::trim)
165 .filter(|selector| !selector.is_empty())
166 .map(str::to_string);
167 let legacy_role_selector = worker
168 .role
169 .as_deref()
170 .map(str::trim)
171 .filter(|selector| !selector.is_empty())
172 .map(str::to_string);
173
174 let selected = if let Some(selector) = explicit_selector.as_deref() {
175 let selected = resolve_member_in_profiles(agent_profiles, selector).map_err(|error| {
176 anyhow::anyhow!(
177 "Fleet task {} has invalid worker.agent_profile selector {selector:?}: {error}",
178 task.id
179 )
180 })?;
181 Some(selected.ok_or_else(|| {
182 anyhow::anyhow!(
183 "Fleet task {} references unknown agent profile selector {selector:?}",
184 task.id
185 )
186 })?)
187 } else if let Some(selector) = legacy_role_selector.as_deref() {
188 // `role` was historically only a posture. Preserve that contract
189 // when no exact Fleet is selected and a roster lookup is absent.
190 // Ambiguity is never a posture: silently dropping it would make a
191 // selected team launch an anonymous session-route worker.
192 match resolve_member_in_profiles(agent_profiles, selector).map_err(|error| {
193 anyhow::anyhow!(
194 "Fleet task {} has invalid worker.role member selector {selector:?}: {error}",
195 task.id
196 )
197 })? {
198 Some(profile) => Some(profile),
199 None if require_exact_member => {
200 bail!(
201 "Fleet task {} worker.role selector {selector:?} does not name a member in the explicitly selected Fleet",
202 task.id
203 )
204 }
205 None => None,
206 }
207 } else if require_exact_member {
208 bail!(
209 "Fleet task {} must name one member from the explicitly selected Fleet",
210 task.id
211 );
212 } else {
213 None
214 };
215
216 if let Some(profile) = selected {
217 validate_selected_member_model(task, profile)?;
218 let snapshot = FrozenFleetMember::from_profile(profile);
219 // A task carries exactly one posture. When an explicit member
220 // selector is present, `worker.role` may only restate that
221 // member's role (any casing or legacy alias); naming a different
222 // posture is an authoring error, not a tie to arbitrate later.
223 // Failing closed here is what keeps the launch-time resolver
224 // honest: a read-only label can never widen to a member's write
225 // authority, and a member's read-only slot can never be widened by
226 // a write-capable label (#5945).
227 if explicit_selector.is_some()
228 && let Some(label) = legacy_role_selector.as_deref()
229 {
230 let label = canonical_public_role_name(label);
231 if label != snapshot.role {
232 bail!(
233 "Fleet task {} selects member {:?} whose role is {:?}, but worker.role names a different posture {:?}; a task has one posture — drop worker.role or select a member with that role",
234 task.id,
235 profile.id,
236 snapshot.role,
237 label
238 );
239 }
240 }
241 task.metadata.insert(
242 FROZEN_FLEET_MEMBER_METADATA_KEY.to_string(),
243 serde_json::to_value(&snapshot)?,
244 );
245 let worker = task.worker.as_mut().expect("worker checked above");
246 worker.agent_profile = Some(format!("member:{}", profile.id));
247 if explicit_selector.is_none() {
248 worker.role = Some(snapshot.role.clone());
249 } else if let Some(role) = worker.role.as_mut() {
250 *role = canonical_public_role_name(role.trim());
251 }
252 } else if let Some(role) = task.worker.as_mut().and_then(|worker| worker.role.as_mut()) {
253 *role = canonical_public_role_name(role.trim());
254 }
255 }
256 Ok(())
257 }
258
259 /// Validate that every task's pinned model route actually resolves before any
260 /// worker is leased (#4866).
261 ///
262 /// Catches the "provider-less model pin" failure mode: a profile that pins a
263 /// concrete model without an explicit provider resolves against the
264 /// session/default provider, which may not carry that model — causing a silent
265 /// launch failure (e.g. selecting `gpt-5.6-luna` as a Fleet Builder model with
266 /// no provider, when Luna lives on a different configured provider). The
267 /// runtime never infers a provider from a model's spelling (#4093/#2608), so a
268 /// pinned model that does not resolve is rejected here with a clear error
269 /// instead of failing silently inside the worker. Every task must have either
270 /// the resolved session config or an explicit profile provider; inherited
271 /// session/run models are validated within that already-authorized scope.
272 pub fn validate_fleet_task_routes(
273 tasks: &[FleetTaskSpec],
274 agent_profiles: &[AgentProfile],
275 session_model: Option<&str>,
276 config: Option<&Config>,
277 ) -> Result<()> {
278 let run_model = session_model.unwrap_or("auto");
279 for task in tasks {
280 let agent_profile = resolve_task_agent_profile(task, agent_profiles)?;
281 let agent_profile = agent_profile.as_deref();
282 let (model, source) =
283 effective_fleet_model_with_source(run_model, task.worker.as_ref(), agent_profile);
284 let pinned_model = matches!(source, "task.model" | "agent_profile.model");
285 let explicit_provider = explicit_fleet_provider_id(agent_profile);
286 if config.is_none() && explicit_provider.is_none() {
287 bail!(
288 "Fleet task `{}` has no provider authority for model `{model}` (source={source}); attach the resolved route config or set the agent profile provider explicitly",
289 task.id,
290 );
291 }
292 if config.is_none()
293 && let Some(provider_id) = explicit_provider.as_deref()
294 && ProviderKind::parse(provider_id)
295 .is_none_or(|provider| provider == ProviderKind::Custom)
296 {
297 bail!(
298 "Fleet task `{}` names custom provider=`{provider_id}`, but a provider name alone does not prove its endpoint or model; attach the live route config before creating the run",
299 task.id,
300 );
301 }
302 if pinned_model && explicit_provider.is_none() {
303 let config = config.expect("provider authority checked above");
304 let identity = config
305 .active_provider_identity()
306 .map_err(anyhow::Error::msg)?;
307 let (provider, base_url) = (identity.provider, config.base_url_for_route(&identity));
308 if let Err(reason) =
309 crate::route_runtime::validate_unpinned_model_provider(provider, &model, &base_url)
310 {
311 bail!("Fleet task `{}`: {reason} (source={source})", task.id);
312 }
313 }
314
315 let route = resolve_fleet_route_with_config(task, agent_profiles, session_model, config);
316 let provider = explicit_provider
317 .map(|provider| format!("provider=`{provider}`"))
318 .unwrap_or_else(|| {
319 "no explicit provider (resolves against the session/default provider)".to_string()
320 });
321 if route.is_none() {
322 if pinned_model {
323 bail!(
324 "Fleet task `{}` pins model `{}` with {} (source={source}), but that route does not \
325 resolve to a real model on any configured provider, so the worker cannot launch. \
326 The runtime never infers a provider from a model's spelling — set an explicit \
327 provider for this model in the profile, or switch the role to `inherit`.",
328 task.id,
329 model,
330 provider
331 );
332 }
333 bail!(
334 "Fleet task `{}` cannot resolve its inherited model `{model}` with {provider} \
335 (source={source}); attach the live route config for custom providers before \
336 creating the run",
337 task.id,
338 );
339 }
340 validate_fleet_reasoning_effort(task, agent_profiles, session_model, config)?;
341 }
342 Ok(())
343 }
344
345 /// Reject an explicit Fleet thinking tier when the exact resolved route does
346 /// not advertise reasoning support. `inherit`, `auto`, and `off` are valid on
347 /// every route because they do not force a reasoning payload. This check is
348 /// deliberately performed at run creation, after the same route resolver used
349 /// for launch, so the UI cannot save a profile that will silently downgrade or
350 /// fail at spawn time (#4866).
351 fn validate_fleet_reasoning_effort(
352 task: &FleetTaskSpec,
353 agent_profiles: &[AgentProfile],
354 session_model: Option<&str>,
355 config: Option<&Config>,
356 ) -> Result<()> {
357 let agent_profile = resolve_task_agent_profile(task, agent_profiles)?;
358 let agent_profile = agent_profile.as_deref();
359 let Some(effort) =
360 effective_fleet_reasoning_effort_for_role(task.worker.as_ref(), agent_profile)
361 else {
362 return Ok(());
363 };
364 if matches!(effort.as_str(), "inherit" | "auto" | "off") {
365 return Ok(());
366 }
367 let Some(route) = resolve_fleet_route_with_config(task, agent_profiles, session_model, config)
368 else {
369 // The model-route validator owns unresolved-route errors and produces
370 // the more useful provider/model diagnosis.
371 return Ok(());
372 };
373 let provider = ProviderKind::parse(&route.provider_kind).unwrap_or(ProviderKind::Custom);
374 let capability = crate::config::provider_capability(provider, &route.wire_model_id);
375 if capability.thinking_supported {
376 return Ok(());
377 }
378 bail!(
379 "Fleet task `{}` requests thinking tier `{effort}` for `{}` / `{}`, but that exact model route does not support thinking; choose inherit, auto, or off, or select a reasoning-capable model",
380 task.id,
381 route.provider_id,
382 route.wire_model_id,
383 );
384 }
385
386 /// Build a sub-agent worker spec after resolving workspace Fleet profile input.
387 ///
388 /// This keeps Fleet and sub-agents on the same runtime substrate: profile files
389 /// and task-level role/loadout intent are composed into the existing
390 /// `AgentWorkerSpec` / `WorkerRuntimeProfile` pair, then optionally intersected
391 /// with a parent profile when the caller has one.
392 /// A worker workspace is isolated when it is a linked git worktree that sits
393 /// outside the coordinating manager's workspace (and does not contain it):
394 /// its mutations cannot overlap the shared checkout, so its launch manifest
395 /// must not claim the shared-workspace coordination scope (#5036).
396 fn worker_workspace_is_isolated(
397 coordination_workspace: &std::path::Path,
398 worker_workspace: &std::path::Path,
399 ) -> bool {
400 let canonical =
401 |path: &std::path::Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
402 let manager = canonical(coordination_workspace);
403 let worker = canonical(worker_workspace);
404 if worker == manager || worker.starts_with(&manager) || manager.starts_with(&worker) {
405 return false;
406 }
407 worker.join(".git").is_file()
408 }
409
410 #[allow(clippy::too_many_arguments)]
411 pub fn fleet_task_to_worker_spec_with_profiles(
412 worker_id: &str,
413 run_id: &str,
414 task_spec: &FleetTaskSpec,
415 _worker_spec: &FleetWorkerSpec,
416 model: &str,
417 workspace: &std::path::Path,
418 coordination_workspace: &std::path::Path,
419 agent_profiles: &[AgentProfile],
420 parent_runtime_profile: Option<&WorkerRuntimeProfile>,
421 ) -> Result<AgentWorkerSpec> {
422 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)?;
423 let agent_profile = agent_profile.as_deref();
424 let worker_profile = task_spec.worker.as_ref();
425 let role = effective_fleet_role(worker_profile, agent_profile);
426 let agent_type = runtime_role_for_member(role.as_deref().unwrap_or_default());
427 let tool_profile = fleet_tool_profile(worker_profile);
428 let objective = fleet_task_prompt_with_profile(task_spec, agent_profile);
429 let max_spawn_depth = codewhale_config::FleetExecConfig::default().max_spawn_depth;
430 let loadout = effective_fleet_loadout(worker_profile, agent_profile);
431 let (effective_model, model_source) =
432 effective_fleet_model_with_source(model, worker_profile, agent_profile);
433 let mut requested_runtime = fleet_worker_runtime_profile_for_loadout(
434 &agent_type,
435 &tool_profile,
436 &effective_model,
437 0,
438 max_spawn_depth,
439 &loadout,
440 model_source,
441 );
442 requested_runtime.provider = explicit_fleet_provider_id(agent_profile);
443 if let Some(reasoning_effort) = effective_fleet_reasoning_effort(agent_profile) {
444 requested_runtime.reasoning_effort = Some(reasoning_effort);
445 }
446 if let Some(agent_profile) = agent_profile
447 && let Some(profile_depth) = agent_profile.profile.delegation.max_spawn_depth
448 {
449 requested_runtime.max_spawn_depth = requested_runtime.max_spawn_depth.min(profile_depth);
450 }
451 let runtime_profile = parent_runtime_profile
452 .map(|parent| parent.derive_child(&requested_runtime))
453 .unwrap_or(requested_runtime);
454 let writable_roots = fleet_write_roots(task_spec)?;
455 let coordination_contracts = fleet_coordination_contracts(task_spec)?;
456 if runtime_profile.permissions.write
457 && writable_roots.is_empty()
458 && coordination_contracts.is_empty()
459 {
460 bail!(
461 "Fleet task '{}' is write-capable but declares no workspace.writable_paths or metadata.coordination_contracts",
462 task_spec.id
463 );
464 }
465 let session_name = format!("fleet-{}-{}", worker_id, task_spec.id);
466 let launch_manifest = ChildLaunchManifest {
467 owner_session: run_id.to_string(),
468 child_id: worker_id.to_string(),
469 profile: runtime_profile.clone(),
470 prompt: objective.clone(),
471 cwd: Some(workspace.display().to_string()),
472 worktree: worker_workspace_is_isolated(coordination_workspace, workspace),
473 writable_roots,
474 writable_files: Vec::new(),
475 coordination_contracts,
476 expected_artifact: None,
477 deliverables: Vec::new(),
478 resume_identity: Some(session_name.clone()),
479 generation: 1,
480 resume_from_agent_id: None,
481 };
482
483 let max_steps = task_spec
484 .budget
485 .as_ref()
486 .and_then(|budget| budget.max_steps)
487 .unwrap_or(0);
488
489 Ok(AgentWorkerSpec {
490 worker_id: worker_id.to_string(),
491 run_id: run_id.to_string(),
492 parent_run_id: None,
493 workflow_run_id: None,
494 session_name: Some(session_name),
495 objective,
496 role,
497 agent_type,
498 model: effective_model,
499 workspace: workspace.to_path_buf(),
500 git_branch: None,
501 context_mode: "fresh".to_string(),
502 fork_context: false,
503 tool_profile,
504 runtime_profile: runtime_profile.clone(),
505 max_steps,
506 spawn_depth: runtime_profile.spawn_depth,
507 max_spawn_depth: runtime_profile.max_spawn_depth,
508 child_route: None,
509 launch_manifest: Some(launch_manifest),
510 })
511 }
512
513 pub(crate) fn fleet_write_roots(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
514 let task_root = normalize_fleet_relative_path(
515 task_spec
516 .workspace
517 .as_ref()
518 .and_then(|workspace| workspace.root.as_deref())
519 .unwrap_or_else(|| std::path::Path::new(".")),
520 &task_spec.id,
521 "workspace.root",
522 )?;
523 let mut roots = Vec::new();
524 for runtime_root in fleet_runtime_write_roots(task_spec)? {
525 let claim_root = match (task_root.as_str(), runtime_root.as_str()) {
526 (".", path) | (path, ".") => path.to_string(),
527 (root, path) => format!("{root}/{path}"),
528 };
529 if !roots.contains(&claim_root) {
530 roots.push(claim_root);
531 }
532 }
533 Ok(roots)
534 }
535
536 pub(crate) fn fleet_runtime_write_roots(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
537 let mut roots = Vec::new();
538 for path in task_spec
539 .workspace
540 .as_ref()
541 .into_iter()
542 .flat_map(|workspace| &workspace.writable_paths)
543 {
544 let normalized =
545 normalize_fleet_relative_path(path, &task_spec.id, "workspace.writable_paths")?;
546 if !roots.contains(&normalized) {
547 roots.push(normalized);
548 }
549 }
550 Ok(roots)
551 }
552
553 fn normalize_fleet_relative_path(
554 path: &std::path::Path,
555 task_id: &str,
556 field: &str,
557 ) -> Result<String> {
558 let raw = path.to_string_lossy().replace('\\', "/");
559 if raw.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
560 || path.is_absolute()
561 || path.components().any(|component| {
562 matches!(
563 component,
564 std::path::Component::ParentDir
565 | std::path::Component::RootDir
566 | std::path::Component::Prefix(_)
567 )
568 })
569 {
570 bail!(
571 "Fleet task '{task_id}' {field} path '{}' must be one repo-relative line and cannot escape the workspace",
572 path.display()
573 );
574 }
575 let mut segments = Vec::new();
576 for segment in raw.split('/') {
577 match segment {
578 "" | "." => {}
579 ".." => {
580 bail!(
581 "Fleet task '{task_id}' {field} path '{}' cannot contain parent traversal",
582 path.display()
583 );
584 }
585 value => segments.push(value),
586 }
587 }
588 Ok(if segments.is_empty() {
589 ".".to_string()
590 } else {
591 segments.join("/")
592 })
593 }
594
595 fn fleet_coordination_contracts(task_spec: &FleetTaskSpec) -> Result<Vec<String>> {
596 let Some(value) = task_spec.metadata.get("coordination_contracts") else {
597 return Ok(Vec::new());
598 };
599 let Some(values) = value.as_array() else {
600 bail!(
601 "Fleet task '{}' metadata.coordination_contracts must be an array of strings",
602 task_spec.id
603 );
604 };
605 if values.len() > 16 {
606 bail!(
607 "Fleet task '{}' metadata.coordination_contracts accepts at most 16 entries",
608 task_spec.id
609 );
610 }
611 let mut contracts = Vec::new();
612 for value in values {
613 let Some(value) = value.as_str() else {
614 bail!(
615 "Fleet task '{}' metadata.coordination_contracts must contain only strings",
616 task_spec.id
617 );
618 };
619 let value = value.trim();
620 if value.is_empty()
621 || value.chars().count() > 128
622 || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
623 {
624 bail!(
625 "Fleet task '{}' coordination contracts must be one non-empty line of at most 128 characters",
626 task_spec.id
627 );
628 }
629 if !contracts.iter().any(|contract| contract == value) {
630 contracts.push(value.to_string());
631 }
632 }
633 Ok(contracts)
634 }
635
636 /// Mint a [`FleetResolvedRoute`] snapshot for a fleet task (#3154).
637 ///
638 /// This calls the existing hermetic resolver bridge
639 /// ([`resolve_route_candidate`]) so the persisted route reflects the same
640 /// resolution semantics the runtime would use, then records only non-sensitive
641 /// shape (provider id/kind, model ids, protocol) combined with the already
642 /// computed effective role/loadout/model-class intent. `source` is
643 /// `"resolver"`.
644 ///
645 /// Honesty rules:
646 /// - `canonical_model` stays `None` when the resolver could not pin one.
647 /// - The provider comes from the resolved agent profile's own explicit
648 /// `provider` field when it has one (#4093) — a Fleet worker profile can be
649 /// pinned to a route independent of the parent/current session provider.
650 /// Absent an explicit pin, the worker profile carries no provider authority;
651 /// callers must supply the resolved live [`Config`]. The provider is NEVER
652 /// inferred by sniffing a substring/prefix out of `model` (EPIC #2608:
653 /// explicit config only). A task-level `model` selector is forwarded as the
654 /// model selector. No reasoning/pricing fields are fabricated.
655 ///
656 /// Returns `None` (never a fabricated route) when resolution fails, so callers
657 /// degrade gracefully without inventing detail.
658 pub(crate) fn resolve_fleet_route(
659 task_spec: &FleetTaskSpec,
660 agent_profiles: &[AgentProfile],
661 session_model: Option<&str>,
662 ) -> Option<FleetResolvedRoute> {
663 resolve_fleet_route_with_config(task_spec, agent_profiles, session_model, None)
664 }
665
666 /// Resolve a Fleet receipt from the same live Config used to launch workers.
667 /// Named custom identities are emitted only through this proof-bearing path;
668 /// the hermetic fallback above cannot truthfully validate arbitrary ids.
669 pub(crate) fn resolve_fleet_route_with_config(
670 task_spec: &FleetTaskSpec,
671 agent_profiles: &[AgentProfile],
672 session_model: Option<&str>,
673 config: Option<&Config>,
674 ) -> Option<FleetResolvedRoute> {
675 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
676 .ok()
677 .flatten();
678 let agent_profile = agent_profile.as_deref();
679 let worker_profile = task_spec.worker.as_ref();
680 let (role, role_source) = effective_fleet_role_with_source(worker_profile, agent_profile);
681 let (loadout, loadout_source) =
682 effective_fleet_loadout_with_source(worker_profile, agent_profile);
683 let (model_class, model_class_source) = task_model_class_with_source(worker_profile);
684
685 // Task/profile model pins are visible route intent; next the session
686 // route (the operator's model) applies as the run-level fallback; only
687 // then does the resolver pick the provider default.
688 let (model_selector, model_source) =
689 fleet_route_model_selector_with_source(worker_profile, agent_profile, session_model);
690 let model_selector = model_selector.as_deref();
691
692 let explicit_provider_id = explicit_fleet_provider_id(agent_profile);
693 let (candidate, provider_id, provider_exact_id, route_source) = if let Some(config) = config {
694 let identity = match explicit_provider_id.as_deref() {
695 Some(provider_id) => config.resolve_provider_identity(provider_id).ok()?,
696 None => config.active_provider_identity().ok()?,
697 };
698 let route = crate::route_runtime::resolve_runtime_route_for_identity(
699 config,
700 &identity,
701 model_selector,
702 )
703 .ok()?
704 .validate()
705 .ok()?;
706 let provider_exact_id = route.identity.persisted_id().map(str::to_string);
707 (
708 route.candidate,
709 route.identity.key.to_string(),
710 provider_exact_id,
711 "runtime_route",
712 )
713 } else {
714 let provider_id = explicit_provider_id.as_deref()?;
715 let metadata = codewhale_config::descriptors::compatibility_for_selector(provider_id)?;
716 let provider = metadata.kind;
717 if provider == ProviderKind::Custom {
718 return None;
719 }
720 let candidate = resolve_route_candidate(
721 provider,
722 model_selector,
723 Some(metadata.default_model),
724 Some(metadata.base_url.to_owned()),
725 None,
726 None,
727 )
728 .ok()?;
729 let provider_id = candidate.provider_id().as_str().to_string();
730 (candidate, provider_id, None, "resolver")
731 };
732
733 Some(FleetResolvedRoute {
734 provider_id,
735 provider_exact_id,
736 provider_kind: candidate.provider_kind().as_str().to_string(),
737 canonical_model: candidate
738 .canonical_model()
739 .as_ref()
740 .map(|model| model.as_str().to_string()),
741 wire_model_id: candidate.wire_model_id().as_str().to_string(),
742 protocol: route_protocol_label(candidate.protocol()).to_string(),
743 role,
744 loadout: loadout_intent_label(&loadout),
745 model_class,
746 model_route: Some(
747 model_route_label(&fleet_model_route_for_loadout(
748 model_selector.unwrap_or("auto"),
749 &loadout,
750 ))
751 .to_string(),
752 ),
753 reasoning_effort: effective_fleet_reasoning_effort_for_role(worker_profile, agent_profile),
754 role_source: role_source.map(str::to_string),
755 loadout_source: loadout_source.map(str::to_string),
756 model_class_source: model_class_source.map(str::to_string),
757 model_source: Some(model_source.to_string()),
758 source: route_source.to_string(),
759 })
760 }
761
762 /// Build the receipt route from route identity reported by the worker itself.
763 ///
764 /// Provider/model fields in this path are process-boundary evidence, not a
765 /// second resolution attempt in the manager's potentially different config.
766 /// Fleet task/profile fields remain intent metadata and are safe to derive
767 /// locally. Protocol and canonical model stay explicitly unreported because
768 /// the current exec terminal envelope does not carry them.
769 pub(crate) fn resolve_fleet_route_from_worker_report(
770 task_spec: &FleetTaskSpec,
771 agent_profiles: &[AgentProfile],
772 session_model: Option<&str>,
773 provider: &str,
774 provider_exact_id: Option<&str>,
775 model: &str,
776 ) -> Option<FleetResolvedRoute> {
777 let provider = non_empty_trimmed(provider)?;
778 let model = non_empty_trimmed(model)?;
779 let provider_exact_id = match provider_exact_id {
780 Some(provider_exact_id) => Some(non_empty_trimmed(provider_exact_id)?),
781 None => None,
782 };
783 let provider_kind = ProviderKind::parse(provider)?;
784 if provider_exact_id.is_some() && provider_kind != ProviderKind::Custom {
785 return None;
786 }
787 let provider_id = provider_exact_id.unwrap_or(provider);
788 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
789 .ok()
790 .flatten();
791 let agent_profile = agent_profile.as_deref();
792 let worker_profile = task_spec.worker.as_ref();
793 let (role, role_source) = effective_fleet_role_with_source(worker_profile, agent_profile);
794 let (loadout, loadout_source) =
795 effective_fleet_loadout_with_source(worker_profile, agent_profile);
796 let (model_class, model_class_source) = task_model_class_with_source(worker_profile);
797 let (model_selector, model_source) =
798 fleet_route_model_selector_with_source(worker_profile, agent_profile, session_model);
799 Some(FleetResolvedRoute {
800 provider_id: provider_id.to_string(),
801 provider_exact_id: provider_exact_id.map(str::to_string),
802 provider_kind: provider_kind.as_str().to_string(),
803 canonical_model: None,
804 wire_model_id: model.to_string(),
805 protocol: "unreported".to_string(),
806 role,
807 loadout: loadout_intent_label(&loadout),
808 model_class,
809 model_route: Some(
810 model_route_label(&fleet_model_route_for_loadout(
811 model_selector.as_deref().unwrap_or("auto"),
812 &loadout,
813 ))
814 .to_string(),
815 ),
816 reasoning_effort: effective_fleet_reasoning_effort_for_role(worker_profile, agent_profile),
817 role_source: role_source.map(str::to_string),
818 loadout_source: loadout_source.map(str::to_string),
819 model_class_source: model_class_source.map(str::to_string),
820 model_source: Some(model_source.to_string()),
821 source: "worker_terminal_metadata".to_string(),
822 })
823 }
824
825 /// Plain-string label for a resolved wire protocol (no config type leaks).
826 fn route_protocol_label(protocol: codewhale_config::route::RequestProtocol) -> &'static str {
827 use codewhale_config::route::RequestProtocol;
828 match protocol {
829 RequestProtocol::ChatCompletions => "chat_completions",
830 RequestProtocol::Responses => "responses",
831 RequestProtocol::AnthropicMessages => "anthropic_messages",
832 }
833 }
834
835 /// Collapse an `inherit` (no-op) loadout to `None` for the receipt.
836 fn loadout_intent_label(loadout: &codewhale_config::FleetLoadout) -> Option<String> {
837 if *loadout == codewhale_config::FleetLoadout::Inherit {
838 None
839 } else {
840 Some(loadout.as_str().to_string())
841 }
842 }
843
844 fn model_route_label(route: &ModelRoute) -> &'static str {
845 match route {
846 ModelRoute::Inherit => "inherit",
847 ModelRoute::Faster => "faster",
848 ModelRoute::Auto => "auto",
849 ModelRoute::Fixed(_) => "fixed",
850 }
851 }
852
853 pub(crate) fn fleet_task_prompt(task_spec: &FleetTaskSpec) -> String {
854 fleet_task_prompt_with_profile(task_spec, None)
855 }
856
857 pub(crate) fn fleet_task_prompt_with_profiles(
858 task_spec: &FleetTaskSpec,
859 agent_profiles: &[AgentProfile],
860 ) -> Result<String> {
861 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)?;
862 Ok(fleet_task_prompt_with_profile(
863 task_spec,
864 agent_profile.as_deref(),
865 ))
866 }
867
868 fn fleet_task_prompt_with_profile(
869 task_spec: &FleetTaskSpec,
870 agent_profile: Option<&AgentProfile>,
871 ) -> String {
872 let role = effective_fleet_role(task_spec.worker.as_ref(), agent_profile)
873 .unwrap_or_else(|| "general".to_string());
874 let mut prompt = String::new();
875 prompt.push_str("You have been summoned as a Codewhale Fleet member (");
876 prompt.push_str(&role);
877 prompt.push_str(") by the Fleet orchestrator.\n\n");
878 prompt.push_str("Fleet operating contract:\n");
879 prompt.push_str("- Work only the assigned slice; keep sibling or topology assumptions out of your answer.\n");
880 prompt.push_str("- Use the policy-gated tools available in this headless worker run.\n");
881 prompt.push_str("- Treat the active provider/model route as inherited unless this task or profile pins a model.\n");
882 prompt.push_str(
883 "- Return concise evidence, gaps, and next actions; the orchestrator will integrate and verify.\n\n",
884 );
885 prompt.push_str("Fleet task: ");
886 prompt.push_str(&task_spec.name);
887
888 if let Some(objective) = task_spec.objective.as_deref() {
889 prompt.push_str("\n\nObjective:\n");
890 prompt.push_str(objective);
891 } else if let Some(description) = task_spec.description.as_deref() {
892 prompt.push_str("\n\nObjective:\n");
893 prompt.push_str(description);
894 }
895
896 prompt.push_str("\n\nInstructions:\n");
897 prompt.push_str(&task_spec.instructions);
898
899 if !task_spec.context.is_empty() {
900 prompt.push_str("\n\nContext:\n");
901 for item in &task_spec.context {
902 prompt.push_str("- ");
903 prompt.push_str(item);
904 prompt.push('\n');
905 }
906 }
907
908 if !task_spec.input_files.is_empty() {
909 prompt.push_str("\nInput files:\n");
910 for path in &task_spec.input_files {
911 prompt.push_str("- ");
912 prompt.push_str(&path.display().to_string());
913 prompt.push('\n');
914 }
915 }
916
917 if let Some(profile) = agent_profile {
918 append_agent_profile_prompt(&mut prompt, profile);
919 }
920
921 prompt
922 }
923
924 /// Shared saved-profile instructions for direct and durable Fleet children.
925 pub(crate) fn append_agent_profile_prompt(prompt: &mut String, agent_profile: &AgentProfile) {
926 prompt.push_str("\nFleet profile: ");
927 prompt.push_str(&agent_profile.id);
928 if let Some(display_name) = agent_profile.display_name.as_deref() {
929 prompt.push_str(" (");
930 prompt.push_str(display_name);
931 prompt.push(')');
932 }
933 if let Some(description) = agent_profile.description.as_deref() {
934 prompt.push_str("\nProfile description:\n");
935 prompt.push_str(description);
936 }
937 if let Some(instructions) = agent_profile.profile.role.instructions.as_deref() {
938 prompt.push_str("\nProfile instructions:\n");
939 prompt.push_str(instructions);
940 }
941 }
942
943 /// Find a saved role pin without letting a member id shadow a second member
944 /// with the same semantic role. Built-in inherited postures are not pins.
945 pub(crate) fn resolve_pinned_role_profile(
946 agent_profiles: &[AgentProfile],
947 role: &str,
948 ) -> Result<Option<AgentProfile>, FleetSelectorError> {
949 let pinned = agent_profiles
950 .iter()
951 .filter(|profile| {
952 profile.origin != ProfileOrigin::BuiltIn
953 && profile
954 .profile
955 .model
956 .as_deref()
957 .and_then(non_empty_trimmed)
958 .is_some_and(|model| !model.eq_ignore_ascii_case("auto"))
959 })
960 .cloned()
961 .collect::<Vec<_>>();
962 resolve_member_in_profiles(
963 &pinned,
964 &format!("role:{}", canonical_public_role_name(role)),
965 )
966 .map(|member| member.cloned())
967 }
968
969 /// Compare only the known route pair; never infer a provider from a wire id's
970 /// namespace. A qualified task selector may restate that same exact pair.
971 pub(crate) fn requested_model_matches_pin(
972 requested: &str,
973 model: &str,
974 provider: Option<&str>,
975 ) -> bool {
976 let requested = requested.trim();
977 let model = model.trim();
978 requested == model
979 || provider
980 .and_then(non_empty_trimmed)
981 .and_then(|provider| requested.strip_prefix(&format!("{provider}/")))
982 .is_some_and(|requested_model| requested_model == model)
983 }
984
985 fn resolve_task_agent_profile<'a>(
986 task_spec: &FleetTaskSpec,
987 agent_profiles: &'a [AgentProfile],
988 ) -> Result<Option<Cow<'a, AgentProfile>>> {
989 if let Some(snapshot) = task_spec.metadata.get(FROZEN_FLEET_MEMBER_METADATA_KEY) {
990 let snapshot: FrozenFleetMember =
991 serde_json::from_value(snapshot.clone()).map_err(|error| {
992 anyhow::anyhow!(
993 "Fleet task {} has an invalid durable member snapshot: {error}",
994 task_spec.id
995 )
996 })?;
997 return Ok(Some(Cow::Owned(snapshot.into_profile()?)));
998 }
999 let Some(worker) = task_spec.worker.as_ref() else {
1000 return Ok(None);
1001 };
1002 if let Some(selector) = worker
1003 .agent_profile
1004 .as_deref()
1005 .map(str::trim)
1006 .filter(|selector| !selector.is_empty())
1007 {
1008 let profile = resolve_member_in_profiles(agent_profiles, selector).map_err(|error| {
1009 anyhow::anyhow!(
1010 "Fleet task {} has invalid worker.agent_profile selector {selector:?}: {error}",
1011 task_spec.id
1012 )
1013 })?;
1014 let Some(profile) = profile else {
1015 bail!(
1016 "Fleet task {} references unknown agent profile selector {selector:?}",
1017 task_spec.id
1018 );
1019 };
1020 validate_selected_member_model(task_spec, profile)?;
1021 return Ok(Some(Cow::Borrowed(profile)));
1022 }
1023
1024 let Some(selector) = worker
1025 .role
1026 .as_deref()
1027 .map(str::trim)
1028 .filter(|selector| !selector.is_empty())
1029 else {
1030 return Ok(None);
1031 };
1032 // `worker.role` predates human member selectors. Resolve it only when one
1033 // member is deterministic; an ambiguous/missing roster match remains the
1034 // historical Runtime posture instead of breaking an existing task.
1035 let profile = resolve_member_in_profiles(agent_profiles, selector).map_err(|error| {
1036 anyhow::anyhow!(
1037 "Fleet task {} has invalid worker.role member selector {selector:?}: {error}",
1038 task_spec.id
1039 )
1040 })?;
1041 if let Some(profile) = profile {
1042 validate_selected_member_model(task_spec, profile)?;
1043 }
1044 Ok(profile.map(Cow::Borrowed))
1045 }
1046
1047 fn validate_selected_member_model(task_spec: &FleetTaskSpec, profile: &AgentProfile) -> Result<()> {
1048 let Some(task_model) = task_spec
1049 .worker
1050 .as_ref()
1051 .and_then(|worker| worker.model.as_deref())
1052 .and_then(non_empty_trimmed)
1053 else {
1054 return Ok(());
1055 };
1056 let Some(profile_model) = profile.profile.model.as_deref().and_then(non_empty_trimmed) else {
1057 return Ok(());
1058 };
1059 if profile_model.eq_ignore_ascii_case("auto") {
1060 return Ok(());
1061 }
1062 let profile_provider = profile
1063 .profile
1064 .provider
1065 .as_deref()
1066 .and_then(non_empty_trimmed);
1067 if !requested_model_matches_pin(task_model, profile_model, profile_provider) {
1068 bail!(
1069 "Fleet task {} selects member {:?} with pinned model {} on {}; worker.model {:?} conflicts with that member route",
1070 task_spec.id,
1071 profile.id,
1072 profile_model,
1073 profile_provider.unwrap_or("the session provider"),
1074 task_model
1075 );
1076 }
1077 Ok(())
1078 }
1079
1080 fn effective_fleet_role(
1081 worker_profile: Option<&FleetTaskWorkerProfile>,
1082 agent_profile: Option<&AgentProfile>,
1083 ) -> Option<String> {
1084 effective_fleet_role_with_source(worker_profile, agent_profile).0
1085 }
1086
1087 fn effective_fleet_role_with_source(
1088 worker_profile: Option<&FleetTaskWorkerProfile>,
1089 agent_profile: Option<&AgentProfile>,
1090 ) -> (Option<String>, Option<&'static str>) {
1091 // A resolved roster member is authoritative for the runtime posture: its
1092 // canonical slot (reviewer/builder/...) defines shell/write/network
1093 // authority. The resolved `AgentProfile` always carries a canonical
1094 // `role.name` — a display name, model label, or route selector is already
1095 // collapsed onto the member during profile resolution, never surfaced as a
1096 // raw posture string here.
1097 //
1098 // Preferring the member over legacy `worker.role` is what makes a task
1099 // whose role label is "manager" but whose agent_profile selects
1100 // `member:reviewer` actually run with reviewer authority. Previously the
1101 // first branch required `worker.agent_profile` to be empty, so a present
1102 // agent_profile fell through to `worker.role` and silently discarded the
1103 // member's slot (fleet-e12f3160: task stayed a manager-coordinator and was
1104 // never leased).
1105 //
1106 // This is not where a conflict gets arbitrated. `freeze_fleet_task_members`
1107 // rejects a spec whose `worker.role` names a posture other than the
1108 // selected member's role before the task is persisted, so by the time a
1109 // member reaches this function its role and the task label agree (#5945).
1110 if let Some(profile) = agent_profile {
1111 return (
1112 Some(canonical_public_role_name(&profile.profile.role.name)),
1113 Some("agent_profile.role"),
1114 );
1115 }
1116 // No member resolved (no agent_profile selector, no frozen snapshot, and
1117 // worker.role was not a deterministic member selector). Keep the legacy
1118 // role label so existing v1 tasks retain their historical posture. A
1119 // receipt whose `role_source` reads "task.role" therefore means exactly
1120 // that: no roster member was resolved for the task at all.
1121 worker_profile
1122 .and_then(|worker| worker.role.as_deref())
1123 .map(str::trim)
1124 .filter(|role| !role.is_empty())
1125 .map(canonical_public_role_name)
1126 .map(|role| (Some(role), Some("task.role")))
1127 .unwrap_or((None, None))
1128 }
1129
1130 fn effective_fleet_loadout(
1131 worker_profile: Option<&FleetTaskWorkerProfile>,
1132 agent_profile: Option<&AgentProfile>,
1133 ) -> codewhale_config::FleetLoadout {
1134 effective_fleet_loadout_with_source(worker_profile, agent_profile).0
1135 }
1136
1137 fn effective_fleet_loadout_with_source(
1138 worker_profile: Option<&FleetTaskWorkerProfile>,
1139 agent_profile: Option<&AgentProfile>,
1140 ) -> (codewhale_config::FleetLoadout, Option<&'static str>) {
1141 if let Some(model_class) = worker_profile
1142 .and_then(|worker| worker.model_class.as_deref())
1143 .and_then(non_empty_trimmed)
1144 {
1145 return (
1146 codewhale_config::FleetLoadout::from_name(model_class),
1147 Some("task.model_class"),
1148 );
1149 }
1150 if let Some(loadout) = worker_profile
1151 .and_then(|worker| worker.loadout.as_deref())
1152 .and_then(non_empty_trimmed)
1153 {
1154 return (
1155 codewhale_config::FleetLoadout::from_name(loadout),
1156 Some("task.loadout"),
1157 );
1158 }
1159 if let Some(loadout) = agent_profile
1160 .map(|profile| profile.profile.loadout.clone())
1161 .filter(|loadout| *loadout != codewhale_config::FleetLoadout::Inherit)
1162 {
1163 return (loadout, Some("agent_profile.loadout"));
1164 }
1165 (codewhale_config::FleetLoadout::Inherit, None)
1166 }
1167
1168 fn effective_fleet_model(
1169 run_model: &str,
1170 worker_profile: Option<&FleetTaskWorkerProfile>,
1171 agent_profile: Option<&AgentProfile>,
1172 ) -> String {
1173 effective_fleet_model_with_source(run_model, worker_profile, agent_profile).0
1174 }
1175
1176 fn effective_fleet_model_with_source(
1177 run_model: &str,
1178 worker_profile: Option<&FleetTaskWorkerProfile>,
1179 agent_profile: Option<&AgentProfile>,
1180 ) -> (String, &'static str) {
1181 if let Some(model) = agent_profile
1182 .and_then(|profile| profile.profile.model.as_deref())
1183 .and_then(non_empty_trimmed)
1184 .filter(|model| !model.eq_ignore_ascii_case("auto"))
1185 {
1186 return (model.to_string(), "agent_profile.model");
1187 }
1188 if let Some(model) = worker_profile
1189 .and_then(|worker| worker.model.as_deref())
1190 .and_then(non_empty_trimmed)
1191 {
1192 return (model.to_string(), "task.model");
1193 }
1194 if let Some(model) = agent_profile
1195 .and_then(|profile| profile.profile.model.as_deref())
1196 .and_then(non_empty_trimmed)
1197 {
1198 return (model.to_string(), "agent_profile.model");
1199 }
1200 (run_model.to_string(), "run.model")
1201 }
1202
1203 /// The provider id a resolved agent profile EXPLICITLY pins, if any (#4093).
1204 ///
1205 /// This preserves user-named OpenAI-compatible custom providers such as
1206 /// `lm-studio` instead of collapsing them through [`ProviderKind`]. Runtime
1207 /// launch paths can set `Config.provider` to this exact id so the normal config
1208 /// resolver finds `[providers.<id>]` (#3965).
1209 ///
1210 /// Returns `None` when no profile names a provider — never invents a DeepSeek
1211 /// default — so launch paths can omit `--provider` and leave profile-less
1212 /// workers on their own session default. EPIC #2608: never inferred from
1213 /// `model`.
1214 pub(crate) fn explicit_fleet_provider_id(agent_profile: Option<&AgentProfile>) -> Option<String> {
1215 agent_profile
1216 .and_then(|profile| profile.profile.provider.as_deref())
1217 .map(str::trim)
1218 .filter(|provider| !provider.is_empty())
1219 .map(str::to_string)
1220 }
1221
1222 /// The built-in provider a resolved agent profile EXPLICITLY pins, if any (#4093).
1223 ///
1224 /// This returns `None` (never the DeepSeek default) when no profile names a
1225 /// provider, so call sites can leave `--provider` off the worker argv and
1226 /// preserve today's behavior for profile-less / provider-less workers (they
1227 /// resolve their provider from their own session default). EPIC #2608: never
1228 /// inferred from `model`.
1229 ///
1230 /// `pub(crate)` so the interactive-TUI in-process spawn path
1231 /// (`tools::subagent`) resolves the pinned provider from the SAME
1232 /// explicit-only source as the headless `codewhale exec` launch route (#4193),
1233 /// instead of re-deriving it and risking a second, divergent policy. User-named
1234 /// custom providers intentionally return `None` here; launch paths that can
1235 /// carry strings should use [`explicit_fleet_provider_id`].
1236 pub(crate) fn explicit_fleet_provider(
1237 agent_profile: Option<&AgentProfile>,
1238 ) -> Option<ProviderKind> {
1239 explicit_fleet_provider_id(agent_profile)
1240 .as_deref()
1241 .and_then(ProviderKind::parse)
1242 }
1243
1244 pub(crate) fn effective_fleet_reasoning_effort(
1245 agent_profile: Option<&AgentProfile>,
1246 ) -> Option<String> {
1247 agent_profile
1248 .and_then(|profile| profile.profile.reasoning_effort.as_deref())
1249 .map(str::trim)
1250 .filter(|effort| !effort.is_empty())
1251 .map(str::to_string)
1252 }
1253
1254 fn effective_fleet_reasoning_effort_for_role(
1255 worker_profile: Option<&FleetTaskWorkerProfile>,
1256 agent_profile: Option<&AgentProfile>,
1257 ) -> Option<String> {
1258 effective_fleet_reasoning_effort(agent_profile).or_else(|| {
1259 let role = effective_fleet_role(worker_profile, agent_profile);
1260 WorkerRuntimeProfile::for_role(runtime_role_for_member(role.as_deref().unwrap_or_default()))
1261 .reasoning_effort
1262 })
1263 }
1264
1265 /// The effective reasoning/thinking tier a Fleet worker should launch with.
1266 ///
1267 /// This is the launch-side twin of the receipt/runtime-profile field: an
1268 /// explicit resolved AgentProfile tier wins, otherwise the selected role's
1269 /// documented default applies. Task model overrides do not invent a tier.
1270 pub(crate) fn fleet_worker_launch_reasoning_effort(
1271 task_spec: &FleetTaskSpec,
1272 agent_profiles: &[AgentProfile],
1273 ) -> Option<String> {
1274 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
1275 .ok()
1276 .flatten();
1277 effective_fleet_reasoning_effort_for_role(task_spec.worker.as_ref(), agent_profile.as_deref())
1278 }
1279
1280 /// The route (model selector + optional explicit provider id) that a fleet
1281 /// worker's actual `codewhale exec` subprocess should launch on (#4093 AC #4).
1282 ///
1283 /// This is the launch-side twin of [`resolve_fleet_route`] (the receipt): both
1284 /// read the worker's model from the same task/profile/run precedence
1285 /// ([`effective_fleet_model`]) and the provider from the same explicit-only
1286 /// source ([`explicit_fleet_provider_id`]), so a worker whose profile is pinned
1287 /// to provider B launches on provider B even when the parent session is on
1288 /// provider A.
1289 ///
1290 /// - `model`: never empty in practice — falls back to `run_model` when neither
1291 /// the task nor the profile pins a model, matching pre-#4093 dispatch.
1292 /// - `provider`: `Some(provider_id)` ONLY when the resolved agent profile
1293 /// explicitly pins a provider. `None` means "no provider authority" — the
1294 /// caller omits `--provider` and the worker keeps its own session default,
1295 /// preserving today's behavior for profile-less workers. Built-ins use their
1296 /// canonical ids; user-named custom providers preserve the profile's id so
1297 /// `codewhale exec --provider <id>` can resolve `[providers.<id>]`.
1298 pub(crate) fn fleet_worker_launch_route(
1299 task_spec: &FleetTaskSpec,
1300 agent_profiles: &[AgentProfile],
1301 run_model: &str,
1302 ) -> (String, Option<String>) {
1303 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
1304 .ok()
1305 .flatten();
1306 let agent_profile = agent_profile.as_deref();
1307 let worker_profile = task_spec.worker.as_ref();
1308 let model = effective_fleet_model(run_model, worker_profile, agent_profile);
1309 let provider = explicit_fleet_provider_id(agent_profile);
1310 (model, provider)
1311 }
1312
1313 fn task_model_class_with_source(
1314 worker_profile: Option<&FleetTaskWorkerProfile>,
1315 ) -> (Option<String>, Option<&'static str>) {
1316 worker_profile
1317 .and_then(|worker| worker.model_class.as_deref())
1318 .and_then(non_empty_trimmed)
1319 .map(|model_class| (Some(model_class.to_string()), Some("task.model_class")))
1320 .unwrap_or((None, None))
1321 }
1322
1323 fn fleet_route_model_selector_with_source(
1324 worker_profile: Option<&FleetTaskWorkerProfile>,
1325 agent_profile: Option<&AgentProfile>,
1326 session_model: Option<&str>,
1327 ) -> (Option<String>, &'static str) {
1328 // The session route (operator model) is the run-level fallback, matching
1329 // the dispatch path where FleetManager::run_model() feeds
1330 // `effective_fleet_model_with_source`. Empty/"auto" stays resolver-default.
1331 let run_model = session_model
1332 .map(str::trim)
1333 .filter(|model| !model.is_empty())
1334 .unwrap_or("auto");
1335 let (model, source) =
1336 effective_fleet_model_with_source(run_model, worker_profile, agent_profile);
1337 if model.trim().is_empty() || model.eq_ignore_ascii_case("auto") {
1338 (None, "resolver.default")
1339 } else {
1340 (Some(model), source)
1341 }
1342 }
1343
1344 /// Runtime agent type for a roster member: role name first, falling back to
1345 /// the org-chart slot name when the role name is empty (#fleet-roster cutover
1346 /// (v0.8.67)).
1347 pub(crate) fn roster_member_agent_type(member: &AgentProfile) -> FleetRole {
1348 let role_name = member.profile.role.name.trim();
1349 if role_name.is_empty() {
1350 runtime_role_for_member(member.profile.slot.as_str())
1351 } else {
1352 runtime_role_for_member(role_name)
1353 }
1354 }
1355
1356 /// Convert a fleet worker profile's tool list into an `AgentWorkerToolProfile`.
1357 fn fleet_tool_profile(profile: Option<&FleetTaskWorkerProfile>) -> AgentWorkerToolProfile {
1358 match profile {
1359 Some(p) if !p.tools.is_empty() => AgentWorkerToolProfile::Explicit(p.tools.clone()),
1360 _ => AgentWorkerToolProfile::Inherited,
1361 }
1362 }
1363
1364 fn fleet_worker_runtime_profile(
1365 agent_type: &FleetRole,
1366 tool_profile: &AgentWorkerToolProfile,
1367 model: &str,
1368 spawn_depth: u32,
1369 max_spawn_depth: u32,
1370 ) -> WorkerRuntimeProfile {
1371 let mut profile = WorkerRuntimeProfile::for_role(agent_type.clone());
1372 profile.tools = match tool_profile {
1373 AgentWorkerToolProfile::Inherited => ToolScope::Inherit,
1374 AgentWorkerToolProfile::Explicit(tools) => ToolScope::Explicit(tools.clone()),
1375 };
1376 profile.model = if model == "auto" {
1377 ModelRoute::Auto
1378 } else {
1379 ModelRoute::Fixed(model.to_string())
1380 };
1381 profile.max_spawn_depth = max_spawn_depth;
1382 profile.spawn_depth = spawn_depth;
1383 profile.background = true;
1384 profile
1385 }
1386
1387 fn fleet_worker_runtime_profile_for_loadout(
1388 agent_type: &FleetRole,
1389 tool_profile: &AgentWorkerToolProfile,
1390 model: &str,
1391 spawn_depth: u32,
1392 max_spawn_depth: u32,
1393 loadout: &codewhale_config::FleetLoadout,
1394 model_source: &'static str,
1395 ) -> WorkerRuntimeProfile {
1396 let mut profile = fleet_worker_runtime_profile(
1397 agent_type,
1398 tool_profile,
1399 model,
1400 spawn_depth,
1401 max_spawn_depth,
1402 );
1403 profile.model = if matches!(model_source, "task.model" | "agent_profile.model") {
1404 fleet_model_route_for_loadout(model, &codewhale_config::FleetLoadout::Inherit)
1405 } else {
1406 fleet_model_route_for_loadout("auto", loadout)
1407 };
1408 profile
1409 }
1410
1411 fn non_empty_trimmed(value: &str) -> Option<&str> {
1412 let trimmed = value.trim();
1413 (!trimmed.is_empty()).then_some(trimmed)
1414 }
1415
1416 pub(crate) fn fleet_model_route_for_loadout(
1417 model: &str,
1418 loadout: &codewhale_config::FleetLoadout,
1419 ) -> ModelRoute {
1420 let model = model.trim();
1421 if !model.is_empty() && !model.eq_ignore_ascii_case("auto") {
1422 return ModelRoute::Fixed(model.to_string());
1423 }
1424 match loadout {
1425 codewhale_config::FleetLoadout::Inherit => ModelRoute::Inherit,
1426 // `Fast` used to mean "cheap sibling" — silently route the child to a
1427 // different, cheaper model than the parent turn. That is a routing
1428 // decision the operator never made and cannot see: the child's model
1429 // is not reported in exec's structured output, so a parent running a
1430 // specifically-priced route (e.g. `muse-spark-1.2-contributor`) would
1431 // spawn a scout billed as something else, and the only place it
1432 // surfaced was the invoice.
1433 //
1434 // A loadout is a statement about how much work a role should do, not
1435 // authority to re-price it. `Fast` now inherits the parent's route
1436 // like every other default; a genuinely different model stays
1437 // available, but only when someone pins it explicitly.
1438 codewhale_config::FleetLoadout::Fast => ModelRoute::Inherit,
1439 codewhale_config::FleetLoadout::Custom(_) => ModelRoute::Auto,
1440 }
1441 }
1442
1443 /// Apply exec hardening to a worker spec from fleet config (#3027).
1444 ///
1445 /// Filters tools against allowed/disallowed lists and caps max_steps to
1446 /// config's max_turns. `append_system_prompt` is deliberately not folded into
1447 /// the objective: the worker command delivers it once, as system prompt text,
1448 /// via `--append-system-prompt`, and the objective must stay identical to the
1449 /// persisted launch manifest prompt.
1450 pub fn apply_exec_hardening(
1451 mut spec: AgentWorkerSpec,
1452 exec: &codewhale_config::FleetExecConfig,
1453 ) -> AgentWorkerSpec {
1454 // Cap max_steps to config max_turns (0 means no cap).
1455 if exec.max_turns > 0 {
1456 spec.max_steps = if spec.max_steps == 0 {
1457 exec.max_turns
1458 } else {
1459 spec.max_steps.min(exec.max_turns)
1460 };
1461 }
1462 spec.max_spawn_depth = spec
1463 .max_spawn_depth
1464 .min(spec.runtime_profile.max_spawn_depth)
1465 .min(exec.max_spawn_depth)
1466 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
1467 spec.runtime_profile.max_spawn_depth = spec.max_spawn_depth;
1468 spec.runtime_profile.spawn_depth = spec.spawn_depth;
1469
1470 // Apply tool filtering
1471 if !exec.allowed_tools.is_empty() || !exec.disallowed_tools.is_empty() {
1472 spec.tool_profile = filter_tool_profile(&spec.tool_profile, exec);
1473 spec.runtime_profile.tools = match &spec.tool_profile {
1474 AgentWorkerToolProfile::Inherited => ToolScope::Inherit,
1475 AgentWorkerToolProfile::Explicit(tools) => ToolScope::Explicit(tools.clone()),
1476 };
1477 }
1478 // #4042: thread `FleetExecConfig.disallowed_tools` into the runtime profile's
1479 // deny-list so it is enforced at run time even for `Inherited` tool profiles,
1480 // which `filter_tool_profile` cannot narrow at spec time. Union with any
1481 // already-inherited entries (deny never relaxes). The subprocess Fleet exec
1482 // path separately passes `--disallowed-tools` on the CLI.
1483 for rule in &exec.disallowed_tools {
1484 if !spec.runtime_profile.denied_tools.contains(rule) {
1485 spec.runtime_profile.denied_tools.push(rule.clone());
1486 }
1487 }
1488
1489 spec
1490 }
1491
1492 pub(crate) fn fleet_effective_permissions_for_task(
1493 task_spec: &FleetTaskSpec,
1494 agent_profiles: &[AgentProfile],
1495 spec: &AgentWorkerSpec,
1496 ) -> FleetEffectivePermissions {
1497 let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
1498 .ok()
1499 .flatten();
1500 crate::fleet::role::fleet_effective_permissions(
1501 &spec.agent_type,
1502 &spec.runtime_profile,
1503 agent_profile.as_ref().map(|profile| profile.id.as_str()),
1504 agent_profile
1505 .as_ref()
1506 .map(|profile| profile_origin_label(profile.origin)),
1507 )
1508 }
1509
1510 /// Return a truthful dispatch warning when a brief asks for network-backed
1511 /// verification but the selected Fleet role cannot use the network.
1512 pub(crate) fn network_posture_warning_for_task(
1513 task: &FleetTaskSpec,
1514 agent_profiles: &[AgentProfile],
1515 session_model: Option<&str>,
1516 ) -> Option<String> {
1517 let brief = format!(
1518 "{}\n{}\n{}",
1519 task.name,
1520 task.objective.as_deref().unwrap_or_default(),
1521 task.instructions
1522 );
1523 let lower = brief.to_ascii_lowercase();
1524 let asks_for_network = [
1525 "gh ",
1526 "gh\n",
1527 "github",
1528 "curl ",
1529 "wget ",
1530 "http://",
1531 "https://",
1532 "network",
1533 "web search",
1534 "check ci",
1535 "check the pr",
1536 "check the issue",
1537 ]
1538 .iter()
1539 .any(|needle| lower.contains(needle));
1540 if !asks_for_network {
1541 return None;
1542 }
1543
1544 let agent_profile = resolve_task_agent_profile(task, agent_profiles)
1545 .ok()
1546 .flatten();
1547 let agent_profile = agent_profile.as_deref();
1548 let worker_profile = task.worker.as_ref();
1549 let role = effective_fleet_role(worker_profile, agent_profile);
1550 let agent_type = runtime_role_for_member(role.as_deref().unwrap_or_default());
1551 let tool_profile = fleet_tool_profile(worker_profile);
1552 let (model, model_source) = effective_fleet_model_with_source(
1553 session_model.unwrap_or("auto"),
1554 worker_profile,
1555 agent_profile,
1556 );
1557 let loadout = effective_fleet_loadout(worker_profile, agent_profile);
1558 let runtime = fleet_worker_runtime_profile_for_loadout(
1559 &agent_type,
1560 &tool_profile,
1561 &model,
1562 0,
1563 codewhale_config::FleetExecConfig::default().max_spawn_depth,
1564 &loadout,
1565 model_source,
1566 );
1567 if runtime.permissions.network {
1568 return None;
1569 }
1570
1571 Some(format!(
1572 "Fleet task `{}` mentions network-backed verification, but role `{}` has network=off and shell={}. Dispatch a `worker` role with shell `read_only` for gh/curl evidence, or revise the brief.",
1573 task.id,
1574 role.as_deref().unwrap_or("worker"),
1575 shell_policy_label(runtime.shell),
1576 ))
1577 }
1578
1579 fn profile_origin_label(origin: crate::fleet::roster::ProfileOrigin) -> &'static str {
1580 match origin {
1581 crate::fleet::roster::ProfileOrigin::BuiltIn => "built_in",
1582 crate::fleet::roster::ProfileOrigin::Plugin => "plugin",
1583 crate::fleet::roster::ProfileOrigin::Config => "config",
1584 crate::fleet::roster::ProfileOrigin::Personal => "personal",
1585 crate::fleet::roster::ProfileOrigin::Workspace => "workspace",
1586 crate::fleet::roster::ProfileOrigin::ClaudeCode => "claude",
1587 }
1588 }
1589
1590 fn shell_policy_label(shell: crate::worker_profile::ShellPolicy) -> &'static str {
1591 match shell {
1592 crate::worker_profile::ShellPolicy::None => "none",
1593 crate::worker_profile::ShellPolicy::ReadOnly => "read_only",
1594 crate::worker_profile::ShellPolicy::Full => "full",
1595 }
1596 }
1597
1598 /// Filter a tool profile against allowed/disallowed lists.
1599 fn filter_tool_profile(
1600 profile: &AgentWorkerToolProfile,
1601 exec: &codewhale_config::FleetExecConfig,
1602 ) -> AgentWorkerToolProfile {
1603 match profile {
1604 AgentWorkerToolProfile::Explicit(tools) => {
1605 let filtered: Vec<String> = tools
1606 .iter()
1607 .filter(|t| {
1608 // If allowed_tools is non-empty, only keep tools in the list
1609 if !exec.allowed_tools.is_empty() && !exec.allowed_tools.contains(t) {
1610 return false;
1611 }
1612 // Disallowed tools always win
1613 !exec.disallowed_tools.contains(t)
1614 })
1615 .cloned()
1616 .collect();
1617 AgentWorkerToolProfile::Explicit(filtered)
1618 }
1619 AgentWorkerToolProfile::Inherited => {
1620 // Inherited profiles can't be filtered at spec time;
1621 // the sub-agent spawn path applies tool filtering.
1622 AgentWorkerToolProfile::Inherited
1623 }
1624 }
1625 }
1626
1627 #[cfg(test)]
1628 mod tests {
1629 use super::*;
1630 use codewhale_protocol::fleet::{FleetHostSpec, FleetTaskBudget, FleetWorkspaceRequirements};
1631 use std::path::{Path, PathBuf};
1632
1633 fn explicit_deepseek_config() -> Config {
1634 Config {
1635 provider: Some("deepseek".to_string()),
1636 ..Config::default()
1637 }
1638 .with_legacy_root(Some("test-key".to_string()), None)
1639 }
1640
1641 #[test]
1642 fn read_only_roles_report_the_narrowed_shell_they_actually_run_under() {
1643 use crate::fleet::role;
1644 use crate::tools::subagent::FleetRole;
1645 use crate::worker_profile::ShellPolicy;
1646 let mut requested = WorkerRuntimeProfile {
1647 shell: ShellPolicy::Full,
1648 ..WorkerRuntimeProfile::default()
1649 };
1650
1651 for role in [FleetRole::Scout, FleetRole::Reviewer, FleetRole::Planner] {
1652 let effective = role::effective_runtime_profile_for_role(&role, &requested);
1653 assert_eq!(effective.shell, ShellPolicy::ReadOnly, "{role:?}");
1654 assert_eq!(
1655 role::fleet_effective_permissions(&role, &requested, None, None).shell,
1656 "read_only",
1657 "{role:?}"
1658 );
1659 }
1660 let worker = role::effective_runtime_profile_for_role(&FleetRole::Worker, &requested);
1661 assert_eq!(worker.shell, ShellPolicy::Full);
1662
1663 // A role that was already narrower than read-only keeps its posture.
1664 requested.shell = ShellPolicy::None;
1665 assert_eq!(
1666 role::effective_runtime_profile_for_role(&FleetRole::Scout, &requested).shell,
1667 ShellPolicy::None
1668 );
1669 }
1670
1671 #[test]
1672 fn worker_workspace_isolation_requires_linked_worktree_outside_manager() {
1673 let tmp = tempfile::tempdir().expect("tempdir");
1674 let manager = tmp.path().join("manager");
1675 std::fs::create_dir_all(manager.join("sub")).expect("manager dirs");
1676 let worktree = tmp.path().join("worktrees").join("wt-1");
1677 std::fs::create_dir_all(&worktree).expect("worktree dir");
1678
1679 assert!(!worker_workspace_is_isolated(&manager, &manager));
1680 assert!(!worker_workspace_is_isolated(
1681 &manager,
1682 &manager.join("sub")
1683 ));
1684 // An external directory without a worktree gitfile stays shared.
1685 assert!(!worker_workspace_is_isolated(&manager, &worktree));
1686
1687 std::fs::write(
1688 worktree.join(".git"),
1689 "gitdir: /elsewhere/.git/worktrees/wt-1\n",
1690 )
1691 .expect("gitfile");
1692 assert!(worker_workspace_is_isolated(&manager, &worktree));
1693 }
1694
1695 fn fleet_task(id: &str, worker: Option<FleetTaskWorkerProfile>) -> FleetTaskSpec {
1696 FleetTaskSpec {
1697 id: id.to_string(),
1698 name: id.to_string(),
1699 description: None,
1700 objective: Some(format!("Complete {id}")),
1701 instructions: format!("do {id}"),
1702 worker,
1703 workspace: Some(FleetWorkspaceRequirements {
1704 root: Some(PathBuf::from(".")),
1705 required_files: Vec::new(),
1706 writable_paths: vec![PathBuf::from(".")],
1707 environment: None,
1708 }),
1709 input_files: Vec::new(),
1710 context: Vec::new(),
1711 budget: None,
1712 tags: Vec::new(),
1713 expected_artifacts: Vec::new(),
1714 scorer: None,
1715 retry_policy: None,
1716 alert_policy: None,
1717 timeout_seconds: None,
1718 metadata: Default::default(),
1719 }
1720 }
1721
1722 #[test]
1723 fn write_capable_fleet_worker_requires_and_persists_a_bounded_claim() {
1724 let worker = FleetWorkerSpec {
1725 id: "worker-1".to_string(),
1726 name: "Worker".to_string(),
1727 host: FleetHostSpec::Local,
1728 trust_level: None,
1729 labels: Default::default(),
1730 capabilities: vec![],
1731 max_concurrent_tasks: None,
1732 };
1733 let mut unscoped = fleet_task("write", None);
1734 unscoped.workspace = None;
1735 let error = fleet_task_to_worker_spec_with_profiles(
1736 "worker-1",
1737 "run-1",
1738 &unscoped,
1739 &worker,
1740 "auto",
1741 Path::new("/tmp"),
1742 Path::new("/tmp"),
1743 &[],
1744 None,
1745 )
1746 .expect_err("unscoped Fleet writer must fail before registration");
1747 assert!(error.to_string().contains("declares no"), "{error:#}");
1748
1749 let scoped = fleet_task_to_worker_spec_with_profiles(
1750 "worker-1",
1751 "run-1",
1752 &fleet_task("write", None),
1753 &worker,
1754 "auto",
1755 Path::new("/tmp"),
1756 Path::new("/tmp"),
1757 &[],
1758 None,
1759 )
1760 .expect("bounded Fleet writer");
1761 let manifest = scoped.launch_manifest.expect("launch manifest");
1762 assert_eq!(manifest.child_id, "worker-1");
1763 assert_eq!(manifest.writable_roots, ["."]);
1764 assert_eq!(manifest.prompt, scoped.objective);
1765 }
1766
1767 #[test]
1768 fn fleet_claim_roots_share_one_manager_workspace_namespace() {
1769 let worker = FleetWorkerSpec {
1770 id: "worker-1".to_string(),
1771 name: "Worker".to_string(),
1772 host: FleetHostSpec::Local,
1773 trust_level: None,
1774 labels: Default::default(),
1775 capabilities: vec![],
1776 max_concurrent_tasks: None,
1777 };
1778 let mut nested = fleet_task("nested", None);
1779 nested.workspace = Some(FleetWorkspaceRequirements {
1780 root: Some(PathBuf::from("pkg-a")),
1781 writable_paths: vec![PathBuf::from("src")],
1782 ..FleetWorkspaceRequirements::default()
1783 });
1784 let mut root = fleet_task("root", None);
1785 root.workspace = Some(FleetWorkspaceRequirements {
1786 root: Some(PathBuf::from(".")),
1787 writable_paths: vec![PathBuf::from("pkg-a/src")],
1788 ..FleetWorkspaceRequirements::default()
1789 });
1790
1791 let nested_spec = fleet_task_to_worker_spec_with_profiles(
1792 "worker-1",
1793 "run-1",
1794 &nested,
1795 &worker,
1796 "auto",
1797 Path::new("/repo/pkg-a"),
1798 Path::new("/repo/pkg-a"),
1799 &[],
1800 None,
1801 )
1802 .unwrap();
1803 let root_spec = fleet_task_to_worker_spec_with_profiles(
1804 "worker-2",
1805 "run-1",
1806 &root,
1807 &worker,
1808 "auto",
1809 Path::new("/repo"),
1810 Path::new("/repo"),
1811 &[],
1812 None,
1813 )
1814 .unwrap();
1815 assert_eq!(
1816 nested_spec.launch_manifest.unwrap().writable_roots,
1817 ["pkg-a/src"]
1818 );
1819 assert_eq!(
1820 root_spec.launch_manifest.unwrap().writable_roots,
1821 ["pkg-a/src"]
1822 );
1823 assert_eq!(fleet_runtime_write_roots(&nested).unwrap(), ["src"]);
1824 }
1825
1826 #[test]
1827 fn fleet_manifest_rejects_control_characters_before_lease() {
1828 let worker = FleetWorkerSpec {
1829 id: "worker-1".to_string(),
1830 name: "Worker".to_string(),
1831 host: FleetHostSpec::Local,
1832 trust_level: None,
1833 labels: Default::default(),
1834 capabilities: vec![],
1835 max_concurrent_tasks: None,
1836 };
1837 let mut bad_contract = fleet_task("bad-contract", None);
1838 bad_contract.metadata.insert(
1839 "coordination_contracts".to_string(),
1840 serde_json::json!(["api\ncontract"]),
1841 );
1842 assert!(
1843 fleet_task_to_worker_spec_with_profiles(
1844 "worker-1",
1845 "run-1",
1846 &bad_contract,
1847 &worker,
1848 "auto",
1849 Path::new("/repo"),
1850 Path::new("/repo"),
1851 &[],
1852 None,
1853 )
1854 .unwrap_err()
1855 .to_string()
1856 .contains("one non-empty line")
1857 );
1858
1859 let mut bad_path = fleet_task("bad-path", None);
1860 bad_path.workspace.as_mut().unwrap().writable_paths = vec![PathBuf::from("src\nother")];
1861 assert!(
1862 fleet_task_to_worker_spec_with_profiles(
1863 "worker-1",
1864 "run-1",
1865 &bad_path,
1866 &worker,
1867 "auto",
1868 Path::new("/repo"),
1869 Path::new("/repo"),
1870 &[],
1871 None,
1872 )
1873 .unwrap_err()
1874 .to_string()
1875 .contains("one repo-relative line")
1876 );
1877 }
1878
1879 fn worker_profile(
1880 agent_profile: Option<&str>,
1881 role: Option<&str>,
1882 loadout: Option<&str>,
1883 model_class: Option<&str>,
1884 model: Option<&str>,
1885 tools: Vec<&str>,
1886 ) -> FleetTaskWorkerProfile {
1887 FleetTaskWorkerProfile {
1888 agent_profile: agent_profile.map(str::to_string),
1889 role: role.map(str::to_string),
1890 loadout: loadout.map(str::to_string),
1891 model_class: model_class.map(str::to_string),
1892 model: model.map(str::to_string),
1893 tool_profile: None,
1894 tools: tools.into_iter().map(str::to_string).collect(),
1895 capabilities: Vec::new(),
1896 }
1897 }
1898
1899 fn agent_profile(
1900 id: &str,
1901 role: &str,
1902 instructions: Option<&str>,
1903 loadout: codewhale_config::FleetLoadout,
1904 ) -> AgentProfile {
1905 AgentProfile {
1906 native_preset: None,
1907 id: id.to_string(),
1908 display_name: Some(format!("{role} profile")),
1909 description: Some(format!("{role} description")),
1910 requires: Vec::new(),
1911 profile: codewhale_config::FleetProfile {
1912 slot: codewhale_config::FleetSlot::from_name(role),
1913 role: codewhale_config::FleetRole {
1914 name: role.to_string(),
1915 description: Some(format!("{role} role")),
1916 instructions: instructions.map(str::to_string),
1917 },
1918 loadout,
1919 model: None,
1920 provider: None,
1921 reasoning_effort: None,
1922 permissions: codewhale_config::FleetProfilePermissions::default(),
1923 delegation: codewhale_config::FleetDelegationHints::default(),
1924 },
1925 source: std::path::PathBuf::from(format!("{id}.toml")),
1926 origin: crate::fleet::roster::ProfileOrigin::Workspace,
1927 plugin_authority: None,
1928 }
1929 }
1930
1931 #[test]
1932 fn fleet_role_smoke_runner_maps_to_verifier() {
1933 assert_eq!(runtime_role_for_member("smoke-runner"), FleetRole::Verifier);
1934 }
1935
1936 #[test]
1937 fn fleet_role_read_only_maps_to_explore() {
1938 assert_eq!(runtime_role_for_member("read-only"), FleetRole::Scout);
1939 }
1940
1941 #[test]
1942 fn fleet_role_reviewer_maps_to_review() {
1943 assert_eq!(runtime_role_for_member("reviewer"), FleetRole::Reviewer);
1944 }
1945
1946 #[test]
1947 fn fleet_role_builder_maps_to_implementer() {
1948 assert_eq!(runtime_role_for_member("builder"), FleetRole::Builder);
1949 }
1950
1951 /// An *absent* role is not an *unknown* role: a Fleet task with no `role`
1952 /// field has always run on the documented general default, and #5575's
1953 /// fail-closed rule is about labels nobody declared, not about the
1954 /// unspecified case.
1955 #[test]
1956 fn an_unspecified_role_still_maps_to_general() {
1957 assert_eq!(runtime_role_for_member(""), FleetRole::Worker);
1958 assert_eq!(runtime_role_for_member(" "), FleetRole::Worker);
1959 }
1960
1961 #[test]
1962 fn fleet_role_manager_and_coordinator_map_to_general() {
1963 assert_eq!(runtime_role_for_member("manager"), FleetRole::Worker);
1964 assert_eq!(runtime_role_for_member("coordinator"), FleetRole::Worker);
1965 }
1966
1967 #[test]
1968 fn fleet_role_operator_maps_to_general_explicitly() {
1969 // The operator coordinates the overall work (assigns managers to
1970 // workflows), so it needs the full General surface — by an explicit
1971 // match arm, not the unknown-role fall-through.
1972 assert_eq!(runtime_role_for_member("operator"), FleetRole::Worker);
1973 }
1974
1975 #[test]
1976 fn agent_profile_member_slot_overrides_legacy_role_label() {
1977 // Regression (fleet-e12f3160): a task whose legacy role label is
1978 // "manager" but whose agent_profile selects `member:reviewer` must run
1979 // with reviewer authority, not fall through to the "manager" label and
1980 // get stuck as a write-capable worker that never leases.
1981 let reviewer = agent_profile(
1982 "reviewer",
1983 "reviewer",
1984 None,
1985 codewhale_config::FleetLoadout::Inherit,
1986 );
1987 let task = fleet_task(
1988 "conflict",
1989 Some(worker_profile(
1990 Some("member:reviewer"),
1991 Some("manager"),
1992 None,
1993 None,
1994 None,
1995 vec!["read_file"],
1996 )),
1997 );
1998 let worker = FleetWorkerSpec {
1999 id: "worker-1".to_string(),
2000 name: "Worker".to_string(),
2001 host: FleetHostSpec::Local,
2002 trust_level: None,
2003 labels: Default::default(),
2004 capabilities: vec![],
2005 max_concurrent_tasks: None,
2006 };
2007 let spec = fleet_task_to_worker_spec_with_profiles(
2008 "worker-1",
2009 "run-1",
2010 &task,
2011 &worker,
2012 "auto",
2013 Path::new("/tmp"),
2014 Path::new("/tmp"),
2015 &[reviewer],
2016 None,
2017 )
2018 .expect("member selector resolves to the reviewer roster profile");
2019
2020 assert_eq!(
2021 spec.role.as_deref(),
2022 Some("reviewer"),
2023 "the selected member's slot must win over the legacy role label"
2024 );
2025 assert_eq!(
2026 spec.agent_type,
2027 FleetRole::Reviewer,
2028 "reviewer authority must not be silently widened to a write-capable worker"
2029 );
2030 }
2031
2032 #[test]
2033 fn legacy_role_label_never_widens_into_a_member_write_slot() {
2034 // Mirror of the regression above (#5945 review): member `alice` sits
2035 // in the write-capable `implement` slot while the task's legacy label
2036 // says `reviewer`. Letting the member win unconditionally would turn a
2037 // read-only task into a write-capable one; letting the label win would
2038 // re-open the original bug. Neither is a posture — the spec is
2039 // rejected before anything is persisted.
2040 let alice = agent_profile(
2041 "alice",
2042 "implement",
2043 None,
2044 codewhale_config::FleetLoadout::Inherit,
2045 );
2046 let mut task = fleet_task(
2047 "mirror-conflict",
2048 Some(worker_profile(
2049 Some("member:alice"),
2050 Some("reviewer"),
2051 None,
2052 None,
2053 None,
2054 vec!["read_file"],
2055 )),
2056 );
2057
2058 let error = freeze_fleet_task_members(std::slice::from_mut(&mut task), &[alice], false)
2059 .expect_err("a read-only label must not become a write-capable member slot");
2060 assert!(error.to_string().contains("\"implement\""), "{error:#}");
2061 assert!(error.to_string().contains("\"reviewer\""), "{error:#}");
2062 assert!(
2063 !task.metadata.contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY),
2064 "a rejected spec must not persist a member snapshot"
2065 );
2066 assert_eq!(
2067 task.worker.as_ref().unwrap().role.as_deref(),
2068 Some("reviewer"),
2069 "the rejected task keeps its authored label untouched"
2070 );
2071 }
2072
2073 #[test]
2074 fn conflicting_member_and_role_label_is_rejected_at_freeze_naming_both_postures() {
2075 // The original fleet-e12f3160 spec: `member:reviewer` plus a
2076 // `manager` label. It is an authoring error, and the message must name
2077 // both postures so the author can see which one to drop.
2078 let reviewer = agent_profile(
2079 "reviewer",
2080 "reviewer",
2081 None,
2082 codewhale_config::FleetLoadout::Inherit,
2083 );
2084 let mut task = fleet_task(
2085 "conflict",
2086 Some(worker_profile(
2087 Some("member:reviewer"),
2088 Some("manager"),
2089 None,
2090 None,
2091 None,
2092 vec!["read_file"],
2093 )),
2094 );
2095
2096 let error = freeze_fleet_task_members(std::slice::from_mut(&mut task), &[reviewer], true)
2097 .expect_err("a task cannot carry two postures");
2098 let message = error.to_string();
2099 assert!(message.contains("selects member \"reviewer\""), "{error:#}");
2100 assert!(message.contains("whose role is \"reviewer\""), "{error:#}");
2101 assert!(
2102 message.contains("worker.role names a different posture \"manager\""),
2103 "{error:#}"
2104 );
2105 }
2106
2107 #[test]
2108 fn role_label_alias_of_the_selected_member_role_is_not_a_conflict() {
2109 // Casing and legacy aliases are spelling, not posture: `Code-Review`
2110 // canonicalizes to `reviewer`, which is exactly the member's role.
2111 let reviewer = agent_profile(
2112 "reviewer",
2113 "reviewer",
2114 None,
2115 codewhale_config::FleetLoadout::Inherit,
2116 );
2117 let mut task = fleet_task(
2118 "alias",
2119 Some(worker_profile(
2120 Some("member:reviewer"),
2121 Some("Code-Review"),
2122 None,
2123 None,
2124 None,
2125 vec!["read_file"],
2126 )),
2127 );
2128
2129 let profiles = [reviewer];
2130 freeze_fleet_task_members(std::slice::from_mut(&mut task), &profiles, true)
2131 .expect("an alias of the member's own role must freeze cleanly");
2132 let worker = task.worker.as_ref().unwrap();
2133 assert_eq!(worker.agent_profile.as_deref(), Some("member:reviewer"));
2134 assert_eq!(worker.role.as_deref(), Some("reviewer"));
2135 assert!(task.metadata.contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY));
2136
2137 let resolved = resolve_task_agent_profile(&task, &profiles)
2138 .unwrap()
2139 .expect("frozen member");
2140 assert_eq!(
2141 effective_fleet_role_with_source(task.worker.as_ref(), Some(&resolved)),
2142 (Some("reviewer".to_string()), Some("agent_profile.role"))
2143 );
2144 }
2145
2146 #[test]
2147 fn consultant_and_legacy_advisory_aliases_share_the_consultant_posture() {
2148 for role in ["consultant", "oracle", "advisor"] {
2149 assert_eq!(
2150 runtime_role_for_member(role),
2151 FleetRole::Consultant,
2152 "role {role}"
2153 );
2154 }
2155 }
2156
2157 #[test]
2158 fn fleet_role_synthesizer_family_maps_to_read_only_plan() {
2159 // A synthesizer must never fall through to General's full-write
2160 // posture; Planner is read-only (reads plus read-only shell probes).
2161 for role in ["synthesizer", "summarizer", "reducer"] {
2162 assert_eq!(
2163 runtime_role_for_member(role),
2164 FleetRole::Planner,
2165 "role {role}"
2166 );
2167 }
2168 }
2169
2170 #[test]
2171 fn roster_member_agent_type_uses_role_then_slot() {
2172 let member = agent_profile(
2173 "synthesizer",
2174 "synthesizer",
2175 None,
2176 codewhale_config::FleetLoadout::Fast,
2177 );
2178 assert_eq!(roster_member_agent_type(&member), FleetRole::Planner);
2179
2180 let mut slot_only = agent_profile(
2181 "custom-summarizer",
2182 "summarizer",
2183 None,
2184 codewhale_config::FleetLoadout::Inherit,
2185 );
2186 slot_only.profile.role.name = String::new();
2187 assert_eq!(
2188 slot_only.profile.slot,
2189 codewhale_config::FleetSlot::Summarizer
2190 );
2191 assert_eq!(roster_member_agent_type(&slot_only), FleetRole::Planner);
2192 }
2193
2194 /// #5285: every seeded dispatch posture maps back to exactly its own
2195 /// runtime role, so the roster listing and the dispatch enum cannot drift
2196 /// into a parallel taxonomy.
2197 #[test]
2198 fn seeded_posture_members_map_1to1_to_their_fleet_role() {
2199 let roster = crate::fleet::roster::FleetRoster::built_ins_only();
2200 for (id, expected) in [
2201 ("worker", FleetRole::Worker),
2202 ("scout", FleetRole::Scout),
2203 ("planner", FleetRole::Planner),
2204 ("reviewer", FleetRole::Reviewer),
2205 ("builder", FleetRole::Builder),
2206 ("verifier", FleetRole::Verifier),
2207 ("consultant", FleetRole::Consultant),
2208 ("custom", FleetRole::Custom),
2209 ] {
2210 let member = roster
2211 .get(id)
2212 .unwrap_or_else(|| panic!("seeded posture {id:?} must be a roster member"));
2213 assert_eq!(
2214 roster_member_agent_type(member),
2215 expected,
2216 "roster member {id:?} must resolve to its own dispatch posture"
2217 );
2218 }
2219 }
2220
2221 /// #5575: an undeclared role name must not be able to hand a worker write
2222 /// authority. Before this fix the durable driver answered `Worker` here and
2223 /// the exact driver answered `Custom` — two different tables, both
2224 /// write-capable and full-shell, for a string nobody declared.
2225 #[test]
2226 fn unknown_role_fails_closed_to_the_read_only_explore_posture() {
2227 for unknown in ["nonexistent-role", "audit-lead", "release-checker"] {
2228 assert_eq!(
2229 runtime_role_for_member(unknown),
2230 FleetRole::Scout,
2231 "unknown role {unknown:?} must fail closed, never to a write-capable posture"
2232 );
2233 assert!(
2234 !WorkerRuntimeProfile::for_role(runtime_role_for_member(unknown))
2235 .permissions
2236 .write,
2237 "unknown role {unknown:?} must never carry write authority"
2238 );
2239 }
2240
2241 // The escape hatch is a *declared* role, not a typo: an operator who
2242 // wants "inherit whatever the parent has" spells it `custom`.
2243 assert_eq!(runtime_role_for_member("custom"), FleetRole::Custom);
2244 }
2245
2246 /// #5575: both Fleet drivers resolve names through the same mapper, so the
2247 /// aliases the durable driver used to own privately now resolve to the same
2248 /// posture on the exact/named-Fleet driver — which previously dropped every
2249 /// one of them into write-capable `custom`.
2250 #[test]
2251 fn the_two_fleet_drivers_agree_on_every_member_role_alias() {
2252 let session = codewhale_workflow::PermissionCeiling {
2253 write: true,
2254 network_tool: true,
2255 shell: codewhale_workflow::ShellCeiling::Full,
2256 delegation_depth: 2,
2257 tools: true,
2258 };
2259 for (role, expected, expected_write) in [
2260 ("smoke-runner", FleetRole::Verifier, "read_only"),
2261 ("read-only", FleetRole::Scout, "read_only"),
2262 ("synthesizer", FleetRole::Planner, "read_only"),
2263 ("summarizer", FleetRole::Planner, "read_only"),
2264 ("reducer", FleetRole::Planner, "read_only"),
2265 ("manager", FleetRole::Worker, "workspace_write"),
2266 ("coordinator", FleetRole::Worker, "workspace_write"),
2267 ("operator", FleetRole::Worker, "workspace_write"),
2268 ] {
2269 assert_eq!(runtime_role_for_member(role), expected, "role {role}");
2270 // The exact driver's authority comes from the same mapper.
2271 assert_eq!(
2272 crate::fleet::role::ChildAuthority::from_runtime_role(role, session)
2273 .write_authority,
2274 expected_write,
2275 "role {role} must resolve the same authority on the exact driver"
2276 );
2277 }
2278 }
2279
2280 #[test]
2281 fn resolved_config_mints_secret_free_fleet_route_snapshot() {
2282 let task = fleet_task(
2283 "route-1",
2284 Some(worker_profile(
2285 None,
2286 Some("builder"),
2287 Some("fast"),
2288 None,
2289 None,
2290 vec!["read_file"],
2291 )),
2292 );
2293 let config = explicit_deepseek_config();
2294 let route = resolve_fleet_route_with_config(&task, &[], None, Some(&config))
2295 .expect("explicit default route should resolve offline");
2296
2297 // Honest, non-empty route shape from the resolver.
2298 assert!(!route.provider_id.is_empty());
2299 assert!(!route.provider_kind.is_empty());
2300 assert!(!route.wire_model_id.is_empty());
2301 // DeepSeek Flash rides Responses since a1c1741afa (see bundled_offerings):
2302 // the default route follows the shipped transport, not the old pin.
2303 assert_eq!(route.protocol, "responses");
2304 assert_eq!(route.role.as_deref(), Some("implement"));
2305 assert_eq!(route.loadout.as_deref(), Some("fast"));
2306 assert_eq!(route.model_class, None);
2307 assert_eq!(route.model_route.as_deref(), Some("inherit"));
2308 assert_eq!(route.reasoning_effort, None);
2309 assert_eq!(route.role_source.as_deref(), Some("task.role"));
2310 assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
2311 assert_eq!(route.model_class_source, None);
2312 assert_eq!(route.model_source.as_deref(), Some("resolver.default"));
2313 assert_eq!(route.source, "runtime_route");
2314
2315 // No-secrets: the serialized snapshot carries no credential markers.
2316 let json = serde_json::to_string(&route).unwrap();
2317 let haystack = json.to_ascii_lowercase();
2318 for needle in [
2319 "api_key",
2320 "apikey",
2321 "api-key",
2322 "authorization",
2323 "bearer ",
2324 "auth_token",
2325 "auth-token",
2326 "password",
2327 "credential",
2328 "sk-ant-",
2329 "sk-proj-",
2330 "sk-or-",
2331 "secret",
2332 ] {
2333 assert!(
2334 !haystack.contains(needle),
2335 "resolved-route JSON must not contain secret marker {needle:?}: {json}"
2336 );
2337 }
2338 }
2339
2340 #[test]
2341 fn resolve_fleet_route_omits_inherit_loadout() {
2342 // No loadout/model_class intent → `inherit` collapses to None, never an
2343 // "inherit" string on the receipt.
2344 let task = fleet_task(
2345 "route-2",
2346 Some(worker_profile(
2347 None,
2348 Some("scout"),
2349 None,
2350 None,
2351 None,
2352 vec!["read_file"],
2353 )),
2354 );
2355 let config = explicit_deepseek_config();
2356 let route = resolve_fleet_route_with_config(&task, &[], None, Some(&config))
2357 .expect("route should resolve");
2358 assert_eq!(route.role.as_deref(), Some("explore"));
2359 assert!(route.loadout.is_none());
2360 assert_eq!(route.loadout_source, None);
2361 assert_eq!(route.model_route.as_deref(), Some("inherit"));
2362 assert_eq!(route.model_source.as_deref(), Some("resolver.default"));
2363 }
2364
2365 #[test]
2366 fn advisory_task_aliases_emit_advisor_in_prompts_and_route_receipts() {
2367 for alias in ["oracle", "advisor"] {
2368 let task = fleet_task(
2369 &format!("legacy-{alias}"),
2370 Some(worker_profile(
2371 None,
2372 Some(alias),
2373 None,
2374 None,
2375 None,
2376 vec!["read_file"],
2377 )),
2378 );
2379
2380 let prompt = fleet_task_prompt(&task);
2381 assert!(
2382 prompt.contains("Fleet member (advisor)"),
2383 "prompt must canonicalize {alias}: {prompt}"
2384 );
2385 if alias != "advisor" {
2386 assert!(
2387 !prompt.contains(&format!("Fleet member ({alias})")),
2388 "prompt must not emit compatibility alias {alias}: {prompt}"
2389 );
2390 }
2391
2392 let config = explicit_deepseek_config();
2393 let resolved = resolve_fleet_route_with_config(&task, &[], None, Some(&config))
2394 .expect("compatibility role should resolve a receipt route");
2395 assert_eq!(resolved.role.as_deref(), Some("advisor"));
2396 assert_eq!(resolved.role_source.as_deref(), Some("task.role"));
2397
2398 let reported = resolve_fleet_route_from_worker_report(
2399 &task,
2400 &[],
2401 None,
2402 "deepseek",
2403 None,
2404 "deepseek-v4-pro",
2405 )
2406 .expect("worker-reported route should retain canonical role metadata");
2407 assert_eq!(reported.role.as_deref(), Some("advisor"));
2408 assert_eq!(reported.role_source.as_deref(), Some("task.role"));
2409 }
2410 }
2411
2412 #[test]
2413 fn resolve_fleet_route_records_model_class_and_profile_sources() {
2414 let mut profile = agent_profile(
2415 "audit",
2416 "reviewer",
2417 None,
2418 codewhale_config::FleetLoadout::Inherit,
2419 );
2420 profile.profile.model = Some("deepseek-v4-flash".to_string());
2421 let task = fleet_task(
2422 "route-profile",
2423 Some(worker_profile(
2424 Some("audit"),
2425 None,
2426 None,
2427 Some("balanced"),
2428 None,
2429 vec!["read_file"],
2430 )),
2431 );
2432 let config = explicit_deepseek_config();
2433 let route = resolve_fleet_route_with_config(&task, &[profile], None, Some(&config))
2434 .expect("profile route should resolve");
2435
2436 assert_eq!(route.role.as_deref(), Some("reviewer"));
2437 assert_eq!(route.role_source.as_deref(), Some("agent_profile.role"));
2438 assert_eq!(route.loadout.as_deref(), Some("balanced"));
2439 assert_eq!(route.loadout_source.as_deref(), Some("task.model_class"));
2440 assert_eq!(route.model_class.as_deref(), Some("balanced"));
2441 assert_eq!(
2442 route.model_class_source.as_deref(),
2443 Some("task.model_class")
2444 );
2445 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
2446 assert_eq!(route.model_route.as_deref(), Some("fixed"));
2447 assert_eq!(route.wire_model_id, "deepseek-v4-flash");
2448 assert_eq!(route.reasoning_effort, None);
2449 }
2450
2451 #[test]
2452 fn fleet_tool_profile_empty_uses_inherited() {
2453 let profile = FleetTaskWorkerProfile {
2454 agent_profile: None,
2455 role: None,
2456 loadout: None,
2457 model_class: None,
2458 model: None,
2459 tool_profile: None,
2460 tools: vec![],
2461 capabilities: vec![],
2462 };
2463 assert_eq!(
2464 fleet_tool_profile(Some(&profile)),
2465 AgentWorkerToolProfile::Inherited
2466 );
2467 }
2468
2469 #[test]
2470 fn fleet_tool_profile_explicit_passes_tools() {
2471 let profile = FleetTaskWorkerProfile {
2472 agent_profile: None,
2473 role: None,
2474 loadout: None,
2475 model_class: None,
2476 model: None,
2477 tool_profile: None,
2478 tools: vec!["cargo".to_string(), "git".to_string()],
2479 capabilities: vec![],
2480 };
2481 assert_eq!(
2482 fleet_tool_profile(Some(&profile)),
2483 AgentWorkerToolProfile::Explicit(vec!["cargo".to_string(), "git".to_string()])
2484 );
2485 }
2486
2487 #[test]
2488 fn network_brief_does_not_warn_for_built_in_roles_that_keep_network_reads() {
2489 let reviewer = fleet_task(
2490 "triage",
2491 Some(worker_profile(
2492 None,
2493 Some("reviewer"),
2494 None,
2495 None,
2496 None,
2497 vec!["read_file"],
2498 )),
2499 );
2500 let mut reviewer = reviewer;
2501 reviewer.instructions = "Use gh to check the PR and report CI evidence.".to_string();
2502 // Scout/reviewer lanes now ship the read-only inspection posture (network reach,
2503 // bounded verification surface; see worker_profile::for_role), so a
2504 // network-dependent reviewer brief no longer warns by default.
2505 assert!(
2506 network_posture_warning_for_task(&reviewer, &[], None).is_none(),
2507 "reviewer read-only inspection posture must not warn for a gh brief"
2508 );
2509
2510 // Every built-in role now keeps network reach (a read); read-only
2511 // roles stay read-only on the workspace by intent. A planner brief that
2512 // needs gh/curl therefore no longer warns either.
2513 let mut planner = reviewer.clone();
2514 planner.worker.as_mut().unwrap().role = Some("planner".to_string());
2515 assert!(
2516 network_posture_warning_for_task(&planner, &[], None).is_none(),
2517 "planner keeps network reads by default"
2518 );
2519
2520 let mut worker = reviewer.clone();
2521 worker.worker.as_mut().unwrap().role = Some("worker".to_string());
2522 assert!(network_posture_warning_for_task(&worker, &[], None).is_none());
2523 }
2524
2525 #[test]
2526 fn non_network_brief_does_not_warn_for_networkless_role() {
2527 let task = fleet_task(
2528 "local-review",
2529 Some(worker_profile(
2530 None,
2531 Some("reviewer"),
2532 None,
2533 None,
2534 None,
2535 vec!["read_file"],
2536 )),
2537 );
2538 assert!(network_posture_warning_for_task(&task, &[], None).is_none());
2539 }
2540
2541 #[test]
2542 fn fleet_task_prompt_includes_instructions_context_and_input_files() {
2543 let task = FleetTaskSpec {
2544 id: "review".to_string(),
2545 name: "Review protocol".to_string(),
2546 description: None,
2547 objective: Some("Find protocol regressions".to_string()),
2548 instructions: "Read the fleet protocol and report issues.".to_string(),
2549 worker: None,
2550 workspace: None,
2551 input_files: vec![std::path::PathBuf::from("crates/protocol/src/fleet.rs")],
2552 context: vec!["Keep the report concise.".to_string()],
2553 budget: None,
2554 tags: vec![],
2555 expected_artifacts: vec![],
2556 scorer: None,
2557 retry_policy: None,
2558 alert_policy: None,
2559 timeout_seconds: None,
2560 metadata: Default::default(),
2561 };
2562
2563 let prompt = fleet_task_prompt(&task);
2564
2565 assert!(prompt.contains("summoned as a Codewhale Fleet member (general)"));
2566 assert!(prompt.contains("Fleet operating contract:"));
2567 assert!(prompt.contains("keep sibling or topology assumptions out of your answer"));
2568 assert!(prompt.contains("Review protocol"));
2569 assert!(prompt.contains("Find protocol regressions"));
2570 assert!(prompt.contains("Read the fleet protocol and report issues."));
2571 assert!(prompt.contains("Keep the report concise."));
2572 assert!(prompt.contains("crates/protocol/src/fleet.rs"));
2573 }
2574
2575 #[test]
2576 fn fleet_worker_spec_resolves_agent_profile_role_prompt_and_loadout() {
2577 let profile = agent_profile(
2578 "reviewer",
2579 "reviewer",
2580 Some("Focus on regressions and missing tests."),
2581 codewhale_config::FleetLoadout::Custom("balanced".to_string()),
2582 );
2583 let task = fleet_task(
2584 "review",
2585 Some(worker_profile(
2586 Some("reviewer"),
2587 None,
2588 None,
2589 None,
2590 None,
2591 vec![],
2592 )),
2593 );
2594 let worker = FleetWorkerSpec {
2595 id: "worker-1".to_string(),
2596 name: "Worker".to_string(),
2597 host: FleetHostSpec::Local,
2598 trust_level: None,
2599 labels: Default::default(),
2600 capabilities: vec![],
2601 max_concurrent_tasks: None,
2602 };
2603
2604 let profiles = vec![profile];
2605 let spec = fleet_task_to_worker_spec_with_profiles(
2606 "worker-1",
2607 "run-1",
2608 &task,
2609 &worker,
2610 "auto",
2611 std::path::Path::new("/tmp"),
2612 std::path::Path::new("/tmp"),
2613 &profiles,
2614 None,
2615 )
2616 .unwrap();
2617
2618 assert_eq!(spec.role.as_deref(), Some("reviewer"));
2619 assert_eq!(spec.agent_type, FleetRole::Reviewer);
2620 assert!(
2621 spec.objective
2622 .contains("summoned as a Codewhale Fleet member (reviewer)")
2623 );
2624 assert!(spec.objective.contains("Fleet profile: reviewer"));
2625 assert!(
2626 spec.objective
2627 .contains("Focus on regressions and missing tests.")
2628 );
2629 assert_eq!(spec.runtime_profile.role, FleetRole::Reviewer);
2630 assert_eq!(spec.runtime_profile.model, ModelRoute::Auto);
2631
2632 let permissions = fleet_effective_permissions_for_task(&task, &profiles, &spec);
2633 assert_eq!(permissions.profile_id.as_deref(), Some("reviewer"));
2634 assert_eq!(permissions.profile_origin.as_deref(), Some("workspace"));
2635 assert_eq!(permissions.source, "worker_runtime_profile");
2636 }
2637
2638 #[test]
2639 fn role_only_consultant_aliases_keep_high_reasoning_and_locked_posture() {
2640 let worker = FleetWorkerSpec {
2641 id: "worker-1".to_string(),
2642 name: "Worker".to_string(),
2643 host: FleetHostSpec::Local,
2644 trust_level: None,
2645 labels: Default::default(),
2646 capabilities: vec![],
2647 max_concurrent_tasks: None,
2648 };
2649
2650 for parent_effort in [None, Some("low")] {
2651 for role in ["consultant", "oracle", "advisor"] {
2652 let task = fleet_task(
2653 &format!("advice-{role}"),
2654 Some(worker_profile(None, Some(role), None, None, None, vec![])),
2655 );
2656 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
2657 parent.reasoning_effort = parent_effort.map(str::to_string);
2658 let spec = fleet_task_to_worker_spec_with_profiles(
2659 "worker-1",
2660 "run-1",
2661 &task,
2662 &worker,
2663 "deepseek-v4-pro",
2664 std::path::Path::new("/tmp"),
2665 std::path::Path::new("/tmp"),
2666 &[],
2667 Some(&parent),
2668 )
2669 .expect("role-only consultant should produce a worker spec");
2670
2671 assert_eq!(spec.role.as_deref(), Some("advisor"));
2672 assert_eq!(spec.agent_type, FleetRole::Consultant);
2673 assert_eq!(spec.model, "deepseek-v4-pro", "session model is inherited");
2674 assert_eq!(spec.runtime_profile.model, ModelRoute::Inherit);
2675 assert_eq!(
2676 spec.runtime_profile.provider, None,
2677 "provider is not invented"
2678 );
2679 assert_eq!(
2680 spec.runtime_profile.reasoning_effort.as_deref(),
2681 Some("high"),
2682 "role={role}, parent={parent_effort:?}"
2683 );
2684 assert!(!spec.runtime_profile.permissions.write);
2685 assert!(
2686 spec.runtime_profile.permissions.network,
2687 "counsel reads the web; only workspace mutation is withheld"
2688 );
2689 assert_eq!(
2690 spec.runtime_profile.shell,
2691 crate::worker_profile::ShellPolicy::None
2692 );
2693 assert_eq!(
2694 fleet_worker_launch_reasoning_effort(&task, &[]).as_deref(),
2695 Some("high")
2696 );
2697 let config = explicit_deepseek_config();
2698 let route = resolve_fleet_route_with_config(
2699 &task,
2700 &[],
2701 Some("deepseek-v4-pro"),
2702 Some(&config),
2703 )
2704 .expect("receipt route resolves");
2705 assert_eq!(route.role.as_deref(), Some("advisor"));
2706 assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
2707 }
2708 }
2709 }
2710
2711 #[test]
2712 fn fleet_worker_spec_inherits_session_run_model_when_unpinned() {
2713 // No task-level model, no roster profile model: the run model (the
2714 // session route — the operator's model) must flow through to the
2715 // worker spec, so the model picked in /model is the model that runs.
2716 let task = fleet_task("build", None);
2717 let worker = FleetWorkerSpec {
2718 id: "worker-1".to_string(),
2719 name: "Worker".to_string(),
2720 host: FleetHostSpec::Local,
2721 trust_level: None,
2722 labels: Default::default(),
2723 capabilities: vec![],
2724 max_concurrent_tasks: None,
2725 };
2726
2727 let spec = fleet_task_to_worker_spec_with_profiles(
2728 "worker-1",
2729 "run-1",
2730 &task,
2731 &worker,
2732 "deepseek-v4-flash",
2733 std::path::Path::new("/tmp"),
2734 std::path::Path::new("/tmp"),
2735 &[],
2736 None,
2737 )
2738 .unwrap();
2739 assert_eq!(spec.model, "deepseek-v4-flash");
2740
2741 // Legacy headless callers with no session still get the auto sentinel.
2742 let legacy = fleet_task_to_worker_spec_with_profiles(
2743 "worker-1",
2744 "run-1",
2745 &task,
2746 &worker,
2747 "auto",
2748 std::path::Path::new("/tmp"),
2749 std::path::Path::new("/tmp"),
2750 &[],
2751 None,
2752 )
2753 .unwrap();
2754 assert_eq!(legacy.model, "auto");
2755 }
2756
2757 #[test]
2758 fn resolve_fleet_route_uses_session_model_as_run_fallback() {
2759 // Route receipts must agree with dispatch: when neither the task nor
2760 // a roster profile pins a model, the session route is the run-level
2761 // fallback and the receipt records it came from `run.model`. A model
2762 // name alone is not provider authority, so the config-less helper
2763 // refuses to invent a route.
2764 let task = fleet_task("route-session", None);
2765 assert!(resolve_fleet_route(&task, &[], Some("deepseek-v4-flash")).is_none());
2766 let config = explicit_deepseek_config();
2767 let route =
2768 resolve_fleet_route_with_config(&task, &[], Some("deepseek-v4-flash"), Some(&config))
2769 .expect("resolved config should authorize the session-model route");
2770 assert_eq!(route.model_source.as_deref(), Some("run.model"));
2771 assert_eq!(route.wire_model_id, "deepseek-v4-flash");
2772
2773 // Task/profile pins still win over the session route.
2774 let mut profile = agent_profile(
2775 "audit",
2776 "reviewer",
2777 None,
2778 codewhale_config::FleetLoadout::Inherit,
2779 );
2780 profile.profile.model = Some("deepseek-v4-pro".to_string());
2781 let pinned_task = fleet_task(
2782 "route-pinned",
2783 Some(worker_profile(
2784 Some("audit"),
2785 None,
2786 None,
2787 None,
2788 None,
2789 vec![],
2790 )),
2791 );
2792 let pinned = resolve_fleet_route_with_config(
2793 &pinned_task,
2794 &[profile],
2795 Some("deepseek-v4-flash"),
2796 Some(&config),
2797 )
2798 .expect("pinned route should resolve under the configured provider");
2799 assert_eq!(pinned.model_source.as_deref(), Some("agent_profile.model"));
2800 assert_eq!(pinned.wire_model_id, "deepseek-v4-pro");
2801 }
2802
2803 #[test]
2804 fn validate_fleet_task_routes_rejects_unresolvable_providerless_pin() {
2805 // #4866 Luna failure mode: a profile pins a concrete model with no
2806 // explicit provider. The runtime never infers a provider from spelling,
2807 // so a model that does not resolve on the default provider must be
2808 // rejected at run creation with a clear error, not fail silently later.
2809 let mut profile = agent_profile(
2810 "builder-luna",
2811 "builder",
2812 None,
2813 codewhale_config::FleetLoadout::Inherit,
2814 );
2815 profile.profile.model = Some("gpt-5.6-luna".to_string());
2816 let task = fleet_task(
2817 "luna-build",
2818 Some(worker_profile(
2819 Some("builder-luna"),
2820 None,
2821 None,
2822 None,
2823 None,
2824 vec![],
2825 )),
2826 );
2827
2828 let err = validate_fleet_task_routes(&[task], &[profile], None, None)
2829 .expect_err("provider-less unresolvable pin must be rejected");
2830 let msg = err.to_string();
2831 assert!(msg.contains("gpt-5.6-luna"), "error names the model: {msg}");
2832 assert!(
2833 msg.contains("provider") || msg.contains("inherit"),
2834 "error tells the user how to fix it: {msg}"
2835 );
2836 }
2837
2838 #[test]
2839 fn configless_fleet_rejects_providerless_deepseek_named_work() {
2840 let mut profile = agent_profile(
2841 "unscoped",
2842 "builder",
2843 None,
2844 codewhale_config::FleetLoadout::Inherit,
2845 );
2846 profile.profile.model = Some("deepseek-v4-flash".to_string());
2847 let task = fleet_task(
2848 "unscoped-build",
2849 Some(worker_profile(
2850 Some("unscoped"),
2851 None,
2852 None,
2853 None,
2854 None,
2855 vec![],
2856 )),
2857 );
2858
2859 let error = validate_fleet_task_routes(
2860 std::slice::from_ref(&task),
2861 std::slice::from_ref(&profile),
2862 Some("deepseek-v4-flash"),
2863 None,
2864 )
2865 .expect_err("model spelling must not select a provider");
2866 let message = error.to_string();
2867 assert!(message.contains("no provider authority"), "{message}");
2868 assert!(
2869 message.contains("set the agent profile provider"),
2870 "{message}"
2871 );
2872 assert!(resolve_fleet_route(&task, &[profile], Some("deepseek-v4-flash")).is_none());
2873 }
2874
2875 #[test]
2876 fn configless_fleet_keeps_explicit_non_deepseek_provider_authority() {
2877 let mut profile = agent_profile(
2878 "grok-builder",
2879 "builder",
2880 None,
2881 codewhale_config::FleetLoadout::Inherit,
2882 );
2883 profile.profile.provider = Some("xai".to_string());
2884 let task = fleet_task(
2885 "grok-build",
2886 Some(worker_profile(
2887 Some("grok-builder"),
2888 None,
2889 None,
2890 None,
2891 None,
2892 vec![],
2893 )),
2894 );
2895
2896 validate_fleet_task_routes(
2897 std::slice::from_ref(&task),
2898 std::slice::from_ref(&profile),
2899 None,
2900 None,
2901 )
2902 .expect("an explicit built-in provider is route authority");
2903 let route = resolve_fleet_route(&task, &[profile], None)
2904 .expect("explicit xAI route should resolve without borrowing session config");
2905 assert_eq!(route.provider_id, "xai");
2906 assert_eq!(route.provider_kind, "xai");
2907 assert_eq!(route.wire_model_id, "grok-4.6");
2908 assert!(
2909 !serde_json::to_string(&route)
2910 .expect("route json")
2911 .to_ascii_lowercase()
2912 .contains("credential")
2913 );
2914 }
2915
2916 #[test]
2917 fn configless_fleet_rejects_explicit_custom_provider_without_live_route_config() {
2918 let mut profile = agent_profile(
2919 "private-gateway-builder",
2920 "builder",
2921 None,
2922 codewhale_config::FleetLoadout::Inherit,
2923 );
2924 profile.profile.provider = Some("private-gateway".to_string());
2925 let task = fleet_task(
2926 "private-build",
2927 Some(worker_profile(
2928 Some("private-gateway-builder"),
2929 None,
2930 None,
2931 None,
2932 None,
2933 vec![],
2934 )),
2935 );
2936
2937 let error = validate_fleet_task_routes(
2938 std::slice::from_ref(&task),
2939 std::slice::from_ref(&profile),
2940 None,
2941 None,
2942 )
2943 .expect_err("a custom provider name is not endpoint or model authority");
2944 let message = error.to_string();
2945 assert!(message.contains("provider=`private-gateway`"), "{message}");
2946 assert!(
2947 message.contains("attach the live route config"),
2948 "{message}"
2949 );
2950 assert!(resolve_fleet_route(&task, &[profile], None).is_none());
2951 }
2952
2953 #[test]
2954 fn validate_fleet_task_routes_rejects_known_foreign_providerless_pin() {
2955 let mut providers = crate::config::ProvidersConfig::default();
2956 providers.moonshot.api_key = Some("test-key".to_string());
2957 let config = Config {
2958 provider: Some("moonshot".to_string()),
2959 providers: Some(providers),
2960 ..Config::default()
2961 };
2962 let mut profile = agent_profile(
2963 "moonshot-builder",
2964 "builder",
2965 None,
2966 codewhale_config::FleetLoadout::Inherit,
2967 );
2968 profile.profile.model = Some("deepseek-v4-pro".to_string());
2969 let task = fleet_task(
2970 "foreign-model",
2971 Some(worker_profile(
2972 Some("moonshot-builder"),
2973 None,
2974 None,
2975 None,
2976 None,
2977 vec![],
2978 )),
2979 );
2980
2981 let err = validate_fleet_task_routes(
2982 std::slice::from_ref(&task),
2983 std::slice::from_ref(&profile),
2984 None,
2985 Some(&config),
2986 )
2987 .expect_err("known foreign model must fail before Fleet dispatch");
2988 let msg = err.to_string();
2989 assert!(msg.contains("deepseek-v4-pro"), "names model: {msg}");
2990 assert!(msg.contains("moonshot"), "names resolved route: {msg}");
2991 assert!(msg.contains("deepseek"), "names catalog owner: {msg}");
2992
2993 profile.profile.provider = Some("moonshot".to_string());
2994 validate_fleet_task_routes(&[task], &[profile], None, Some(&config))
2995 .expect("an explicit provider+model pair remains deliberate route intent");
2996 }
2997
2998 #[test]
2999 fn validate_fleet_task_routes_accepts_resolvable_pin_and_inherit() {
3000 // A real default-provider model resolves fine without an explicit pin.
3001 let mut good = agent_profile(
3002 "builder-ds",
3003 "builder",
3004 None,
3005 codewhale_config::FleetLoadout::Inherit,
3006 );
3007 good.profile.model = Some("deepseek-v4-flash".to_string());
3008 good.profile.provider = Some("deepseek".to_string());
3009 let good_task = fleet_task(
3010 "ds-build",
3011 Some(worker_profile(
3012 Some("builder-ds"),
3013 None,
3014 None,
3015 None,
3016 None,
3017 vec![],
3018 )),
3019 );
3020 validate_fleet_task_routes(&[good_task], &[good], None, None)
3021 .expect("resolvable default-provider model must pass");
3022
3023 // Inherit (no model pin) is never rejected.
3024 let inherit = agent_profile(
3025 "inherit-role",
3026 "builder",
3027 None,
3028 codewhale_config::FleetLoadout::Inherit,
3029 );
3030 let inherit_task = fleet_task(
3031 "inherit-build",
3032 Some(worker_profile(
3033 Some("inherit-role"),
3034 None,
3035 None,
3036 None,
3037 None,
3038 vec![],
3039 )),
3040 );
3041 validate_fleet_task_routes(
3042 &[inherit_task],
3043 &[inherit],
3044 None,
3045 Some(&explicit_deepseek_config()),
3046 )
3047 .expect("inherit (no model pin) must pass");
3048 }
3049
3050 #[test]
3051 fn validate_fleet_task_routes_rejects_unsupported_thinking_tier() {
3052 let mut profile = agent_profile(
3053 "preview-builder",
3054 "builder",
3055 None,
3056 codewhale_config::FleetLoadout::Inherit,
3057 );
3058 profile.profile.model = Some("trinity-large-preview".to_string());
3059 profile.profile.provider = Some("arcee".to_string());
3060 profile.profile.reasoning_effort = Some("high".to_string());
3061 let task = fleet_task(
3062 "preview-build",
3063 Some(worker_profile(
3064 Some("preview-builder"),
3065 None,
3066 None,
3067 None,
3068 None,
3069 vec![],
3070 )),
3071 );
3072
3073 let err = validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
3074 .expect_err("unsupported thinking tier must fail before leasing");
3075 let message = err.to_string();
3076 assert!(message.contains("does not support thinking"), "{message}");
3077 assert!(message.contains("trinity-large-preview"), "{message}");
3078 }
3079
3080 #[test]
3081 fn validate_fleet_task_routes_accepts_thinking_capable_route() {
3082 let mut profile = agent_profile(
3083 "deep-builder",
3084 "builder",
3085 None,
3086 codewhale_config::FleetLoadout::Inherit,
3087 );
3088 profile.profile.model = Some("deepseek-v4-flash".to_string());
3089 profile.profile.provider = Some("deepseek".to_string());
3090 profile.profile.reasoning_effort = Some("high".to_string());
3091 let task = fleet_task(
3092 "deep-build",
3093 Some(worker_profile(
3094 Some("deep-builder"),
3095 None,
3096 None,
3097 None,
3098 None,
3099 vec![],
3100 )),
3101 );
3102
3103 validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
3104 .expect("thinking-capable route must pass");
3105 }
3106
3107 #[test]
3108 fn validate_fleet_task_routes_keeps_non_explicit_thinking_modes_route_agnostic() {
3109 for effort in ["inherit", "auto", "off"] {
3110 let mut profile = agent_profile(
3111 "preview-builder",
3112 "builder",
3113 None,
3114 codewhale_config::FleetLoadout::Inherit,
3115 );
3116 profile.profile.model = Some("trinity-large-preview".to_string());
3117 profile.profile.provider = Some("arcee".to_string());
3118 profile.profile.reasoning_effort = Some(effort.to_string());
3119 let task = fleet_task(
3120 "preview-build",
3121 Some(worker_profile(
3122 Some("preview-builder"),
3123 None,
3124 None,
3125 None,
3126 None,
3127 vec![],
3128 )),
3129 );
3130
3131 validate_fleet_task_routes(&[task], &[profile], Some("deepseek-v4-flash"), None)
3132 .unwrap_or_else(|error| panic!("{effort} must remain valid: {error}"));
3133 }
3134 }
3135
3136 #[test]
3137 fn resolve_fleet_route_honors_explicit_profile_provider_not_the_default() {
3138 // EPIC #2608 / #4093: the resolved provider must come ONLY from the
3139 // profile's explicit `provider` field — never inferred from a
3140 // provider-shaped substring in `model`, and never the parent/session
3141 // route's provider. `deepseek-v4-flash` is deliberately DeepSeek-shaped
3142 // while the profile pins `openrouter`.
3143 let mut profile = agent_profile(
3144 "cross-provider",
3145 "scout",
3146 None,
3147 codewhale_config::FleetLoadout::Inherit,
3148 );
3149 profile.profile.model = Some("deepseek-v4-flash".to_string());
3150 profile.profile.provider = Some("openrouter".to_string());
3151 profile.profile.reasoning_effort = Some("max".to_string());
3152 let task = fleet_task(
3153 "route-cross-provider",
3154 Some(worker_profile(
3155 Some("cross-provider"),
3156 None,
3157 None,
3158 None,
3159 None,
3160 vec![],
3161 )),
3162 );
3163
3164 // The "parent"/session route is a completely different provider's
3165 // model, proving the resolved route does not fall back to it.
3166 let route = resolve_fleet_route(&task, &[profile], Some("deepseek-v4-pro"))
3167 .expect("cross-provider profile route should resolve");
3168
3169 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
3170
3171 // Resolving `openrouter` directly with the same selector is the
3172 // ground truth for what this route SHOULD produce — comparing
3173 // against it (rather than hardcoding a wire id) proves the profile's
3174 // provider actually drove resolution, whatever wire id/aggregator
3175 // mapping the resolver's catalog assigns.
3176 let openrouter_candidate = resolve_route_candidate(
3177 ProviderKind::Openrouter,
3178 Some("deepseek-v4-flash"),
3179 None,
3180 None,
3181 None,
3182 None,
3183 )
3184 .expect("openrouter should resolve the pinned model directly");
3185 assert_eq!(
3186 route.wire_model_id,
3187 openrouter_candidate.wire_model_id().as_str()
3188 );
3189 assert_eq!(
3190 route.provider_id,
3191 openrouter_candidate.provider_id().as_str()
3192 );
3193 assert_eq!(
3194 route.provider_kind,
3195 openrouter_candidate.provider_kind().as_str()
3196 );
3197 assert_eq!(route.reasoning_effort.as_deref(), Some("max"));
3198 // Differs from DeepSeek — the pre-#4093 hardcoded default AND the
3199 // parent/session's provider.
3200 assert_ne!(route.provider_id, "deepseek");
3201 }
3202
3203 #[test]
3204 fn cross_provider_profile_saves_reloads_and_resolves_to_its_own_provider() {
3205 // Required cross-provider save/load/launch coverage for #4093: create
3206 // a Fleet profile whose provider differs from the parent/session
3207 // provider, save it to a real TOML file, reload it from disk through
3208 // the same loader Fleet uses, then resolve its route and confirm the
3209 // resolved provider+model are the SAVED ones — never the parent's.
3210 let draft = crate::fleet::profile::FleetProfileDraft {
3211 id: "scout-openrouter".to_string(),
3212 display_name: Some("Scout".to_string()),
3213 description: Some("Cross-provider scout profile.".to_string()),
3214 role_hint: "scout".to_string(),
3215 model_class_hint: None,
3216 model: Some("deepseek-v4-flash".to_string()),
3217 provider: Some("openrouter".to_string()),
3218 reasoning_effort: Some("max".to_string()),
3219 instructions: None,
3220 };
3221
3222 let dir = tempfile::TempDir::new().unwrap();
3223 std::fs::write(dir.path().join(draft.file_name()), draft.render_toml()).unwrap();
3224 let profiles = crate::fleet::profile::load_agent_profiles_from_dir(dir.path())
3225 .expect("rendered profile TOML loads");
3226 assert_eq!(profiles.len(), 1);
3227 assert_eq!(profiles[0].profile.provider.as_deref(), Some("openrouter"));
3228 assert_eq!(profiles[0].profile.reasoning_effort.as_deref(), Some("max"));
3229 assert_eq!(
3230 profiles[0].profile.model.as_deref(),
3231 Some("deepseek-v4-flash")
3232 );
3233
3234 let task = fleet_task(
3235 "route-saved-profile",
3236 Some(worker_profile(
3237 Some("scout-openrouter"),
3238 None,
3239 None,
3240 None,
3241 None,
3242 vec![],
3243 )),
3244 );
3245
3246 // "Parent"/session route: a different provider's model entirely, so a
3247 // fallback to it would be an obvious, loud test failure.
3248 let route = resolve_fleet_route(&task, &profiles, Some("deepseek-v4-pro"))
3249 .expect("saved cross-provider profile route should resolve");
3250
3251 let openrouter_candidate = resolve_route_candidate(
3252 ProviderKind::Openrouter,
3253 Some("deepseek-v4-flash"),
3254 None,
3255 None,
3256 None,
3257 None,
3258 )
3259 .expect("openrouter should resolve the saved model directly");
3260 assert_eq!(
3261 route.wire_model_id,
3262 openrouter_candidate.wire_model_id().as_str()
3263 );
3264 assert_eq!(
3265 route.provider_id,
3266 openrouter_candidate.provider_id().as_str()
3267 );
3268 assert_eq!(route.reasoning_effort.as_deref(), Some("max"));
3269 assert_ne!(route.provider_id, "deepseek");
3270 }
3271
3272 #[test]
3273 fn resolve_fleet_route_preserves_exact_named_custom_provider_without_secrets() {
3274 let mut profile = agent_profile(
3275 "local",
3276 "scout",
3277 None,
3278 codewhale_config::FleetLoadout::Inherit,
3279 );
3280 profile.profile.model = Some("qwen-2.5-7b".to_string());
3281 profile.profile.provider = Some("lm-studio".to_string());
3282 let task = fleet_task(
3283 "custom-receipt",
3284 Some(worker_profile(
3285 Some("local"),
3286 None,
3287 None,
3288 None,
3289 None,
3290 vec![],
3291 )),
3292 );
3293
3294 assert!(
3295 resolve_fleet_route(&task, &[profile.clone()], Some("deepseek-v4-pro")).is_none(),
3296 "a profile string alone is not proof that a named custom route exists"
3297 );
3298 let config = Config {
3299 provider: Some("lm-studio".to_string()),
3300 providers: Some(crate::config::ProvidersConfig {
3301 custom: std::collections::HashMap::from([(
3302 "lm-studio".to_string(),
3303 crate::config::ProviderConfig {
3304 kind: Some("openai-compatible".to_string()),
3305 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
3306 model: Some("qwen-2.5-7b".to_string()),
3307 api_key: Some("receipt-must-redact-this".to_string()),
3308 ..Default::default()
3309 },
3310 )]),
3311 ..Default::default()
3312 }),
3313 ..Default::default()
3314 };
3315 let route = resolve_fleet_route_with_config(
3316 &task,
3317 &[profile],
3318 Some("deepseek-v4-pro"),
3319 Some(&config),
3320 )
3321 .expect("live config should prove the named custom route");
3322
3323 assert_eq!(route.provider_id, "lm-studio");
3324 assert_eq!(route.provider_exact_id.as_deref(), Some("lm-studio"));
3325 assert_eq!(route.provider_kind, "custom");
3326 assert_eq!(route.wire_model_id, "qwen-2.5-7b");
3327 assert_eq!(route.protocol, "chat_completions");
3328 assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
3329 assert_eq!(route.source, "runtime_route");
3330
3331 // The exact identity and wire model are durable, while endpoint/auth
3332 // config remains outside the receipt. The generic Custom descriptor's
3333 // placeholder endpoint is never serialized either.
3334 let json = serde_json::to_string(&route).unwrap();
3335 let haystack = json.to_ascii_lowercase();
3336 assert!(haystack.contains("lm-studio"));
3337 assert!(!haystack.contains("base_url"));
3338 assert!(!haystack.contains("http://"));
3339 assert!(!haystack.contains("https://"));
3340 for needle in [
3341 "api_key",
3342 "apikey",
3343 "api-key",
3344 "authorization",
3345 "bearer ",
3346 "auth_token",
3347 "auth-token",
3348 "password",
3349 "credential",
3350 "sk-ant-",
3351 "sk-proj-",
3352 "sk-or-",
3353 "secret",
3354 "receipt-must-redact-this",
3355 ] {
3356 assert!(
3357 !haystack.contains(needle),
3358 "named-custom route JSON must not contain secret marker {needle:?}: {json}"
3359 );
3360 }
3361 }
3362
3363 #[test]
3364 fn fleet_receipt_prefers_live_case_colliding_custom_identity() {
3365 let mut profile = agent_profile(
3366 "case-local",
3367 "scout",
3368 None,
3369 codewhale_config::FleetLoadout::Inherit,
3370 );
3371 profile.profile.model = Some("case-model".to_string());
3372 profile.profile.provider = Some("CUSTOM".to_string());
3373 let task = fleet_task(
3374 "case-custom-receipt",
3375 Some(worker_profile(
3376 Some("case-local"),
3377 None,
3378 None,
3379 None,
3380 None,
3381 vec![],
3382 )),
3383 );
3384 let config = Config {
3385 provider: Some("CUSTOM".to_string()),
3386 providers: Some(crate::config::ProvidersConfig {
3387 custom: std::collections::HashMap::from([(
3388 "CUSTOM".to_string(),
3389 crate::config::ProviderConfig {
3390 kind: Some("openai-compatible".to_string()),
3391 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
3392 model: Some("case-model".to_string()),
3393 ..Default::default()
3394 },
3395 )]),
3396 ..Default::default()
3397 }),
3398 ..Default::default()
3399 };
3400
3401 let route = resolve_fleet_route_with_config(
3402 &task,
3403 &[profile],
3404 Some("deepseek-v4-pro"),
3405 Some(&config),
3406 )
3407 .expect("live config route proof");
3408 assert_eq!(route.provider_id, "CUSTOM");
3409 assert_eq!(route.provider_exact_id.as_deref(), Some("CUSTOM"));
3410 assert_eq!(route.provider_kind, "custom");
3411 assert_eq!(route.source, "runtime_route");
3412 }
3413
3414 #[test]
3415 fn worker_report_route_preserves_literal_custom_vs_idless_root_without_local_resolution() {
3416 let task = fleet_task("reported-custom", None);
3417 let literal = resolve_fleet_route_from_worker_report(
3418 &task,
3419 &[],
3420 Some("manager-model-y"),
3421 "custom",
3422 Some("custom"),
3423 "worker-model-x",
3424 )
3425 .expect("literal custom worker report");
3426 let root = resolve_fleet_route_from_worker_report(
3427 &task,
3428 &[],
3429 Some("manager-model-y"),
3430 "custom",
3431 None,
3432 "worker-model-root",
3433 )
3434 .expect("idless root custom worker report");
3435
3436 assert_eq!(literal.provider_id, "custom");
3437 assert_eq!(literal.provider_exact_id.as_deref(), Some("custom"));
3438 assert_eq!(literal.wire_model_id, "worker-model-x");
3439 assert_eq!(root.provider_id, "custom");
3440 assert_eq!(root.provider_exact_id, None);
3441 assert_eq!(root.wire_model_id, "worker-model-root");
3442 assert_eq!(literal.source, "worker_terminal_metadata");
3443 assert_eq!(root.source, "worker_terminal_metadata");
3444
3445 let literal_json = serde_json::to_value(&literal).unwrap();
3446 let root_json = serde_json::to_value(&root).unwrap();
3447 assert_eq!(literal_json["provider_exact_id"], "custom");
3448 assert!(root_json.get("provider_exact_id").is_none());
3449 assert_ne!(literal, root);
3450 }
3451
3452 #[test]
3453 fn worker_report_builtin_route_does_not_become_custom_exact_route() {
3454 let task = fleet_task("reported-built-in", None);
3455 let route = resolve_fleet_route_from_worker_report(
3456 &task,
3457 &[],
3458 None,
3459 "deepseek",
3460 None,
3461 "deepseek-v4-pro",
3462 )
3463 .expect("built-in worker report");
3464
3465 assert_eq!(route.provider_id, "deepseek");
3466 assert_eq!(route.provider_exact_id, None);
3467 assert_eq!(route.provider_kind, "deepseek");
3468
3469 assert!(
3470 resolve_fleet_route_from_worker_report(
3471 &task,
3472 &[],
3473 None,
3474 "deepseek",
3475 Some("custom-x"),
3476 "deepseek-v4-pro",
3477 )
3478 .is_none(),
3479 "built-in kind plus custom exact id is contradictory provenance"
3480 );
3481 assert!(
3482 resolve_fleet_route_from_worker_report(
3483 &task,
3484 &[],
3485 None,
3486 "custom",
3487 Some(" "),
3488 "root-model",
3489 )
3490 .is_none(),
3491 "present-empty exact id must not collapse to idless custom root"
3492 );
3493 }
3494
3495 #[test]
3496 fn fleet_worker_launch_route_is_explicit_provider_only() {
3497 // The LAUNCH resolver (twin of the receipt) must emit a provider ONLY
3498 // when the profile explicitly pins one, and NEVER infer it from a
3499 // provider-shaped model id (EPIC #2608).
3500
3501 // 1) Explicit cross-provider pin: model + provider both come from the
3502 // profile, not the parent/session model.
3503 let mut pinned = agent_profile(
3504 "cross",
3505 "scout",
3506 None,
3507 codewhale_config::FleetLoadout::Inherit,
3508 );
3509 pinned.profile.model = Some("glm-5.2".to_string());
3510 pinned.profile.provider = Some("openrouter".to_string());
3511 pinned.profile.reasoning_effort = Some("high".to_string());
3512 let pinned_task = fleet_task(
3513 "launch-pinned",
3514 Some(worker_profile(
3515 Some("cross"),
3516 None,
3517 None,
3518 None,
3519 None,
3520 vec![],
3521 )),
3522 );
3523 let pinned_profiles = vec![pinned];
3524 let (model, provider) =
3525 fleet_worker_launch_route(&pinned_task, &pinned_profiles, "deepseek-v4-pro");
3526 assert_eq!(model, "glm-5.2");
3527 assert_eq!(provider.as_deref(), Some("openrouter"));
3528 assert_eq!(
3529 fleet_worker_launch_reasoning_effort(&pinned_task, &pinned_profiles).as_deref(),
3530 Some("high")
3531 );
3532
3533 // 1b) User-named OpenAI-compatible providers are launchable too: keep
3534 // the exact provider id so `codewhale exec --provider lm-studio`
3535 // can resolve `[providers.lm-studio]` from config (#3965).
3536 let mut custom = agent_profile(
3537 "local",
3538 "scout",
3539 None,
3540 codewhale_config::FleetLoadout::Inherit,
3541 );
3542 custom.profile.model = Some("qwen-2.5-7b".to_string());
3543 custom.profile.provider = Some("lm-studio".to_string());
3544 let custom_task = fleet_task(
3545 "launch-custom",
3546 Some(worker_profile(
3547 Some("local"),
3548 None,
3549 None,
3550 None,
3551 None,
3552 vec![],
3553 )),
3554 );
3555 let custom_profiles = vec![custom];
3556 let (model, provider) =
3557 fleet_worker_launch_route(&custom_task, &custom_profiles, "deepseek-v4-pro");
3558 assert_eq!(model, "qwen-2.5-7b");
3559 assert_eq!(provider.as_deref(), Some("lm-studio"));
3560
3561 // 2) A DeepSeek-shaped model with NO explicit provider must NOT infer a
3562 // provider — provider stays None so the worker keeps its own session
3563 // default, and no `--provider` is emitted.
3564 let mut model_only = agent_profile(
3565 "modelonly",
3566 "scout",
3567 None,
3568 codewhale_config::FleetLoadout::Inherit,
3569 );
3570 model_only.profile.model = Some("deepseek-v4-flash".to_string());
3571 let model_only_task = fleet_task(
3572 "launch-model-only",
3573 Some(worker_profile(
3574 Some("modelonly"),
3575 None,
3576 None,
3577 None,
3578 None,
3579 vec![],
3580 )),
3581 );
3582 let model_only_profiles = vec![model_only];
3583 let (model, provider) =
3584 fleet_worker_launch_route(&model_only_task, &model_only_profiles, "deepseek-v4-pro");
3585 assert_eq!(model, "deepseek-v4-flash");
3586 assert_eq!(provider, None);
3587 assert_eq!(
3588 fleet_worker_launch_reasoning_effort(&model_only_task, &model_only_profiles),
3589 None
3590 );
3591
3592 // 3) No profile at all: run-level model, no provider (unchanged).
3593 let bare = fleet_task("launch-bare", None);
3594 let (model, provider) = fleet_worker_launch_route(&bare, &[], "deepseek-v4-pro");
3595 assert_eq!(model, "deepseek-v4-pro");
3596 assert_eq!(provider, None);
3597 }
3598
3599 #[test]
3600 fn fleet_worker_spec_rejects_unknown_agent_profile_before_spawn() {
3601 let task = fleet_task(
3602 "review",
3603 Some(worker_profile(
3604 Some("missing"),
3605 None,
3606 None,
3607 None,
3608 None,
3609 vec![],
3610 )),
3611 );
3612
3613 let err = validate_task_agent_profiles(&[task], &[])
3614 .expect_err("unknown agent profile must fail validation");
3615
3616 assert!(
3617 err.to_string()
3618 .contains("references unknown agent profile selector \"missing\"")
3619 );
3620 }
3621
3622 #[test]
3623 fn fleet_task_member_selector_accepts_display_model_and_keeps_member_posture() {
3624 let mut profile = agent_profile(
3625 "flash-scout",
3626 "scout",
3627 Some("Inspect the requested surface."),
3628 codewhale_config::FleetLoadout::Fast,
3629 );
3630 profile.display_name = Some("Scout One".to_string());
3631 profile.profile.provider = Some("deepseek".to_string());
3632 profile.profile.model = Some("deepseek-v4-flash".to_string());
3633 let worker = FleetWorkerSpec {
3634 id: "worker-1".to_string(),
3635 name: "Worker".to_string(),
3636 host: FleetHostSpec::Local,
3637 trust_level: None,
3638 labels: Default::default(),
3639 capabilities: vec![],
3640 max_concurrent_tasks: None,
3641 };
3642
3643 for task in [
3644 fleet_task(
3645 "profile-selector",
3646 Some(worker_profile(
3647 Some("DeepSeek V4 Flash"),
3648 None,
3649 None,
3650 None,
3651 None,
3652 vec![],
3653 )),
3654 ),
3655 fleet_task(
3656 "legacy-role-selector",
3657 Some(worker_profile(
3658 None,
3659 Some("DeepSeek V4 Flash"),
3660 None,
3661 None,
3662 None,
3663 vec![],
3664 )),
3665 ),
3666 ] {
3667 let spec = fleet_task_to_worker_spec_with_profiles(
3668 "worker-1",
3669 "run-1",
3670 &task,
3671 &worker,
3672 "auto",
3673 Path::new("/tmp"),
3674 Path::new("/tmp"),
3675 &[profile.clone()],
3676 None,
3677 )
3678 .expect("display-model selector should resolve");
3679 assert_eq!(spec.model, "deepseek-v4-flash");
3680 assert_eq!(spec.role.as_deref(), Some("explore"));
3681 assert_eq!(spec.agent_type, FleetRole::Scout);
3682 assert!(spec.objective.contains("Fleet profile: flash-scout"));
3683 }
3684 }
3685
3686 #[test]
3687 fn fleet_task_member_selector_reports_ambiguity() {
3688 let mut first = agent_profile(
3689 "scout-a",
3690 "scout",
3691 None,
3692 codewhale_config::FleetLoadout::Fast,
3693 );
3694 first.profile.provider = Some("deepseek".to_string());
3695 first.profile.model = Some("deepseek-v4-flash".to_string());
3696 let mut second = agent_profile(
3697 "scout-b",
3698 "reviewer",
3699 None,
3700 codewhale_config::FleetLoadout::Fast,
3701 );
3702 second.profile.provider = Some("deepseek".to_string());
3703 second.profile.model = Some("deepseek-v4-flash".to_string());
3704 let task = fleet_task(
3705 "ambiguous",
3706 Some(worker_profile(
3707 Some("DeepSeek V4 Flash"),
3708 None,
3709 None,
3710 None,
3711 None,
3712 vec![],
3713 )),
3714 );
3715
3716 let error = validate_task_agent_profiles(&[task], &[first, second])
3717 .expect_err("ambiguous selector must fail before lease");
3718 assert!(error.to_string().contains("is ambiguous"), "{error:#}");
3719 assert!(error.to_string().contains("scout-a"), "{error:#}");
3720 assert!(error.to_string().contains("scout-b"), "{error:#}");
3721 }
3722
3723 #[test]
3724 fn unmatched_legacy_worker_role_remains_a_runtime_posture() {
3725 let task = fleet_task(
3726 "legacy-role",
3727 Some(worker_profile(
3728 None,
3729 Some("reviewer"),
3730 None,
3731 None,
3732 None,
3733 vec![],
3734 )),
3735 );
3736 assert!(resolve_task_agent_profile(&task, &[]).unwrap().is_none());
3737 assert_eq!(
3738 effective_fleet_role(task.worker.as_ref(), None).as_deref(),
3739 Some("reviewer")
3740 );
3741 assert_eq!(
3742 runtime_role_for_member(
3743 effective_fleet_role(task.worker.as_ref(), None)
3744 .as_deref()
3745 .unwrap_or_default()
3746 ),
3747 FleetRole::Reviewer
3748 );
3749 }
3750
3751 #[test]
3752 fn ambiguous_legacy_role_never_falls_back_to_anonymous_posture() {
3753 let first = agent_profile(
3754 "scout-a",
3755 "scout",
3756 None,
3757 codewhale_config::FleetLoadout::Fast,
3758 );
3759 let second = agent_profile(
3760 "scout-b",
3761 "scout",
3762 None,
3763 codewhale_config::FleetLoadout::Fast,
3764 );
3765 let mut task = fleet_task(
3766 "ambiguous-role",
3767 Some(worker_profile(
3768 None,
3769 Some("scout"),
3770 None,
3771 None,
3772 None,
3773 vec![],
3774 )),
3775 );
3776
3777 let error =
3778 freeze_fleet_task_members(std::slice::from_mut(&mut task), &[first, second], true)
3779 .expect_err("an ambiguous role must fail before persistence");
3780 assert!(error.to_string().contains("is ambiguous"), "{error:#}");
3781 assert!(error.to_string().contains("scout-a"), "{error:#}");
3782 assert!(error.to_string().contains("scout-b"), "{error:#}");
3783 }
3784
3785 #[test]
3786 fn exact_roster_requires_every_task_to_name_one_member() {
3787 let profile = agent_profile(
3788 "flash-scout",
3789 "scout",
3790 None,
3791 codewhale_config::FleetLoadout::Fast,
3792 );
3793 let mut missing = fleet_task(
3794 "missing-member",
3795 Some(worker_profile(
3796 None,
3797 Some("reviewer"),
3798 None,
3799 None,
3800 None,
3801 vec![],
3802 )),
3803 );
3804 let error = freeze_fleet_task_members(
3805 std::slice::from_mut(&mut missing),
3806 std::slice::from_ref(&profile),
3807 true,
3808 )
3809 .expect_err("an exact Fleet cannot silently fall back to a posture");
3810 assert!(
3811 error.to_string().contains("does not name a member"),
3812 "{error:#}"
3813 );
3814
3815 let mut unspecified = fleet_task("unspecified-member", None);
3816 let error =
3817 freeze_fleet_task_members(std::slice::from_mut(&mut unspecified), &[profile], true)
3818 .expect_err("an exact Fleet task must name a member");
3819 assert!(
3820 error.to_string().contains("must name one member"),
3821 "{error:#}"
3822 );
3823 }
3824
3825 #[test]
3826 fn frozen_member_selection_survives_roster_edits_and_task_round_trip() {
3827 let mut original = agent_profile(
3828 "flash-scout",
3829 "scout",
3830 Some("Inspect the selected surface."),
3831 codewhale_config::FleetLoadout::Fast,
3832 );
3833 original.display_name = Some("Scout One".to_string());
3834 original.profile.provider = Some("deepseek".to_string());
3835 original.profile.model = Some("deepseek-v4-flash".to_string());
3836 original.profile.reasoning_effort = Some("medium".to_string());
3837 original.profile.delegation.max_spawn_depth = Some(2);
3838
3839 let mut task = fleet_task(
3840 "durable-selection",
3841 Some(worker_profile(
3842 Some("DeepSeek V4 Flash"),
3843 None,
3844 None,
3845 None,
3846 None,
3847 vec![],
3848 )),
3849 );
3850 freeze_fleet_task_members(std::slice::from_mut(&mut task), &[original], true)
3851 .expect("selection should freeze");
3852 assert_eq!(
3853 task.worker.as_ref().unwrap().agent_profile.as_deref(),
3854 Some("member:flash-scout")
3855 );
3856
3857 // Exercise the actual durable representation, then present a live
3858 // roster whose same id now points somewhere else. Launch must keep the
3859 // run-creation snapshot rather than re-resolving the edited profile.
3860 let task: FleetTaskSpec =
3861 serde_json::from_value(serde_json::to_value(task).unwrap()).unwrap();
3862 let mut edited = agent_profile(
3863 "flash-scout",
3864 "reviewer",
3865 Some("This edit happened after queueing."),
3866 codewhale_config::FleetLoadout::Inherit,
3867 );
3868 edited.profile.provider = Some("openrouter".to_string());
3869 edited.profile.model = Some("gpt-5.6".to_string());
3870
3871 let edited_profiles = [edited];
3872 let resolved = resolve_task_agent_profile(&task, &edited_profiles)
3873 .unwrap()
3874 .expect("frozen member");
3875 assert_eq!(resolved.profile.role.name, "explore");
3876 assert_eq!(resolved.profile.provider.as_deref(), Some("deepseek"));
3877 assert_eq!(resolved.profile.model.as_deref(), Some("deepseek-v4-flash"));
3878 assert_eq!(resolved.profile.delegation.max_spawn_depth, Some(2));
3879 }
3880
3881 #[test]
3882 fn legacy_advisory_member_id_is_selected_before_role_canonicalization() {
3883 let profile = agent_profile(
3884 "advisor",
3885 "reviewer",
3886 None,
3887 codewhale_config::FleetLoadout::Inherit,
3888 );
3889 let mut task = fleet_task(
3890 "advisor-selection",
3891 Some(worker_profile(
3892 None,
3893 Some("advisor"),
3894 None,
3895 None,
3896 None,
3897 vec![],
3898 )),
3899 );
3900
3901 freeze_fleet_task_members(std::slice::from_mut(&mut task), &[profile], true)
3902 .expect("exact member id should win");
3903 let worker = task.worker.as_ref().unwrap();
3904 assert_eq!(worker.agent_profile.as_deref(), Some("member:advisor"));
3905 assert_eq!(worker.role.as_deref(), Some("reviewer"));
3906 }
3907
3908 #[test]
3909 fn selected_exact_member_route_rejects_conflicting_task_model() {
3910 let mut profile = agent_profile(
3911 "flash-scout",
3912 "scout",
3913 None,
3914 codewhale_config::FleetLoadout::Fast,
3915 );
3916 profile.profile.provider = Some("deepseek".to_string());
3917 profile.profile.model = Some("deepseek-v4-flash".to_string());
3918 let task = fleet_task(
3919 "conflict",
3920 Some(worker_profile(
3921 Some("flash-scout"),
3922 None,
3923 None,
3924 None,
3925 Some("deepseek-v4-pro"),
3926 vec![],
3927 )),
3928 );
3929
3930 let error = validate_task_agent_profiles(&[task], &[profile])
3931 .expect_err("conflicting task model must fail before lease");
3932 assert!(error.to_string().contains("pinned model"), "{error:#}");
3933 assert!(error.to_string().contains("worker.model"), "{error:#}");
3934 }
3935
3936 #[test]
3937 fn pinned_role_lookup_ignores_builtins_and_rejects_semantic_ambiguity() {
3938 let mut builtin = agent_profile("reviewer", "reviewer", None, FleetLoadout::Inherit);
3939 builtin.origin = ProfileOrigin::BuiltIn;
3940 builtin.profile.model = Some("builtin-default".into());
3941 let mut first = agent_profile("review-choice", "reviewer", None, FleetLoadout::Inherit);
3942 first.profile.provider = Some("openrouter".into());
3943 first.profile.model = Some("qwen/qwen3.7-plus".into());
3944 let selected = resolve_pinned_role_profile(&[builtin.clone(), first.clone()], "review")
3945 .unwrap()
3946 .expect("the unique saved role wins over the builtin posture");
3947 assert_eq!(selected.id, "review-choice");
3948 assert_eq!(selected.profile.provider.as_deref(), Some("openrouter"));
3949 assert_eq!(selected.profile.model.as_deref(), Some("qwen/qwen3.7-plus"));
3950
3951 let mut second = first.clone();
3952 second.id = "reviewer".into();
3953 second.profile.provider = Some("TeamA".into());
3954 second.profile.model = Some("private-reviewer".into());
3955 for profiles in [
3956 vec![builtin.clone(), first.clone(), second.clone()],
3957 vec![second, first, builtin],
3958 ] {
3959 let error = resolve_pinned_role_profile(&profiles, "reviewer")
3960 .expect_err("an exact member id must not hide a second same-role pin");
3961 assert!(error.to_string().contains("ambiguous"), "{error:#}");
3962 assert!(error.to_string().contains("review-choice"), "{error:#}");
3963 }
3964 }
3965
3966 #[test]
3967 fn pinned_model_comparison_preserves_exact_provider_identity_and_wire_namespace() {
3968 assert!(requested_model_matches_pin(
3969 "model-x",
3970 "model-x",
3971 Some("TeamA")
3972 ));
3973 assert!(requested_model_matches_pin(
3974 "TeamA/model-x",
3975 "model-x",
3976 Some("TeamA")
3977 ));
3978 assert!(!requested_model_matches_pin(
3979 "TeamA/MODEL-X",
3980 "model-x",
3981 Some("TeamA")
3982 ));
3983 assert!(!requested_model_matches_pin(
3984 "teama/model-x",
3985 "model-x",
3986 Some("TeamA")
3987 ));
3988 assert!(!requested_model_matches_pin(
3989 "TeamA/model-x",
3990 "model-x",
3991 Some("teama")
3992 ));
3993 assert!(requested_model_matches_pin(
3994 "org/model-x",
3995 "org/model-x",
3996 Some("TeamA")
3997 ));
3998 assert!(requested_model_matches_pin(
3999 "TeamA/org/model-x",
4000 "org/model-x",
4001 Some("TeamA")
4002 ));
4003 assert!(!requested_model_matches_pin(
4004 "Other/org/model-x",
4005 "org/model-x",
4006 Some("TeamA")
4007 ));
4008 assert!(!requested_model_matches_pin(
4009 "TeamA/model-x",
4010 "model-x",
4011 None
4012 ));
4013 }
4014
4015 #[test]
4016 fn saved_case_distinct_model_conflict_is_rejected_before_freeze() {
4017 for provider in [None, Some("TeamA")] {
4018 let mut profile =
4019 agent_profile("review-choice", "reviewer", None, FleetLoadout::Inherit);
4020 profile.profile.model = Some("Preview-fixture".into());
4021 profile.profile.provider = provider.map(str::to_string);
4022 for requested in ["Preview-fixture", "preview-fixture"] {
4023 let mut selectors = vec![requested.to_string()];
4024 if let Some(provider) = provider {
4025 selectors.push(format!("{provider}/{requested}"));
4026 }
4027 for selector in selectors {
4028 let mut task = fleet_task(
4029 "review",
4030 Some(worker_profile(
4031 Some("review-choice"),
4032 None,
4033 None,
4034 None,
4035 Some(&selector),
4036 vec![],
4037 )),
4038 );
4039 let result = freeze_fleet_task_members(
4040 std::slice::from_mut(&mut task),
4041 &[profile.clone()],
4042 false,
4043 );
4044 if requested == "Preview-fixture" {
4045 result.unwrap();
4046 assert!(task.metadata.contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY));
4047 } else {
4048 assert!(result.unwrap_err().to_string().contains("conflicts"));
4049 assert!(!task.metadata.contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY));
4050 }
4051 assert_eq!(profile.profile.model.as_deref(), Some("Preview-fixture"));
4052 }
4053 }
4054 }
4055 }
4056
4057 #[test]
4058 fn providerless_saved_profile_pin_refuses_conflicts_before_freeze() {
4059 let mut profile = agent_profile("review-choice", "reviewer", None, FleetLoadout::Inherit);
4060 profile.profile.model = Some("deepseek-v4-flash".into());
4061 let mut conflict = fleet_task(
4062 "review",
4063 Some(worker_profile(
4064 Some("review-choice"),
4065 None,
4066 None,
4067 None,
4068 Some("deepseek-v4-pro"),
4069 vec![],
4070 )),
4071 );
4072 let error = freeze_fleet_task_members(
4073 std::slice::from_mut(&mut conflict),
4074 &[profile.clone()],
4075 false,
4076 )
4077 .expect_err("providerless saved models are still explicit profile pins");
4078 assert!(error.to_string().contains("conflicts"), "{error:#}");
4079 assert!(
4080 !conflict
4081 .metadata
4082 .contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY)
4083 );
4084
4085 let mut agreeing = fleet_task(
4086 "review",
4087 Some(worker_profile(
4088 Some("review-choice"),
4089 None,
4090 None,
4091 None,
4092 Some("deepseek-v4-flash"),
4093 vec![],
4094 )),
4095 );
4096 freeze_fleet_task_members(
4097 std::slice::from_mut(&mut agreeing),
4098 &[profile.clone()],
4099 false,
4100 )
4101 .unwrap();
4102 assert!(
4103 agreeing
4104 .metadata
4105 .contains_key(FROZEN_FLEET_MEMBER_METADATA_KEY)
4106 );
4107 assert_eq!(
4108 fleet_worker_launch_route(&agreeing, &[profile], "deepseek-v4-pro"),
4109 ("deepseek-v4-flash".into(), None),
4110 "freezing a model-only pin never fabricates provider authority"
4111 );
4112 }
4113
4114 #[test]
4115 fn fleet_task_max_steps_zero_or_omitted_is_unbounded_and_positive_is_enforced() {
4116 let worker = FleetWorkerSpec {
4117 id: "worker-1".to_string(),
4118 name: "Worker".to_string(),
4119 host: FleetHostSpec::Local,
4120 trust_level: None,
4121 labels: Default::default(),
4122 capabilities: vec![],
4123 max_concurrent_tasks: None,
4124 };
4125 for (max_steps, expected) in [(None, 0), (Some(0), 0), (Some(7), 7)] {
4126 let mut task = fleet_task("budget", None);
4127 task.budget = Some(FleetTaskBudget {
4128 max_tokens: None,
4129 max_steps,
4130 max_tool_calls: Some(99),
4131 max_seconds: None,
4132 });
4133 let spec = fleet_task_to_worker_spec_with_profiles(
4134 "worker-1",
4135 "run-1",
4136 &task,
4137 &worker,
4138 "auto",
4139 Path::new("/tmp"),
4140 Path::new("/tmp"),
4141 &[],
4142 None,
4143 )
4144 .expect("budget should build");
4145 assert_eq!(spec.max_steps, expected, "max_steps={max_steps:?}");
4146 }
4147 }
4148
4149 #[test]
4150 fn fleet_worker_spec_keeps_profile_pins_and_unpinned_task_choices() {
4151 let mut profile = agent_profile(
4152 "reviewer",
4153 "reviewer",
4154 Some("Focus on regressions and missing tests."),
4155 codewhale_config::FleetLoadout::Inherit,
4156 );
4157 profile.profile.model = Some("glm-5.2".to_string());
4158 let worker = FleetWorkerSpec {
4159 id: "worker-1".to_string(),
4160 name: "Worker".to_string(),
4161 host: FleetHostSpec::Local,
4162 trust_level: None,
4163 labels: Default::default(),
4164 capabilities: vec![],
4165 max_concurrent_tasks: None,
4166 };
4167
4168 let profile_model_spec = fleet_task_to_worker_spec_with_profiles(
4169 "worker-1",
4170 "run-1",
4171 &fleet_task(
4172 "review",
4173 Some(worker_profile(
4174 Some("reviewer"),
4175 None,
4176 None,
4177 None,
4178 None,
4179 vec![],
4180 )),
4181 ),
4182 &worker,
4183 "auto",
4184 std::path::Path::new("/tmp"),
4185 std::path::Path::new("/tmp"),
4186 &[profile.clone()],
4187 None,
4188 )
4189 .unwrap();
4190
4191 assert_eq!(profile_model_spec.model, "glm-5.2");
4192 assert_eq!(
4193 profile_model_spec.runtime_profile.model,
4194 ModelRoute::Fixed("glm-5.2".to_string())
4195 );
4196
4197 let conflicting_task = fleet_task(
4198 "review",
4199 Some(worker_profile(
4200 Some("reviewer"),
4201 None,
4202 None,
4203 None,
4204 Some("deepseek-v4-pro"),
4205 vec![],
4206 )),
4207 );
4208 let error = fleet_task_to_worker_spec_with_profiles(
4209 "worker-2",
4210 "run-1",
4211 &conflicting_task,
4212 &worker,
4213 "auto",
4214 std::path::Path::new("/tmp"),
4215 std::path::Path::new("/tmp"),
4216 &[profile.clone()],
4217 None,
4218 )
4219 .expect_err("a task cannot replace a providerless saved profile pin");
4220 assert!(error.to_string().contains("conflicts"), "{error:#}");
4221
4222 profile.profile.model = None;
4223 let task_model_spec = fleet_task_to_worker_spec_with_profiles(
4224 "worker-2",
4225 "run-1",
4226 &fleet_task(
4227 "review",
4228 Some(worker_profile(
4229 Some("reviewer"),
4230 None,
4231 None,
4232 None,
4233 Some("deepseek-v4-pro"),
4234 vec![],
4235 )),
4236 ),
4237 &worker,
4238 "auto",
4239 std::path::Path::new("/tmp"),
4240 std::path::Path::new("/tmp"),
4241 &[profile],
4242 None,
4243 )
4244 .unwrap();
4245
4246 assert_eq!(task_model_spec.model, "deepseek-v4-pro");
4247 assert_eq!(
4248 task_model_spec.runtime_profile.model,
4249 ModelRoute::Fixed("deepseek-v4-pro".to_string())
4250 );
4251 }
4252
4253 #[test]
4254 fn fleet_worker_spec_carries_agent_profile_provider_through_runtime_contract() {
4255 let mut profile = agent_profile(
4256 "scout-openrouter",
4257 "scout",
4258 Some("Use the OpenRouter scout route."),
4259 codewhale_config::FleetLoadout::Fast,
4260 );
4261 profile.profile.model = Some("deepseek-v4-flash".to_string());
4262 profile.profile.provider = Some("openrouter".to_string());
4263 profile.profile.reasoning_effort = Some("max".to_string());
4264 let task = fleet_task(
4265 "scout",
4266 Some(worker_profile(
4267 Some("scout-openrouter"),
4268 None,
4269 None,
4270 None,
4271 None,
4272 vec![],
4273 )),
4274 );
4275 let worker = FleetWorkerSpec {
4276 id: "worker-1".to_string(),
4277 name: "Worker".to_string(),
4278 host: FleetHostSpec::Local,
4279 trust_level: None,
4280 labels: Default::default(),
4281 capabilities: vec![],
4282 max_concurrent_tasks: None,
4283 };
4284 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker);
4285 parent.provider = Some("deepseek".to_string());
4286 parent.reasoning_effort = Some("low".to_string());
4287 parent.max_spawn_depth = 3;
4288
4289 let spec = fleet_task_to_worker_spec_with_profiles(
4290 "worker-1",
4291 "run-1",
4292 &task,
4293 &worker,
4294 "deepseek-v4-pro",
4295 std::path::Path::new("/tmp"),
4296 std::path::Path::new("/tmp"),
4297 &[profile],
4298 Some(&parent),
4299 )
4300 .unwrap();
4301
4302 assert_eq!(spec.model, "deepseek-v4-flash");
4303 assert_eq!(
4304 spec.runtime_profile.model,
4305 ModelRoute::Fixed("deepseek-v4-flash".to_string())
4306 );
4307 assert_eq!(spec.runtime_profile.provider.as_deref(), Some("openrouter"));
4308 assert_eq!(
4309 spec.runtime_profile.reasoning_effort.as_deref(),
4310 Some("max")
4311 );
4312 assert_eq!(spec.runtime_profile.max_spawn_depth, 3);
4313 assert_eq!(spec.runtime_profile.spawn_depth, 1);
4314 }
4315
4316 #[test]
4317 fn fleet_worker_spec_model_route_precedence_is_profile_unpinned_task_then_session() {
4318 let worker = FleetWorkerSpec {
4319 id: "worker-1".to_string(),
4320 name: "Worker".to_string(),
4321 host: FleetHostSpec::Local,
4322 trust_level: None,
4323 labels: Default::default(),
4324 capabilities: vec![],
4325 max_concurrent_tasks: None,
4326 };
4327 let run_model = "deepseek-v4-pro";
4328
4329 let mut profile =
4330 agent_profile("scout", "scout", None, codewhale_config::FleetLoadout::Fast);
4331 profile.profile.model = Some("deepseek-v4-flash".to_string());
4332 let mut unpinned_profile = profile.clone();
4333 unpinned_profile.profile.model = None;
4334
4335 let task_model = fleet_task_to_worker_spec_with_profiles(
4336 "worker-task",
4337 "run-1",
4338 &fleet_task(
4339 "task-model",
4340 Some(worker_profile(
4341 Some("scout"),
4342 None,
4343 None,
4344 None,
4345 Some("deepseek-v4.1"),
4346 vec![],
4347 )),
4348 ),
4349 &worker,
4350 run_model,
4351 std::path::Path::new("/tmp"),
4352 std::path::Path::new("/tmp"),
4353 &[unpinned_profile],
4354 None,
4355 )
4356 .unwrap();
4357 assert_eq!(task_model.model, "deepseek-v4.1");
4358 assert_eq!(
4359 task_model.runtime_profile.model,
4360 ModelRoute::Fixed("deepseek-v4.1".to_string())
4361 );
4362
4363 let profile_model = fleet_task_to_worker_spec_with_profiles(
4364 "worker-profile",
4365 "run-1",
4366 &fleet_task(
4367 "profile-model",
4368 Some(worker_profile(
4369 Some("scout"),
4370 None,
4371 None,
4372 None,
4373 None,
4374 vec![],
4375 )),
4376 ),
4377 &worker,
4378 run_model,
4379 std::path::Path::new("/tmp"),
4380 std::path::Path::new("/tmp"),
4381 &[profile],
4382 None,
4383 )
4384 .unwrap();
4385 assert_eq!(profile_model.model, "deepseek-v4-flash");
4386 assert_eq!(
4387 profile_model.runtime_profile.model,
4388 ModelRoute::Fixed("deepseek-v4-flash".to_string())
4389 );
4390
4391 let role_default = fleet_task_to_worker_spec_with_profiles(
4392 "worker-role",
4393 "run-1",
4394 &fleet_task(
4395 "role-default",
4396 Some(worker_profile(
4397 None,
4398 Some("scout"),
4399 Some("fast"),
4400 None,
4401 None,
4402 vec![],
4403 )),
4404 ),
4405 &worker,
4406 run_model,
4407 std::path::Path::new("/tmp"),
4408 std::path::Path::new("/tmp"),
4409 &[],
4410 None,
4411 )
4412 .unwrap();
4413 assert_eq!(role_default.model, run_model);
4414 assert_eq!(role_default.runtime_profile.model, ModelRoute::Inherit);
4415
4416 let inherited = fleet_task_to_worker_spec_with_profiles(
4417 "worker-inherit",
4418 "run-1",
4419 &fleet_task("inherit", None),
4420 &worker,
4421 run_model,
4422 std::path::Path::new("/tmp"),
4423 std::path::Path::new("/tmp"),
4424 &[],
4425 None,
4426 )
4427 .unwrap();
4428 assert_eq!(inherited.model, run_model);
4429 assert_eq!(inherited.runtime_profile.model, ModelRoute::Inherit);
4430 }
4431
4432 #[test]
4433 fn fleet_worker_spec_intersects_task_tools_with_parent_runtime_profile() {
4434 let task = fleet_task(
4435 "build",
4436 Some(worker_profile(
4437 None,
4438 Some("builder"),
4439 None,
4440 Some("fast"),
4441 None,
4442 vec!["read_file", "apply_patch"],
4443 )),
4444 );
4445 let worker = FleetWorkerSpec {
4446 id: "worker-1".to_string(),
4447 name: "Worker".to_string(),
4448 host: FleetHostSpec::Local,
4449 trust_level: None,
4450 labels: Default::default(),
4451 capabilities: vec![],
4452 max_concurrent_tasks: None,
4453 };
4454 let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Scout);
4455 parent.tools = ToolScope::Explicit(vec!["read_file".to_string()]);
4456 parent.max_spawn_depth = 2;
4457
4458 let spec = fleet_task_to_worker_spec_with_profiles(
4459 "worker-1",
4460 "run-1",
4461 &task,
4462 &worker,
4463 "auto",
4464 std::path::Path::new("/tmp"),
4465 std::path::Path::new("/tmp"),
4466 &[],
4467 Some(&parent),
4468 )
4469 .unwrap();
4470
4471 assert_eq!(spec.agent_type, FleetRole::Builder);
4472 assert!(!spec.runtime_profile.permissions.write);
4473 assert!(
4474 spec.runtime_profile.permissions.network,
4475 "read-only inspection lanes keep network reach"
4476 );
4477 assert_eq!(
4478 spec.runtime_profile.shell,
4479 crate::worker_profile::ShellPolicy::Full
4480 );
4481 assert_eq!(
4482 spec.runtime_profile.tools,
4483 ToolScope::Explicit(vec!["read_file".to_string()])
4484 );
4485 assert_eq!(spec.runtime_profile.model, ModelRoute::Inherit);
4486 assert_eq!(spec.max_spawn_depth, 2);
4487 assert_eq!(spec.spawn_depth, 1);
4488
4489 let permissions = crate::fleet::role::fleet_effective_permissions(
4490 &spec.agent_type,
4491 &spec.runtime_profile,
4492 None,
4493 None,
4494 );
4495 assert!(!permissions.write);
4496 assert!(
4497 permissions.network,
4498 "read-only inspection lanes keep network reach"
4499 );
4500 assert_eq!(permissions.shell, "full");
4501 assert_eq!(permissions.tool_scope, "explicit");
4502 assert_eq!(permissions.tools, vec!["read_file".to_string()]);
4503 assert!(permissions.background);
4504 assert_eq!(permissions.max_spawn_depth, 2);
4505 assert_eq!(permissions.source, "worker_runtime_profile");
4506 }
4507
4508 #[test]
4509 fn fleet_worker_spec_defaults_to_shared_subagent_depth() {
4510 let task = FleetTaskSpec {
4511 id: "task-1".to_string(),
4512 name: "Task".to_string(),
4513 description: None,
4514 objective: None,
4515 instructions: "Do the task.".to_string(),
4516 worker: Some(FleetTaskWorkerProfile {
4517 agent_profile: None,
4518 role: Some("reviewer".to_string()),
4519 loadout: None,
4520 model_class: None,
4521 model: None,
4522 tool_profile: Some("read-only".to_string()),
4523 tools: Vec::new(),
4524 capabilities: Vec::new(),
4525 }),
4526 workspace: None,
4527 input_files: vec![],
4528 context: vec![],
4529 budget: None,
4530 tags: vec![],
4531 expected_artifacts: vec![],
4532 scorer: None,
4533 retry_policy: None,
4534 alert_policy: None,
4535 timeout_seconds: None,
4536 metadata: Default::default(),
4537 };
4538 let worker = FleetWorkerSpec {
4539 id: "worker-1".to_string(),
4540 name: "Worker".to_string(),
4541 host: FleetHostSpec::Local,
4542 trust_level: None,
4543 labels: Default::default(),
4544 capabilities: vec![],
4545 max_concurrent_tasks: None,
4546 };
4547
4548 let spec = fleet_task_to_worker_spec_with_profiles(
4549 "worker-1",
4550 "run-1",
4551 &task,
4552 &worker,
4553 "auto",
4554 std::path::Path::new("/tmp"),
4555 std::path::Path::new("/tmp"),
4556 &[],
4557 None,
4558 )
4559 .expect("worker spec with empty profiles");
4560
4561 // Root fleet worker runs at depth 0; its budget equals the shared
4562 // sub-agent default (3) so fleet and sub-agents are one substrate and
4563 // at least 3 nested delegation levels are afforded.
4564 assert_eq!(spec.spawn_depth, 0);
4565 assert_eq!(spec.max_spawn_depth, codewhale_config::DEFAULT_SPAWN_DEPTH);
4566 assert_eq!(spec.max_spawn_depth, 3);
4567
4568 // End-to-end reachability: walk the SAME gate the SubAgentRuntime
4569 // enforces (`would_exceed_depth` = `spawn_depth + 1 > max_spawn_depth`).
4570 // A depth-0 root must reach 3 nested levels, then stop. This fails if
4571 // anyone lowers the shared default below 3 (Hunter: afford >= 3).
4572 let hardened = apply_exec_hardening(spec, &codewhale_config::FleetExecConfig::default());
4573 let would_exceed = |spawn_depth: u32| spawn_depth + 1 > hardened.max_spawn_depth;
4574 assert!(
4575 !would_exceed(0),
4576 "root (depth 0) must spawn a child at depth 1"
4577 );
4578 assert!(!would_exceed(1), "depth-1 child must spawn to depth 2");
4579 assert!(!would_exceed(2), "depth-2 child must spawn to depth 3");
4580 assert!(
4581 would_exceed(3),
4582 "depth 3 is the afforded ceiling; depth 4 is blocked"
4583 );
4584 }
4585
4586 #[test]
4587 fn fleet_fanout_role_loadouts_keep_distinct_child_models() {
4588 let worker = FleetWorkerSpec {
4589 id: "local-worker".to_string(),
4590 name: "Local worker".to_string(),
4591 host: FleetHostSpec::Local,
4592 trust_level: None,
4593 labels: Default::default(),
4594 capabilities: vec![],
4595 max_concurrent_tasks: None,
4596 };
4597
4598 let cases = [
4599 (
4600 "scout",
4601 "deepseek-v4-flash",
4602 FleetRole::Scout,
4603 AgentWorkerToolProfile::Explicit(vec![
4604 "read_file".to_string(),
4605 "grep_files".to_string(),
4606 ]),
4607 ),
4608 (
4609 "builder",
4610 "deepseek-v4-pro",
4611 FleetRole::Builder,
4612 AgentWorkerToolProfile::Explicit(vec![
4613 "read_file".to_string(),
4614 "apply_patch".to_string(),
4615 ]),
4616 ),
4617 (
4618 "verifier",
4619 "deepseek-v4-pro",
4620 FleetRole::Verifier,
4621 AgentWorkerToolProfile::Explicit(vec![
4622 "exec_shell".to_string(),
4623 "read_file".to_string(),
4624 ]),
4625 ),
4626 ];
4627
4628 let parent_model = "parent-session-model";
4629 let mut child_models = std::collections::BTreeSet::new();
4630 for (role, model, expected_type, expected_tools) in cases {
4631 let task = FleetTaskSpec {
4632 id: format!("{role}-task"),
4633 name: format!("{role} task"),
4634 description: None,
4635 objective: Some(format!("{role} objective")),
4636 instructions: "Complete the assigned fanout lane.".to_string(),
4637 worker: Some(FleetTaskWorkerProfile {
4638 agent_profile: None,
4639 role: Some(role.to_string()),
4640 loadout: None,
4641 model_class: None,
4642 model: None,
4643 tool_profile: None,
4644 tools: match &expected_tools {
4645 AgentWorkerToolProfile::Explicit(tools) => tools.clone(),
4646 AgentWorkerToolProfile::Inherited => Vec::new(),
4647 },
4648 capabilities: vec![],
4649 }),
4650 workspace: matches!(&expected_type, FleetRole::Builder).then(|| {
4651 FleetWorkspaceRequirements {
4652 root: Some(PathBuf::from(".")),
4653 required_files: Vec::new(),
4654 writable_paths: vec![PathBuf::from(".")],
4655 environment: None,
4656 }
4657 }),
4658 input_files: vec![],
4659 context: vec![],
4660 budget: None,
4661 tags: vec![],
4662 expected_artifacts: vec![],
4663 scorer: None,
4664 retry_policy: None,
4665 alert_policy: None,
4666 timeout_seconds: None,
4667 metadata: Default::default(),
4668 };
4669
4670 let spec = fleet_task_to_worker_spec_with_profiles(
4671 &format!("{role}-worker"),
4672 "run-3289",
4673 &task,
4674 &worker,
4675 model,
4676 std::path::Path::new("/tmp"),
4677 std::path::Path::new("/tmp"),
4678 &[],
4679 None,
4680 )
4681 .expect("worker spec with empty profiles");
4682
4683 let public_role = crate::fleet::role::public_role_label(role);
4684 assert_eq!(spec.role.as_deref(), Some(public_role.as_str()));
4685 assert_eq!(spec.agent_type, expected_type, "role {role}");
4686 assert_eq!(spec.tool_profile, expected_tools, "role {role}");
4687 assert_eq!(spec.model, model, "role {role}");
4688 assert_ne!(
4689 spec.model, parent_model,
4690 "Fleet fanout child {role} must use its resolved loadout, not blindly inherit"
4691 );
4692 assert_eq!(
4693 spec.runtime_profile.model,
4694 ModelRoute::Inherit,
4695 "role {role}"
4696 );
4697 assert_eq!(spec.runtime_profile.role, expected_type, "role {role}");
4698 child_models.insert(spec.model.clone());
4699 }
4700 assert_eq!(
4701 child_models,
4702 std::collections::BTreeSet::from([
4703 "deepseek-v4-flash".to_string(),
4704 "deepseek-v4-pro".to_string(),
4705 ]),
4706 "Fleet fanout should preserve a mixed scout/builder/verifier loadout"
4707 );
4708 }
4709
4710 #[test]
4711 fn fleet_route_parity_uses_shared_router_candidates() {
4712 use crate::config::ProviderKind;
4713 use crate::model_routing::{RouterCandidates, provider_router_candidates};
4714
4715 // Fleet emits the SAME `ModelRoute` seam the sub-agent assignment path
4716 // consumes. `Fast` no longer re-prices the child onto a cheaper
4717 // sibling: a loadout says how much work a role should do, not which
4718 // model it is billed as, so it inherits like every other default.
4719 assert_eq!(
4720 fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
4721 ModelRoute::Inherit,
4722 );
4723 assert_eq!(
4724 fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Inherit),
4725 ModelRoute::Inherit,
4726 );
4727 assert_eq!(
4728 fleet_model_route_for_loadout(
4729 "auto",
4730 &codewhale_config::FleetLoadout::Custom("strong".to_string())
4731 ),
4732 ModelRoute::Auto,
4733 );
4734 // An explicit model always pins to a Fixed route, regardless of loadout.
4735 assert_eq!(
4736 fleet_model_route_for_loadout(
4737 "deepseek-v4-flash",
4738 &codewhale_config::FleetLoadout::Custom("strong".to_string())
4739 ),
4740 ModelRoute::Fixed("deepseek-v4-flash".to_string()),
4741 );
4742
4743 // The sub-agent runtime resolves a `ModelRoute` to a concrete model via
4744 // `provider_router_candidates` (see `worker_profile_subagent_assignment_route`):
4745 // Fixed(m) -> m
4746 // Faster | Auto -> candidates.cheap (else parent)
4747 // Inherit -> parent
4748 // A fleet worker hands its `ModelRoute` to that same resolution. A
4749 // fleet "fast" loadout no longer lands on the provider's cheap
4750 // sibling — it inherits the parent route, so the child is billed as
4751 // the model the operator actually chose. `Auto` still resolves to the
4752 // cheap sibling, which is the remaining way to opt into one.
4753 let parent = "deepseek-v4-pro";
4754 let resolve = |route: &ModelRoute, candidates: &RouterCandidates| match route {
4755 ModelRoute::Fixed(model) => model.clone(),
4756 ModelRoute::Faster | ModelRoute::Auto => candidates
4757 .cheap
4758 .clone()
4759 .unwrap_or_else(|| parent.to_string()),
4760 ModelRoute::Inherit => parent.to_string(),
4761 };
4762
4763 let deepseek = provider_router_candidates(ProviderKind::Deepseek, parent);
4764 assert_eq!(
4765 resolve(
4766 &fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
4767 &deepseek,
4768 ),
4769 parent,
4770 "fleet fast loadout resolves to the provider cheap sibling via the shared router",
4771 );
4772
4773 // A provider with no known fast sibling must keep children on the parent
4774 // model rather than fabricating a cloud id (#3166 route assertion).
4775 let no_sibling = provider_router_candidates(ProviderKind::Anthropic, parent);
4776 assert_eq!(no_sibling.cheap, None);
4777 assert_eq!(
4778 resolve(
4779 &fleet_model_route_for_loadout("auto", &codewhale_config::FleetLoadout::Fast),
4780 &no_sibling,
4781 ),
4782 parent,
4783 "fast with no provider sibling stays on the parent/default model",
4784 );
4785 }
4786
4787 #[test]
4788 fn exec_hardening_caps_max_steps_to_max_turns() {
4789 let spec = AgentWorkerSpec {
4790 worker_id: "w1".to_string(),
4791 run_id: "r1".to_string(),
4792 parent_run_id: None,
4793 workflow_run_id: None,
4794 session_name: None,
4795 objective: "test".to_string(),
4796 role: None,
4797 agent_type: FleetRole::Worker,
4798 model: "auto".to_string(),
4799 workspace: std::path::PathBuf::from("/tmp"),
4800 git_branch: None,
4801 context_mode: "fresh".to_string(),
4802 fork_context: false,
4803 tool_profile: AgentWorkerToolProfile::Inherited,
4804 runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
4805 max_steps: 1000,
4806 spawn_depth: 0,
4807 max_spawn_depth: 0,
4808 child_route: None,
4809 launch_manifest: None,
4810 };
4811 let exec = codewhale_config::FleetExecConfig {
4812 max_turns: 50,
4813 ..Default::default()
4814 };
4815 let hardened = apply_exec_hardening(spec, &exec);
4816 assert_eq!(hardened.max_steps, 50);
4817 }
4818
4819 #[test]
4820 fn exec_hardening_applies_and_clamps_spawn_depth() {
4821 let spec = AgentWorkerSpec {
4822 worker_id: "w1".to_string(),
4823 run_id: "r1".to_string(),
4824 parent_run_id: None,
4825 workflow_run_id: None,
4826 session_name: None,
4827 objective: "test".to_string(),
4828 role: None,
4829 agent_type: FleetRole::Worker,
4830 model: "auto".to_string(),
4831 workspace: std::path::PathBuf::from("/tmp"),
4832 git_branch: None,
4833 context_mode: "fresh".to_string(),
4834 fork_context: false,
4835 tool_profile: AgentWorkerToolProfile::Inherited,
4836 runtime_profile: WorkerRuntimeProfile {
4837 max_spawn_depth: codewhale_config::MAX_SPAWN_DEPTH_CEILING,
4838 ..WorkerRuntimeProfile::for_role(FleetRole::Worker)
4839 },
4840 max_steps: 1000,
4841 spawn_depth: 0,
4842 max_spawn_depth: codewhale_config::MAX_SPAWN_DEPTH_CEILING,
4843 child_route: None,
4844 launch_manifest: None,
4845 };
4846
4847 let exec = codewhale_config::FleetExecConfig {
4848 max_spawn_depth: 2,
4849 ..Default::default()
4850 };
4851 let hardened = apply_exec_hardening(spec.clone(), &exec);
4852 assert_eq!(hardened.max_spawn_depth, 2);
4853
4854 let exec = codewhale_config::FleetExecConfig {
4855 max_spawn_depth: 99,
4856 ..Default::default()
4857 };
4858 let hardened = apply_exec_hardening(spec.clone(), &exec);
4859 assert_eq!(
4860 hardened.max_spawn_depth,
4861 codewhale_config::MAX_SPAWN_DEPTH_CEILING
4862 );
4863
4864 let exec = codewhale_config::FleetExecConfig {
4865 max_spawn_depth: 0,
4866 ..Default::default()
4867 };
4868 let hardened = apply_exec_hardening(spec, &exec);
4869 assert_eq!(hardened.max_spawn_depth, 0);
4870 }
4871
4872 #[test]
4873 fn exec_hardening_filters_disallowed_tools() {
4874 let profile = AgentWorkerToolProfile::Explicit(vec![
4875 "read_file".to_string(),
4876 "exec_shell".to_string(),
4877 "git_diff".to_string(),
4878 ]);
4879 let exec = codewhale_config::FleetExecConfig {
4880 disallowed_tools: vec!["exec_shell".to_string()],
4881 ..Default::default()
4882 };
4883 let filtered = filter_tool_profile(&profile, &exec);
4884 assert_eq!(
4885 filtered,
4886 AgentWorkerToolProfile::Explicit(
4887 vec!["read_file".to_string(), "git_diff".to_string(),]
4888 )
4889 );
4890 }
4891
4892 #[test]
4893 fn exec_hardening_allowed_tools_acts_as_allowlist() {
4894 let profile = AgentWorkerToolProfile::Explicit(vec![
4895 "read_file".to_string(),
4896 "exec_shell".to_string(),
4897 "git_diff".to_string(),
4898 ]);
4899 let exec = codewhale_config::FleetExecConfig {
4900 allowed_tools: vec!["read_file".to_string(), "git_diff".to_string()],
4901 ..Default::default()
4902 };
4903 let filtered = filter_tool_profile(&profile, &exec);
4904 assert_eq!(
4905 filtered,
4906 AgentWorkerToolProfile::Explicit(
4907 vec!["read_file".to_string(), "git_diff".to_string(),]
4908 )
4909 );
4910 }
4911
4912 #[test]
4913 fn exec_hardening_allowed_plus_disallowed_disallowed_wins() {
4914 let profile = AgentWorkerToolProfile::Explicit(vec![
4915 "read_file".to_string(),
4916 "exec_shell".to_string(),
4917 ]);
4918 let exec = codewhale_config::FleetExecConfig {
4919 allowed_tools: vec!["read_file".to_string(), "exec_shell".to_string()],
4920 disallowed_tools: vec!["exec_shell".to_string()],
4921 ..Default::default()
4922 };
4923 let filtered = filter_tool_profile(&profile, &exec);
4924 assert_eq!(
4925 filtered,
4926 AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(),])
4927 );
4928 }
4929
4930 #[test]
4931 fn exec_hardening_leaves_policy_prompt_out_of_the_objective() {
4932 let spec = AgentWorkerSpec {
4933 worker_id: "w1".to_string(),
4934 run_id: "r1".to_string(),
4935 parent_run_id: None,
4936 workflow_run_id: None,
4937 session_name: None,
4938 objective: "do the thing".to_string(),
4939 role: None,
4940 agent_type: FleetRole::Worker,
4941 model: "auto".to_string(),
4942 workspace: std::path::PathBuf::from("/tmp"),
4943 git_branch: None,
4944 context_mode: "fresh".to_string(),
4945 fork_context: false,
4946 tool_profile: AgentWorkerToolProfile::Inherited,
4947 runtime_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
4948 max_steps: 100,
4949 spawn_depth: 0,
4950 max_spawn_depth: 0,
4951 child_route: None,
4952 launch_manifest: None,
4953 };
4954 let exec = codewhale_config::FleetExecConfig {
4955 append_system_prompt: "never push to main".to_string(),
4956 ..Default::default()
4957 };
4958 let hardened = apply_exec_hardening(spec, &exec);
4959 // The policy travels as system prompt text on the worker command
4960 // (`--append-system-prompt`), never duplicated into the task prompt.
4961 assert_eq!(hardened.objective, "do the thing");
4962 }
4963 }
4964
4964 lines RUST