Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fleet setup is a role/profile roster editor, not a provider-scoped model
picker: the Model step lists routes from every configured provider (not
only the active one), a picked route's provider is persisted explicitly in
the saved profile TOML (`provider = "..."`, never inferred from the model
id), and the loader/route resolver read that field back out verbatim. The
draft-preview ratify keypress no longer competes with a separate pager's
`g`/`G` scroll bindings — the exact TOML preview now renders inline on the
same Review step that ratifies it (#4093).
- The headless `codewhale fleet run` CLI now launches workers on their profile-pinned route, not just records it on the receipt: `codewhale exec` gains a non-secret `--provider` flag, and a worker whose profile pins provider B is dispatched with `--provider B --model <B's model>` even when the parent session is on provider A (credentials still resolve from the worker's own environment; provider is never inferred from the model id). Workers with no profile-bound provider are unchanged — no `--provider`, run-level model. The interactive TUI spawns roster members in-process and does not yet honor the pinned provider (it uses the session provider); that remainder is tracked in #4193 (#4093).
- The Fleet setup `m` model-assisted redraft no longer drops a picked
cross-provider route: the provider/model the operator chose are re-pinned
onto the drafted profile (a model draft is always `provider: None`), so
ratifying it keeps the explicit route instead of persisting an ambiguous,
provider-scoped profile (#4093).
- Ratifying a Fleet profile now fails with a clear message when it pins a
provider that has no configured credentials, using the same
configured-provider check the model picker uses (#4093).
- Workflow correctness: completion polling fails closed instead of
fabricating success when a sub-agent reports no terminal status; cancel
interrupts the JS VM (cancel handle + abort) and blocks further spawns;
Expand Down
14 changes: 14 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,20 @@ pub struct FleetProfile {
/// validates the executable provider/model/wire-model decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional explicit provider id for this profile's model (#4093).
///
/// Present only when the profile was created against a specific,
/// credential-checked provider (e.g. via the Fleet setup model picker),
/// so a worker can be pinned to a route independent of the parent/current
/// session provider. `None` means "no route pin" (inherit), matching
/// `model: None`; a profile must never carry `provider` without `model`.
///
/// EPIC #2608 explicit-config-only mandate: this field is the ONLY
/// authority for the profile's provider. It is never inferred by sniffing
/// a substring/prefix out of `model` — callers that need the provider for
/// this profile must read this field, not guess from the model id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Permission defaults requested by the profile.
#[serde(default)]
pub permissions: FleetProfilePermissions,
Expand Down
17 changes: 17 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fleet setup is a role/profile roster editor, not a provider-scoped model
picker: the Model step lists routes from every configured provider (not
only the active one), a picked route's provider is persisted explicitly in
the saved profile TOML (`provider = "..."`, never inferred from the model
id), and the loader/route resolver read that field back out verbatim. The
draft-preview ratify keypress no longer competes with a separate pager's
`g`/`G` scroll bindings — the exact TOML preview now renders inline on the
same Review step that ratifies it (#4093).
- The headless `codewhale fleet run` CLI now launches workers on their profile-pinned route, not just records it on the receipt: `codewhale exec` gains a non-secret `--provider` flag, and a worker whose profile pins provider B is dispatched with `--provider B --model <B's model>` even when the parent session is on provider A (credentials still resolve from the worker's own environment; provider is never inferred from the model id). Workers with no profile-bound provider are unchanged — no `--provider`, run-level model. The interactive TUI spawns roster members in-process and does not yet honor the pinned provider (it uses the session provider); that remainder is tracked in #4193 (#4093).
- The Fleet setup `m` model-assisted redraft no longer drops a picked
cross-provider route: the provider/model the operator chose are re-pinned
onto the drafted profile (a model draft is always `provider: None`), so
ratifying it keeps the explicit route instead of persisting an ambiguous,
provider-scoped profile (#4093).
- Ratifying a Fleet profile now fails with a clear message when it pins a
provider that has no configured credentials, using the same
configured-provider check the model picker uses (#4093).
- Workflow correctness: completion polling fails closed instead of
fabricating success when a sub-agent reports no terminal status; cancel
interrupts the JS VM (cancel handle + abort) and blocks further spawns;
Expand Down
126 changes: 123 additions & 3 deletions crates/tui/src/fleet/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ use codewhale_protocol::fleet::{FleetHostSpec, FleetTaskSpec, FleetWorkerEventPa

use super::host::{FleetHostAdapter, FleetWorkerCommand};
use super::profile::AgentProfile;
use super::worker_runtime::{fleet_task_prompt, fleet_task_prompt_with_profiles};
use super::worker_runtime::{
fleet_task_prompt, fleet_task_prompt_with_profiles, fleet_worker_launch_route,
};

/// Build the `codewhale exec` argv that runs a fleet task headlessly.
///
Expand All @@ -38,7 +40,11 @@ use super::worker_runtime::{fleet_task_prompt, fleet_task_prompt_with_profiles};
///
/// Secrets are NEVER placed on the argv: provider credentials are resolved by
/// the worker process from its own config/keyring exactly like an interactive
/// run. The host adapter additionally refuses secret-bearing env keys.
/// run. The host adapter additionally refuses secret-bearing env keys. The
/// `--provider` flag threaded by [`build_worker_exec_command_with_profiles`] is
/// a non-secret provider *identifier* only (#4093) — the worker still resolves
/// that provider's credentials from its own env/config, so this invariant
/// holds.
pub fn build_worker_exec_command(
codewhale_binary: &str,
task_spec: &FleetTaskSpec,
Expand All @@ -50,22 +56,35 @@ pub fn build_worker_exec_command(
fleet_task_prompt(task_spec),
exec_config,
model,
None,
)
}

/// Build a worker command after resolving workspace Fleet profile input.
///
/// The launched subprocess runs on the worker's RESOLVED route, not blindly on
/// the run-level session model (#4093 AC #4): the per-worker model+provider are
/// resolved from the task's agent profile via the same explicit-only path the
/// receipt uses ([`fleet_worker_launch_route`]). A worker whose profile pins
/// provider B thus launches on provider B's model even when the parent session
/// is on provider A. Workers with no profile-bound provider fall back to the
/// run-level model and emit no `--provider`, so the worker keeps its own
/// session default (today's behavior, unchanged).
pub fn build_worker_exec_command_with_profiles(
codewhale_binary: &str,
task_spec: &FleetTaskSpec,
exec_config: &FleetExecConfig,
model: Option<&str>,
agent_profiles: &[AgentProfile],
) -> Result<FleetWorkerCommand> {
let (worker_model, worker_provider) =
fleet_worker_launch_route(task_spec, agent_profiles, model.unwrap_or_default());
Ok(build_worker_exec_command_from_prompt(
codewhale_binary,
fleet_task_prompt_with_profiles(task_spec, agent_profiles)?,
exec_config,
model,
Some(worker_model.as_str()),
worker_provider.as_deref(),
))
}

Expand All @@ -74,6 +93,7 @@ fn build_worker_exec_command_from_prompt(
task_prompt: String,
exec_config: &FleetExecConfig,
model: Option<&str>,
provider: Option<&str>,
) -> FleetWorkerCommand {
let mut args: Vec<String> = vec![
"exec".to_string(),
Expand All @@ -87,6 +107,15 @@ fn build_worker_exec_command_from_prompt(
args.push(model.to_string());
}

// Non-secret provider identifier only (#4093): the worker resolves the
// provider's credentials from its own env/config. Emitted ONLY when the
// worker's profile explicitly pins a provider, so profile-less workers keep
// their own session default exactly as before.
if let Some(provider) = provider.map(str::trim).filter(|p| !p.is_empty()) {
args.push("--provider".to_string());
args.push(provider.to_string());
}

if !exec_config.allowed_tools.is_empty() {
args.push("--allowed-tools".to_string());
args.push(exec_config.allowed_tools.join(","));
Expand Down Expand Up @@ -437,6 +466,7 @@ mod tests {
},
loadout: FleetLoadout::Inherit,
model: None,
provider: None,
permissions: FleetProfilePermissions::default(),
delegation: FleetDelegationHints::default(),
},
Expand Down Expand Up @@ -499,6 +529,96 @@ mod tests {
assert!(prompt.contains("Focus on defects, regressions, and missing tests."));
}

/// #4093 AC #4 at the LAUNCH boundary (not just the receipt): a worker whose
/// profile pins a DIFFERENT provider+model than the parent session must
/// actually launch on the profile's route. The parent session is DeepSeek
/// here (`--model deepseek-v4-pro`); the profile pins OpenRouter + glm-5.2.
/// The emitted argv must carry OpenRouter's id and the profile's model as
/// paired flag/values — never the parent's model. This is the gap the
/// save→load→resolve receipt tests never covered.
#[test]
fn worker_command_launches_profile_bound_provider_and_model_not_the_parent() {
let mut task = task("audit");
task.worker.as_mut().unwrap().agent_profile = Some("cross".to_string());

let mut profile = agent_profile("cross", "scout", "Read first.");
profile.profile.provider = Some("openrouter".to_string());
profile.profile.model = Some("glm-5.2".to_string());

let cmd = build_worker_exec_command_with_profiles(
"codewhale",
&task,
&FleetExecConfig::default(),
Some("deepseek-v4-pro"), // parent/session model on provider A.
&[profile],
)
.unwrap();

// Assert the flag/value PAIRS, so the provider and model are proven to
// ride together rather than merely appearing somewhere on the argv.
let provider_idx = cmd
.args
.iter()
.position(|a| a == "--provider")
.expect("--provider must be threaded for a provider-pinned worker");
assert_eq!(
cmd.args.get(provider_idx + 1).map(String::as_str),
Some("openrouter"),
"{:?}",
cmd.args
);
let model_idx = cmd
.args
.iter()
.position(|a| a == "--model")
.expect("--model must be present");
assert_eq!(
cmd.args.get(model_idx + 1).map(String::as_str),
Some("glm-5.2"),
"{:?}",
cmd.args
);

// The parent/session model must NOT leak onto the argv.
assert!(
!cmd.args.iter().any(|a| a == "deepseek-v4-pro"),
"parent model leaked into a profile-pinned worker's argv: {:?}",
cmd.args
);
}

/// A worker with no profile-bound provider preserves today's behavior: the
/// run-level model on `--model`, and NO `--provider` (the worker keeps its
/// own session default). Guards against regressing profile-less workers.
#[test]
fn worker_command_without_profile_provider_omits_provider_and_keeps_run_model() {
let cmd = build_worker_exec_command_with_profiles(
"codewhale",
&task("read"),
&FleetExecConfig::default(),
Some("deepseek-v4-pro"),
&[],
)
.unwrap();

assert!(
!cmd.args.iter().any(|a| a == "--provider"),
"profile-less worker must not carry --provider: {:?}",
cmd.args
);
let model_idx = cmd
.args
.iter()
.position(|a| a == "--model")
.expect("--model must be present");
assert_eq!(
cmd.args.get(model_idx + 1).map(String::as_str),
Some("deepseek-v4-pro"),
"{:?}",
cmd.args
);
}

#[test]
fn unbounded_max_turns_is_not_passed() {
let exec = FleetExecConfig::default(); // max_turns == u32::MAX
Expand Down
Loading
Loading