From 2d50a60e20d9a0cdd5d4671def32fa5aa9c06fc7 Mon Sep 17 00:00:00 2001 From: xiaoming-2026 Date: Wed, 29 Jul 2026 22:31:37 +0800 Subject: [PATCH 1/2] test(a2a): stabilize workflow smoke assertions --- service/agent-service-demo/example/a2a/README.md | 4 ++-- service/agent-service-demo/example/a2a/smoke-a2a.ps1 | 6 ++---- service/agent-service-demo/example/a2a/smoke-a2a.sh | 6 ++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/service/agent-service-demo/example/a2a/README.md b/service/agent-service-demo/example/a2a/README.md index 70685aba..75e2f58d 100644 --- a/service/agent-service-demo/example/a2a/README.md +++ b/service/agent-service-demo/example/a2a/README.md @@ -377,7 +377,7 @@ curl -sS -N -X POST http://localhost:18090/a2a/ \ JSON ``` -恢复后应进入 `TASK_STATE_COMPLETED`。Agent D 的结构化最终结果通过 `parts.data` 传递,逐层返回后的用户结果应包含 `claim_id`、`WF-STREAM-001`、`policy_status`、`OVER_LIMIT`、`approved` 和 `llm_report`,且不得包含未解开的 `workflow_final` AgentCore 信封。 +恢复后应进入 `TASK_STATE_COMPLETED`,保持原 `taskId`,且不得包含未解开的 `workflow_final` AgentCore 信封。Agent D 的结构化最终结果通过 `parts.data` 传递,但经过 Agent B 和 Agent A 的 ReAct 层后,最外层文本可能被模型重新表述;烟测不对该文本的字段名或格式做断言。 ### 场景 5:A -> B -> D 非流式人工审批与恢复 @@ -430,7 +430,7 @@ curl -sS -X POST http://localhost:18090/a2a/ \ JSON ``` -恢复后应进入 `TASK_STATE_COMPLETED`,结果应包含 `claim_id`、`WF-NONSTREAM-001`、`policy_status`、`OVER_LIMIT`、`approved` 和 `llm_report`,并且 Agent B 不应因空工具结果重复调用 Agent D。 +恢复后应进入 `TASK_STATE_COMPLETED` 并保持原 `taskId`,同时 Agent B 不应因空工具结果重复调用 Agent D。烟测不检查经过 ReAct 层后的最终文本字段或格式。 ### 场景 6:A -> B -> D 合规费用自动审批 diff --git a/service/agent-service-demo/example/a2a/smoke-a2a.ps1 b/service/agent-service-demo/example/a2a/smoke-a2a.ps1 index 3c7618a0..3ae58756 100644 --- a/service/agent-service-demo/example/a2a/smoke-a2a.ps1 +++ b/service/agent-service-demo/example/a2a/smoke-a2a.ps1 @@ -548,8 +548,7 @@ try { $dStreamResponse2 = Invoke-Utf8TextRequest -Uri "$BaseUrlA/a2a/" -Body $dStreamBody2 ` -TimeoutSec $A2aRequestTimeoutSec $dStreamResponse2 | Set-Content -LiteralPath (Join-Path $tmp "d-stream-response-2.txt") -Encoding utf8 - $dStreamTask2 = Read-SseTask $dStreamResponse2 "TASK_STATE_COMPLETED" ` - "policy_status" $dStreamClaim "llm_report" + $dStreamTask2 = Read-SseTask $dStreamResponse2 "TASK_STATE_COMPLETED" $dStreamArtifacts = @( foreach ($line in ($dStreamResponse2 -split "`r?`n")) { if ($line.StartsWith("data:")) { @@ -585,8 +584,7 @@ try { -Body $dNonstreamBody2 -Accept "application/json" -TimeoutSec $A2aRequestTimeoutSec $dNonstreamResponse2 = $dNonstreamRawResponse2 | ConvertFrom-Json Save-JsonResponse $dNonstreamResponse2 "d-nonstream-response-2.json" - $dNonstreamTask2 = Read-SyncTask $dNonstreamResponse2 "TASK_STATE_COMPLETED" ` - "policy_status" $dNonstreamClaim "llm_report" + $dNonstreamTask2 = Read-SyncTask $dNonstreamResponse2 "TASK_STATE_COMPLETED" Assert-PlainTerminalArtifacts @($dNonstreamResponse2.result.task.artifacts) $dNonstreamRawResponse2 if ($dNonstreamTask2.TaskId -ne $dNonstreamTask1.TaskId) { throw "Agent D non-streaming resume changed taskId from $($dNonstreamTask1.TaskId) to $($dNonstreamTask2.TaskId)" diff --git a/service/agent-service-demo/example/a2a/smoke-a2a.sh b/service/agent-service-demo/example/a2a/smoke-a2a.sh index 43f8b655..f4a45cee 100755 --- a/service/agent-service-demo/example/a2a/smoke-a2a.sh +++ b/service/agent-service-demo/example/a2a/smoke-a2a.sh @@ -470,8 +470,7 @@ write_a2a_request "SendStreamingMessage" "d-stream-2" "$D_STREAM_CONTEXT" "$d_st curl -sS -N --max-time "$A2A_REQUEST_TIMEOUT_SECONDS" -X POST "$BASE_URL_A/a2a/" \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' --data-binary "@$d_stream_request2" >"$d_stream_response2" -d_stream_resumed_task_id="$(assert_sse_task "$d_stream_response2" "TASK_STATE_COMPLETED" \ - "policy_status" "$D_STREAM_CLAIM" "llm_report")" +d_stream_resumed_task_id="$(assert_sse_task "$d_stream_response2" "TASK_STATE_COMPLETED")" assert_plain_terminal_artifacts "$d_stream_response2" "sse" if [ "$d_stream_resumed_task_id" != "$d_stream_task_id" ]; then fail "Agent D streaming resume changed taskId from $d_stream_task_id to $d_stream_resumed_task_id" @@ -502,8 +501,7 @@ write_a2a_request "SendMessage" "d-nonstream-2" "$D_NONSTREAM_CONTEXT" "$d_nonst curl -sS --max-time "$A2A_REQUEST_TIMEOUT_SECONDS" -X POST "$BASE_URL_A/a2a/" \ -H 'Content-Type: application/json' \ --data-binary "@$d_nonstream_request2" >"$d_nonstream_response2" -d_nonstream_resumed_task_id="$(assert_sync_task "$d_nonstream_response2" "TASK_STATE_COMPLETED" \ - "policy_status" "$D_NONSTREAM_CLAIM" "llm_report")" +d_nonstream_resumed_task_id="$(assert_sync_task "$d_nonstream_response2" "TASK_STATE_COMPLETED")" assert_plain_terminal_artifacts "$d_nonstream_response2" "sync" if [ "$d_nonstream_resumed_task_id" != "$d_nonstream_task_id" ]; then fail "Agent D non-streaming resume changed taskId from $d_nonstream_task_id to $d_nonstream_resumed_task_id" From 681b9a01fa5cd52e0ccee774c3e7a4962f0393e5 Mon Sep 17 00:00:00 2001 From: xiaoming-2026 Date: Wed, 29 Jul 2026 23:02:41 +0800 Subject: [PATCH 2/2] fix(a2a): prevent Redis task queue backlogs --- .../app/controller/a2a/A2AAgentExecutor.java | 48 ++++++++++++------- .../a2a/WriteThrottlingTaskStore.java | 5 +- .../controller/a2a/A2AAgentExecutorTest.java | 23 +++++++++ .../a2a/WriteThrottlingTaskStoreTest.java | 16 ++++++- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutor.java b/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutor.java index 28e6a0be..0a20da71 100644 --- a/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutor.java +++ b/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutor.java @@ -47,9 +47,10 @@ public class A2AAgentExecutor implements AgentExecutor { private static final String INTERRUPT = "_interrupt"; /** - * Dead-time bound for waiting on the in-flight queue to drain before - * force-closing the stream. The wait returns as soon as the queue actually - * drains, so this only caps the worst case; it is set generously because under + * Dead-time bound for waiting on the in-flight queue to drain before closing + * the stream. The wait returns as soon as the queue actually drains; on timeout + * the queue stays open so a late interrupted status can still be delivered. The + * bound is set generously because under * a high-latency Redis task store the event-bus processor persists each * backed-up streaming event with a blocking round-trip, so a large backlog can * take a while to clear. @@ -278,22 +279,29 @@ private static void closeEventQueue(AgentEmitter emitter, String taskId) { try { Optional queue = emitterEventQueue(emitter); if (queue.isPresent()) { - org.a2aproject.sdk.server.events.EventQueue q = queue.get(); - awaitInFlightDrained(q, taskId); - q.close(false, false); + closeWhenDrained(queue.get(), taskId, CLOSE_DRAIN_TIMEOUT_MS); } - log.info("A2A eventQueue closed (INPUT_REQUIRED preserved) taskId={}", taskId); } catch (ReflectiveOperationException | SecurityException e) { log.warn("A2A closeEventQueue failed, falling back to complete() taskId={}", taskId, e); emitter.complete(); } } + static void closeWhenDrained(org.a2aproject.sdk.server.events.EventQueue queue, String taskId, long timeoutMs) { + if (awaitInFlightDrained(queue, taskId, timeoutMs)) { + queue.close(false, false); + log.info("A2A eventQueue closed (INPUT_REQUIRED preserved) taskId={}", taskId); + return; + } + log.warn("A2A eventQueue still has in-flight events after {}ms; leaving it open to preserve INPUT_REQUIRED " + + "delivery taskId={}", timeoutMs, taskId); + } + private static void completeAndDrain(AgentEmitter emitter, String taskId) { emitter.complete(); try { Optional queue = emitterEventQueue(emitter); - queue.ifPresent(q -> awaitInFlightDrained(q, taskId)); + queue.ifPresent(q -> awaitInFlightDrained(q, taskId, CLOSE_DRAIN_TIMEOUT_MS)); log.info("A2A eventQueue drained after COMPLETED taskId={}", taskId); } catch (ReflectiveOperationException | SecurityException e) { log.debug("A2A completeAndDrain: eventQueue unavailable taskId={}", taskId, e); @@ -308,7 +316,7 @@ private void failAndDrain(AgentEmitter emitter, A2AMessageContext msgCtx, Throwa String taskId = msgCtx.getTaskId(); try { Optional queue = emitterEventQueue(emitter); - queue.ifPresent(q -> awaitInFlightDrained(q, taskId)); + queue.ifPresent(q -> awaitInFlightDrained(q, taskId, CLOSE_DRAIN_TIMEOUT_MS)); log.info("A2A eventQueue drained after FAILED taskId={}", taskId); } catch (ReflectiveOperationException | SecurityException e) { log.debug("A2A failAndDrain: eventQueue unavailable taskId={}", taskId, e); @@ -333,16 +341,20 @@ private static Optional emitterEven * child queue. {@code MainQueue.size()} only returns to zero after * {@code distributeToChildren()} and the matching semaphore release, so this is * the reliable "safe to close" signal. Returns early as soon as the queue - * drains and only blocks up to {@link #CLOSE_DRAIN_TIMEOUT_MS}; falls back to - * an immediate close if the topology or {@code size()} cannot be read - * reflectively. + * drains and only blocks up to the supplied timeout. A timeout or unavailable + * queue size is reported to the caller so it can avoid closing ahead of an + * in-flight interrupted status. * * @param childQueue * the emitter's (child) event queue * @param taskId * the A2A task ID for logging + * @param timeoutMs + * maximum time to wait for in-flight events + * @return {@code true} when the queue drained before the timeout */ - private static void awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQueue childQueue, String taskId) { + private static boolean awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQueue childQueue, String taskId, + long timeoutMs) { Object sizeTarget = childQueue; try { var parentField = childQueue.getClass().getDeclaredField("parent"); @@ -354,24 +366,24 @@ private static void awaitInFlightDrained(org.a2aproject.sdk.server.events.EventQ } catch (ReflectiveOperationException | SecurityException e) { log.debug("A2A awaitInFlightDrained: no parent queue, polling child queue taskId={}", taskId); } - long deadline = System.currentTimeMillis() + CLOSE_DRAIN_TIMEOUT_MS; + long deadline = System.currentTimeMillis() + timeoutMs; try { var sizeMethod = sizeTarget.getClass().getMethod("size"); sizeMethod.setAccessible(true); while (System.currentTimeMillis() < deadline) { Object size = sizeMethod.invoke(sizeTarget); if (size instanceof Integer i && i <= 0) { - return; + return true; } Thread.sleep(CLOSE_DRAIN_POLL_MS); } - log.warn("A2A awaitInFlightDrained timed out after {}ms, closing anyway taskId={}", CLOSE_DRAIN_TIMEOUT_MS, - taskId); + return false; } catch (InterruptedException e) { log.debug("A2A awaitInFlightDrained interrupted taskId={}", taskId); } catch (ReflectiveOperationException | SecurityException e) { - log.debug("A2A awaitInFlightDrained: size() unavailable, closing immediately taskId={}", taskId); + log.debug("A2A awaitInFlightDrained: size() unavailable taskId={}", taskId); } + return false; } @Override diff --git a/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStore.java b/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStore.java index 896cdaae..48412083 100644 --- a/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStore.java +++ b/service/agent-service-app/src/main/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStore.java @@ -108,7 +108,7 @@ public void save(Task task, boolean isOverwrite) { if (isCritical || isWriteDue) { delegate.save(task, isOverwrite); - lastWriteMs.put(id, now); + lastWriteMs.put(id, clock.getAsLong()); if (state != null && state.isFinal()) { // Durable in the delegate now; drop the in-memory copy so the map stays // bounded. @@ -143,10 +143,9 @@ public void delete(String taskId) { public ListTasksResult list(ListTasksParams params) { // Flush any coalesced-but-unwritten state so the delegate's scan reflects // in-flight tasks too. - long now = clock.getAsLong(); for (Map.Entry e : latest.entrySet()) { delegate.save(e.getValue(), true); - lastWriteMs.put(e.getKey(), now); + lastWriteMs.put(e.getKey(), clock.getAsLong()); } return delegate.list(params); } diff --git a/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutorTest.java b/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutorTest.java index d7d43ed7..8df8fb47 100644 --- a/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutorTest.java +++ b/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/A2AAgentExecutorTest.java @@ -154,6 +154,15 @@ void syncCompletedPathWaitsForEnqueuedFinalEventToDrain() { assertThat(queue.sizeCalls.get()).isPositive(); } + @Test + void inputRequiredQueueRemainsOpenWhenDrainTimesOut() { + NeverDrainingEventQueue queue = new NeverDrainingEventQueue(); + + A2AAgentExecutor.closeWhenDrained(queue, "task-1", 0L); + + assertThat(queue.closeCalls.get()).isZero(); + } + @Test void failurePathEmitsFailedStatusWithBusinessError() { ServeOrchestrator orchestrator = mock(ServeOrchestrator.class); @@ -394,4 +403,18 @@ public void close(boolean isImmediate) { public void close(boolean isImmediate, boolean shouldNotifyParent) { } } + + private static final class NeverDrainingEventQueue extends CountingEventQueue { + private final AtomicInteger closeCalls = new AtomicInteger(); + + @Override + public int size() { + return 1; + } + + @Override + public void close(boolean isImmediate, boolean shouldNotifyParent) { + closeCalls.incrementAndGet(); + } + } } diff --git a/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStoreTest.java b/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStoreTest.java index 180c4c43..f958081f 100644 --- a/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStoreTest.java +++ b/service/agent-service-app/src/test/java/com/openjiuwen/service/app/controller/a2a/WriteThrottlingTaskStoreTest.java @@ -68,6 +68,17 @@ void writesAgainOnceThrottleWindowElapses() { assertThat(delegate.data.get(ID).contextId()).isEqualTo("v1"); } + @Test + void measuresThrottleWindowAfterSlowDelegateWriteCompletes() { + delegate.saveDurationMs = INTERVAL_MS + 50L; + + store.save(working("v0"), false); + store.save(working("v1"), false); + + assertThat(delegate.saveCount).isEqualTo(1); + assertThat(store.get(ID).contextId()).isEqualTo("v1"); + } + @Test void finalStateAlwaysWritesThroughAndEvictsCache() { store.save(working("v0"), false); // count 1 @@ -119,15 +130,18 @@ private static Task withState(String versionTag, TaskState state) { * In-memory {@link TaskStore} that counts writes, standing in for the slow * Redis delegate. */ - private static final class CountingStore implements TaskStore { + private final class CountingStore implements TaskStore { private final Map data = new HashMap<>(); private int saveCount; + private long saveDurationMs; + @Override public void save(Task task, boolean isOverwrite) { saveCount++; data.put(task.id(), task); + now[0] += saveDurationMs; } @Override