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();