From d7cdcde443103eeba3635af791a9cf66d5498825 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 31 Aug 2026 19:28:09 +0900 Subject: [PATCH] fix: preserve standalone scope conflict details --- crates/standalone/src/host.rs | 23 ++++++++++++- crates/standalone/tests/host.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 405ad8af..c4c5df73 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -4,6 +4,7 @@ use std::time::Duration; use agen::llm_client::client::LlmClient; use client::Client; use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket}; +use manifest::ScopeRule; use protocol::stream::{decode_method, encode_event}; use protocol::{Event, Method, WorkerId}; use session_store::{ @@ -18,6 +19,7 @@ use worker::ipc::protocol_session::{ WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, subscribe_worker_protocol_session, }; +use worker::runtime::worker_allocation::ScopeLockError; use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; use crate::launch::ResolvedStandaloneLaunch; @@ -43,7 +45,7 @@ pub struct StandaloneHost { lease: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum StandaloneStartupError { #[error("the standalone state store could not be opened or validated")] StateStore, @@ -53,6 +55,16 @@ pub enum StandaloneStartupError { LeaseLivenessUnknown, #[error("the standalone Worker working directory is unavailable or changed")] WorkingDirectoryUnavailable, + #[error( + "requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`", + requested_rule.target.display(), + competitor_rule.target.display() + )] + ScopeConflict { + competitor: String, + requested_rule: ScopeRule, + competitor_rule: ScopeRule, + }, #[error("the resolved Worker configuration or persisted history is invalid")] WorkerConfiguration, #[error("the configured model provider is unavailable")] @@ -509,6 +521,15 @@ fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartu fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError { match error { + WorkerBootstrapError::Worker(WorkerError::ScopeLock(ScopeLockError::WriteConflict { + competitor, + rule, + competitor_rule, + })) => StandaloneStartupError::ScopeConflict { + competitor, + requested_rule: rule, + competitor_rule, + }, WorkerBootstrapError::Worker(WorkerError::Provider(_)) => { StandaloneStartupError::ModelProvider } diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index 755f5718..510cc68c 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -164,6 +164,65 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() { host.shutdown().await.expect("graceful shutdown"); } +#[tokio::test] +async fn startup_preserves_occupied_scope_conflict_details() { + let temp = tempfile::tempdir().expect("tempdir"); + let cwd = temp.path().join("project"); + std::fs::create_dir(&cwd).expect("create project"); + + let first_launch = StandaloneLaunchConfig::new( + &cwd, + temp.path().join("first-state"), + manifest::ProfileSelector::Default, + "first-worker", + ) + .resolve() + .expect("resolve first launch"); + let first_host = + StandaloneHost::start_with_model_client(first_launch, ScriptedClient::new(Vec::new())) + .await + .expect("start first host"); + let competitor = first_host.record().storage_key.clone(); + + let second_launch = StandaloneLaunchConfig::new( + &cwd, + temp.path().join("second-state"), + manifest::ProfileSelector::Default, + "second-worker", + ) + .resolve() + .expect("resolve second launch"); + let error = + StandaloneHost::start_with_model_client(second_launch, ScriptedClient::new(Vec::new())) + .await + .err() + .expect("occupied scope rejected"); + + first_host.shutdown().await.expect("shutdown first host"); + + let canonical_cwd = cwd.canonicalize().expect("canonical cwd"); + match &error { + StandaloneStartupError::ScopeConflict { + competitor: actual_competitor, + requested_rule, + competitor_rule, + } => { + assert_eq!(actual_competitor, &competitor); + assert_eq!(requested_rule.target, canonical_cwd); + assert_eq!(competitor_rule.target, canonical_cwd); + } + other => panic!("expected scope conflict, got {other:?}"), + } + assert_eq!( + error.to_string(), + format!( + "requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`", + canonical_cwd.display(), + canonical_cwd.display() + ) + ); +} + #[tokio::test] async fn state_store_failure_is_redacted_and_starts_no_controller() { let temp = tempfile::tempdir().expect("tempdir");