@@ -6,7 +6,7 @@ use std::path::Path;
66use std:: sync:: Arc ;
77
88use orchestrator_config:: workflow_config:: {
9- load_workflow_config_or_default , load_workflow_config_with_metadata , OauthConfig , OauthFlow ,
9+ try_load_workflow_config , OauthConfig , OauthFlow , WorkflowConfigAvailability ,
1010} ;
1111use orchestrator_core:: SecretStore ;
1212use protocol:: repository_scope:: { repository_scope_for_path, scoped_state_root} ;
@@ -21,6 +21,13 @@ pub enum ServerResolutionError {
2121 UnknownServer ( String ) ,
2222 #[ error( "MCP server `{0}` has no `url`; an HTTP-transport URL is required for OAuth" ) ]
2323 MissingUrl ( String ) ,
24+ /// The `config_source` failed to load (spawn / RPC / DB overload / validation),
25+ /// so the server's config could NOT be determined. This is distinct from
26+ /// [`UnknownServer`], which fires only when the config loaded and the server
27+ /// is genuinely absent. Callers should retry / report a transient source
28+ /// error rather than "server not configured".
29+ #[ error( "MCP server `{0}` could not be resolved: the workflow config source failed to load ({1}). This is a config-source error, not a missing-server error — retry shortly or check the `config_source` plugin." ) ]
30+ ConfigSourceUnavailable ( String , String ) ,
2431 #[ error( "failed to load workflow config: {0}" ) ]
2532 WorkflowConfig ( String ) ,
2633 #[ error( "failed to load project config: {0}" ) ]
@@ -99,22 +106,30 @@ pub fn resolve_server_url(
99106) -> Result < ServerResolution , ServerResolutionError > {
100107 // Workflow config mcp_servers first (authoritative for daemon runs).
101108 //
102- // A malformed `.animus/workflows.yaml` must surface its YAML/validation
103- // error rather than silently falling back to the builtin default (which
104- // would mislead the user into "unknown server"). But an *absent* workflow
105- // config is normal (project-config-only setups), and
106- // `load_workflow_config_with_metadata` returns a "missing" error in that
107- // case. So: only propagate the load error when a workflow YAML source
108- // actually exists; otherwise fall back to the builtin default and continue
109- // to project config.
110- let loaded = match load_workflow_config_with_metadata ( project_root, None ) {
111- Ok ( loaded) => loaded,
112- Err ( err) if workflow_yaml_present ( project_root) => {
113- return Err ( ServerResolutionError :: WorkflowConfig ( err. to_string ( ) ) ) ;
109+ // Use the NON-SWALLOWING loader so a transient `config_source` failure (DB
110+ // overload under bulk `mcp call`) is NOT degraded into an empty config that
111+ // then misreports the server as "not defined". `try_load_workflow_config`
112+ // distinguishes three cases:
113+ // * Loaded — use its `mcp_servers`.
114+ // * NoSource — no config_source configured; benign, fall through to
115+ // project config (project-config-only setups).
116+ // * SourceUnavailable — the source failed; surface a DISTINCT retryable
117+ // error. We do this EVEN when the caller supplied a
118+ // `--url`: a `--url` overrides only the upstream
119+ // endpoint, not the server's oauth FLOW/block, so
120+ // synthesizing an `authorization_code` resolution from
121+ // the URL alone would misroute a broker-flow server
122+ // (`manual_bearer` / `client_credentials` /
123+ // `refresh_token`) to the keychain path. Erroring lets
124+ // the caller retry once the source recovers.
125+ let loaded = match try_load_workflow_config ( project_root, None ) {
126+ WorkflowConfigAvailability :: Loaded ( loaded) => Some ( loaded) ,
127+ WorkflowConfigAvailability :: NoSource => None ,
128+ WorkflowConfigAvailability :: SourceUnavailable ( err) => {
129+ return Err ( ServerResolutionError :: ConfigSourceUnavailable ( server. to_string ( ) , err. to_string ( ) ) ) ;
114130 }
115- Err ( _) => load_workflow_config_or_default ( project_root) ,
116131 } ;
117- if let Some ( def) = loaded. config . mcp_servers . get ( server) {
132+ if let Some ( def) = loaded. as_ref ( ) . and_then ( |loaded| loaded . config . mcp_servers . get ( server) ) {
118133 return finalize ( server, url_override, def. url . clone ( ) , def. oauth . clone ( ) ) ;
119134 }
120135
@@ -139,21 +154,6 @@ pub fn resolve_server_url(
139154 }
140155}
141156
142- /// True when a workflow YAML source exists at `.animus/workflows.yaml` or
143- /// any `.animus/workflows/*.yaml`. Used to decide whether a workflow-config
144- /// load failure is a real (malformed-config) error worth propagating vs the
145- /// benign "no workflow config" case.
146- fn workflow_yaml_present ( project_root : & Path ) -> bool {
147- let animus = project_root. join ( ".animus" ) ;
148- if animus. join ( "workflows.yaml" ) . exists ( ) {
149- return true ;
150- }
151- let dir = animus. join ( "workflows" ) ;
152- std:: fs:: read_dir ( & dir)
153- . map ( |entries| entries. flatten ( ) . any ( |e| e. path ( ) . extension ( ) . is_some_and ( |ext| ext == "yaml" || ext == "yml" ) ) )
154- . unwrap_or ( false )
155- }
156-
157157fn finalize (
158158 server : & str ,
159159 url_override : Option < & str > ,
@@ -188,3 +188,102 @@ fn finalize(
188188 } ) ,
189189 }
190190}
191+
192+ #[ cfg( test) ]
193+ mod tests {
194+ use super :: * ;
195+ use orchestrator_config:: workflow_config:: builtin_workflow_config;
196+ use orchestrator_config:: workflow_config:: config_source_client:: test_seam;
197+ use std:: sync:: { Mutex , MutexGuard , OnceLock } ;
198+
199+ // These tests mutate the process-global config_source test seam + HOME env,
200+ // so serialize them under one lock held for the whole test body.
201+ fn serial ( ) -> & ' static Mutex < ( ) > {
202+ static LOCK : OnceLock < Mutex < ( ) > > = OnceLock :: new ( ) ;
203+ LOCK . get_or_init ( || Mutex :: new ( ( ) ) )
204+ }
205+
206+ /// RAII guard that points `HOME` at a temp dir for the test and RESTORES the
207+ /// previous value on drop, so the mutation never leaks to sibling tests.
208+ struct HomeGuard {
209+ prev : Option < std:: ffi:: OsString > ,
210+ }
211+
212+ impl HomeGuard {
213+ fn set ( path : & Path ) -> Self {
214+ let prev = std:: env:: var_os ( "HOME" ) ;
215+ std:: env:: set_var ( "HOME" , path) ;
216+ Self { prev }
217+ }
218+ }
219+
220+ impl Drop for HomeGuard {
221+ fn drop ( & mut self ) {
222+ match self . prev . take ( ) {
223+ Some ( v) => std:: env:: set_var ( "HOME" , v) ,
224+ None => std:: env:: remove_var ( "HOME" ) ,
225+ }
226+ }
227+ }
228+
229+ /// Common fixture: hold the serial lock, isolate `HOME` to a fresh tempdir
230+ /// (restored on drop). Bound in the caller in declaration order so the
231+ /// tempdir drops LAST — after `HomeGuard` has restored `HOME`.
232+ fn fixture ( ) -> ( MutexGuard < ' static , ( ) > , tempfile:: TempDir , HomeGuard ) {
233+ let guard = serial ( ) . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
234+ let temp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
235+ let home = HomeGuard :: set ( temp. path ( ) ) ;
236+ ( guard, temp, home)
237+ }
238+
239+ /// TASK-326 (fix d): a transient config_source failure must surface as the
240+ /// DISTINCT `ConfigSourceUnavailable`, NOT `UnknownServer` — otherwise the
241+ /// caller concludes "krisp isn't configured" when the source is merely down.
242+ #[ test]
243+ fn source_failure_yields_config_source_unavailable_not_unknown_server ( ) {
244+ let ( _guard, temp, _home) = fixture ( ) ;
245+ let root = temp. path ( ) ;
246+
247+ let _fail = test_seam:: install_failure ( root, "config_source RPC timed out" ) ;
248+ let err = resolve_server_url ( root, "krisp" , None ) . expect_err ( "must fail when source is down" ) ;
249+ assert ! (
250+ matches!( err, ServerResolutionError :: ConfigSourceUnavailable ( ref s, _) if s == "krisp" ) ,
251+ "expected ConfigSourceUnavailable, got {err:?}"
252+ ) ;
253+ }
254+
255+ /// Even with an explicit `--url`, a source outage yields
256+ /// `ConfigSourceUnavailable` (never a synthesized `authorization_code`
257+ /// resolution): a `--url` overrides only the upstream endpoint, not the
258+ /// server's oauth FLOW, so guessing the flow could misroute a broker server
259+ /// to the keychain path. The caller retries once the source recovers.
260+ #[ test]
261+ fn explicit_url_still_errors_when_source_unavailable ( ) {
262+ let ( _guard, temp, _home) = fixture ( ) ;
263+ let root = temp. path ( ) ;
264+
265+ let _fail = test_seam:: install_failure ( root, "config_source RPC timed out" ) ;
266+ let err = resolve_server_url ( root, "krisp" , Some ( "https://example.test/mcp" ) )
267+ . expect_err ( "a source outage must error even with --url, to avoid misrouting a broker server" ) ;
268+ assert ! (
269+ matches!( err, ServerResolutionError :: ConfigSourceUnavailable ( ref s, _) if s == "krisp" ) ,
270+ "expected ConfigSourceUnavailable, got {err:?}"
271+ ) ;
272+ }
273+
274+ /// When the config LOADS but the server is genuinely absent, the error is
275+ /// `UnknownServer` (the pre-existing behavior) — never `ConfigSourceUnavailable`.
276+ #[ test]
277+ fn loaded_config_missing_server_yields_unknown_server ( ) {
278+ let ( _guard, temp, _home) = fixture ( ) ;
279+ let root = temp. path ( ) ;
280+
281+ // A base with no mcp_servers loads cleanly; the server is simply absent.
282+ let _base = test_seam:: install ( root, builtin_workflow_config ( ) ) ;
283+ let err = resolve_server_url ( root, "krisp" , None ) . expect_err ( "absent server must error" ) ;
284+ assert ! (
285+ matches!( err, ServerResolutionError :: UnknownServer ( ref s) if s == "krisp" ) ,
286+ "expected UnknownServer, got {err:?}"
287+ ) ;
288+ }
289+ }
0 commit comments