From 16bc4504a88c91f4ba974400ec8c777672be74c8 Mon Sep 17 00:00:00 2001 From: tangzhiqiang Date: Thu, 22 Oct 2026 17:45:35 +0800 Subject: [PATCH] fix(spring): bind the agent executor pool by bean name, not by type agentExecutorPool used a bare @ConditionalOnMissingBean, which matches by return type (ExecutorService). Host applications routinely define their own ExecutorService beans - dispatchers, ScheduledExecutorService-based SSE heartbeat schedulers, pub-sub workers - and because user configuration registers before auto-configuration, any one of them suppressed the A2A pool. requestHandler and restHandler then injected ExecutorService by type without a qualifier, found several candidates (none primary), and the application failed to refresh with NoUniqueBeanDefinitionException at "Parameter 5 of method requestHandler". Match by bean name instead: the pool is always auto-configured unless the host declares its own bean named agentExecutorPool, and both injection points take @Qualifier("agentExecutorPool"). Capacity keeps coming from the a2at.server.executor-* properties. Regression: a WebApplicationContextRunner with foreign ExecutorService and ScheduledExecutorService beans pre-registered now starts and wires the auto-configured pool (verified to reproduce the reported NoUniqueBeanDefinitionException without the fix), plus a host-override test where a same-named bean replaces the auto-configured one. Signed-off-by: tangzhiqiang --- .../engine/spring/A2AAutoConfiguration.java | 19 ++++- .../spring/A2AAutoConfigurationTest.java | 73 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index 9d3a387..45d1fc3 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -47,6 +47,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -159,8 +160,15 @@ public PushNotificationConfigStore pushStore() { return new InMemoryPushNotificationConfigStore(); } - @Bean(destroyMethod = "shutdownNow") - @ConditionalOnMissingBean + /** + * The A2A server executor. Matches by bean name, never by type: a host application may + * legitimately define any number of its own {@link ExecutorService} beans (dispatchers, SSE + * schedulers, pub-sub workers), and a type-based condition would silently suppress this pool + * while {@link #requestHandler} and {@link #restHandler} still need a single unambiguous + * candidate. Hosts override it by declaring their own {@code agentExecutorPool} bean. + */ + @Bean(name = "agentExecutorPool", destroyMethod = "shutdownNow") + @ConditionalOnMissingBean(name = "agentExecutorPool") public ExecutorService agentExecutorPool(A2AProperties properties) { if (properties.getExecutorMaxSize() < properties.getExecutorCoreSize()) { throw new IllegalArgumentException( @@ -201,7 +209,7 @@ public RequestHandler requestHandler( InMemoryQueueManager qm, PushNotificationConfigStore pushStore, MainEventBusProcessor proc, - ExecutorService pool, + @Qualifier("agentExecutorPool") ExecutorService pool, A2AProperties properties, ObjectProvider authorizationProvider) { DefaultRequestHandler delegate = @@ -232,7 +240,10 @@ public RequestHandler requestHandler( @Bean @ConditionalOnMissingBean - public RestHandler restHandler(AgentCard card, RequestHandler handler, ExecutorService pool) { + public RestHandler restHandler( + AgentCard card, + RequestHandler handler, + @Qualifier("agentExecutorPool") ExecutorService pool) { return new RestHandler(card, new AgentCardCacheMetadata(card, null), handler, pool); } diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index 4a6c5ad..0f9e0db 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -21,6 +21,7 @@ import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -43,7 +44,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Flow; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -726,6 +729,76 @@ void requestHandlerIsCreatedWithoutAnAuthorizationProvider() { .run(context -> assertTrue(context.containsBean("requestHandler"))); } + /** + * A host may legitimately own any number of {@link ExecutorService} beans (dispatchers, SSE + * heartbeat schedulers, pub-sub workers) and {@link ScheduledExecutorService} is a subtype, so a + * type-based condition or injection can never be unique. The auto-configured pool must still be + * registered and the handlers must wire to it — before the fix this context failed to refresh + * with {@code required a single bean, but N were found}. + */ + @Test + void autoConfiguredPoolSurvivesForeignExecutorServiceBeans() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withBean( + "spaceAiDispatcherExecutor", + ExecutorService.class, + () -> + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "foreign-dispatcher"); + t.setDaemon(true); + return t; + })) + .withBean( + "spaceSseHeartbeatScheduler", + ScheduledExecutorService.class, + () -> + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "foreign-heartbeat"); + t.setDaemon(true); + return t; + })) + .withBean(AgentCard.class, () -> mock(AgentCard.class)) + .withBean(AgentExecutor.class, () -> mock(AgentExecutor.class)) + .withPropertyValues("a2at.server.enabled=true") + .run( + context -> { + assertTrue(context.containsBean("agentExecutorPool")); + ExecutorService pool = context.getBean("agentExecutorPool", ExecutorService.class); + assertTrue( + pool instanceof ThreadPoolExecutor, + "the auto-configured pool must be the A2A ThreadPoolExecutor, not a foreign bean"); + assertTrue(context.containsBean("requestHandler")); + assertTrue(context.containsBean("restHandler")); + }); + } + + /** A host that declares its own {@code agentExecutorPool} by name takes over the wiring. */ + @Test + void hostDefinedAgentExecutorPoolReplacesTheAutoConfiguredOne() { + ExecutorService hostPool = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "host-a2a-pool"); + t.setDaemon(true); + return t; + }); + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withBean("agentExecutorPool", ExecutorService.class, () -> hostPool) + .withBean(AgentCard.class, () -> mock(AgentCard.class)) + .withBean(AgentExecutor.class, () -> mock(AgentExecutor.class)) + .withPropertyValues("a2at.server.enabled=true") + .run( + context -> { + assertSame(hostPool, context.getBean("agentExecutorPool", ExecutorService.class)); + assertTrue(context.containsBean("requestHandler")); + assertTrue(context.containsBean("restHandler")); + }); + } + @Test void heartbeatIntervalDefaultsToFifteenSeconds() { A2AProperties properties = new A2AProperties();