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