This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 12b63f347 fix(ai): correct three run-lifecycle defects around stopping
and attaching (#5440)
12b63f347 is described below
commit 12b63f34778007f53f52d3efdb8edb95d33818c8
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Oct 9 16:28:21 2026 +0800
fix(ai): correct three run-lifecycle defects around stopping and attaching
(#5440)
Three defects in the AI run lifecycle, plus the test that was racing them.
- The composer lost a draft when Enter arrived while a run was stopping:
`canSend` keyed off
`!generating`, and `generating` is already false once a stop was
requested, so the message went into
a run that was ending. (#5425)
- `AiRunService.stop` read the single boolean from `registry.stop()` as
"this process does not own the
run" and then wrote a terminal state for a run that was still live.
(#5440)
- `useActiveRunAttach` keyed its anti-hot-loop ref on the run id alone, so
a run that left the
admission window and came back with the same id attached to a cleanly
finished empty stream instead
of an error state. The ref now carries id + status. (#5446)
-
`AiRunServiceTest.stopShouldAbortALiveRunAndLeaveItStoppedWithUserStopTest`
asserted without
waiting for the worker, which made it flaky - this is the failure that
reddened an unrelated PR's
`Backend Tests` run today. (#5450)
`AiRunServiceTest` 23 tests green with the dev profile, 0 checkstyle
violations;
`Composer.test.tsx` + `useActiveRunAttach.test.ts` 21 tests green; prettier
clean.
Folded in #5425, #5446 and #5450.
---
.../studio/ops/ai/conversation/AiRunService.java | 7 ++++++
.../ops/ai/conversation/AiRunServiceTest.java | 28 +++++++++++++++++++---
web/src/pages/ai/components/Composer.tsx | 10 +++++---
.../ai/components/__tests__/Composer.test.tsx | 17 +++++++++++++
web/src/pages/ai/hooks/useActiveRunAttach.test.ts | 27 +++++++++++++++++++++
web/src/pages/ai/hooks/useActiveRunAttach.ts | 25 +++++++++++++++----
6 files changed, 103 insertions(+), 11 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
index 3b5a83589..1aec07b2e 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunService.java
@@ -378,6 +378,13 @@ public class AiRunService {
}
if (registry.stop(run.getId(), AbortReason.USER_STOP)) {
log.info("agent run {} is being stopped at the user's request",
run.getId());
+ } else if (registry.handle(run.getId()).isPresent()) {
+ // This process owns the run and it is already aborting (a second
tab, a retried stop):
+ // registry.stop() returns false for that case as well as for an
unowned run, and its
+ // worker writes the terminal state itself. Writing one here would
append a second
+ // terminal row - a phantom empty bubble on reload - and close the
observers while the
+ // worker is still flushing its buffered text.
+ log.info("agent run {} is already stopping", run.getId());
} else {
// The row says active but nothing in this process owns it, so no
worker will ever write the
// terminal state. Writing it here is what keeps the stop button
from spinning until the
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
index 0629713c2..984273bab 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiRunServiceTest.java
@@ -320,12 +320,14 @@ class AiRunServiceTest {
service.sendMessage(CONVERSATION_ID, AiRunService.RunRequest.of("a
long answer"));
assertThat(started.await(5, TimeUnit.SECONDS)).isTrue();
assertThat(registry.isLive(RUN_ID)).isTrue();
+ // Capture the handle before stopping: stop() cancels the worker,
and the worker's own
+ // finalisation removes the registration, so looking it up again
afterwards races with
+ // that cleanup and is legitimately empty on a loaded machine.
+ AgentRunHandle handle = registry.handle(RUN_ID).orElseThrow();
RmqAiRun stopped = service.stop(RUN_ID);
- assertThat(registry.handle(RUN_ID))
- .get()
- .satisfies(handle ->
assertThat(handle.abortReason()).contains(AbortReason.USER_STOP));
+ assertThat(handle.abortReason()).contains(AbortReason.USER_STOP);
release.countDown();
assertThat(stopped.getId()).isEqualTo(RUN_ID);
assertThat(awaitRunStatus(RunStatus.STOPPED,
Duration.ofSeconds(5))).isTrue();
@@ -350,6 +352,26 @@ class AiRunServiceTest {
assertThat(inserted).isEmpty();
}
+ @Test
+ void secondStopOfAnAbortingRunShouldNotWriteItsOwnTerminalStateTest() {
+ RmqAiRun running = AiRunTestSupport.run(RUN_ID, CONVERSATION_ID, 1,
RunStatus.RUNNING);
+ when(runRepository.findById(RUN_ID)).thenReturn(Optional.of(running));
+ when(runRepository.findActiveByConversationId(CONVERSATION_ID))
+ .thenReturn(Optional.of(running));
+ // A worker in this process owns the run and is already aborting,
which is exactly what
+ // registry.stop() reports as false - the same value it returns for a
run nobody owns here.
+ AgentRunHandle handle = new AgentRunHandle(RUN_ID,
Duration.ofSeconds(3));
+ registry.register(RUN_ID, handle);
+ assertThat(handle.requestStop(AbortReason.USER_STOP)).isTrue();
+
+ service.stop(RUN_ID);
+
+ // The worker writes the terminal state itself; a fabricated one here
would append a second
+ // terminal row and close the observers mid-flush.
+ assertThat(runUpdates).isEmpty();
+ assertThat(inserted).isEmpty();
+ }
+
@Test
void reportSpeedShouldTouchOnlyTheSpeedColumnTest() {
// The client reports the speed as its stream closes, which also
happens mid-run when the
diff --git a/web/src/pages/ai/components/Composer.tsx
b/web/src/pages/ai/components/Composer.tsx
index 3ea68e9bf..4d3637134 100644
--- a/web/src/pages/ai/components/Composer.tsx
+++ b/web/src/pages/ai/components/Composer.tsx
@@ -59,12 +59,13 @@ import SendStopButton, { type SendStopState } from
'./SendStopButton';
* ```ts
* const generating = isStreaming && !stopRequested;
* state = stopRequested ? 'stopping' : generating ? 'stop' : 'send';
- * canSend = value.trim() && !disabled && !readOnlyRole && llmReady &&
!generating;
+ * canSend = value.trim() && !disabled && !readOnlyRole && llmReady &&
!isStreaming;
* ```
*
* `stopRequested` is what separates "the stop is on its way" from "the run is
still going": the stop
* is a server round-trip and the stream stays open to deliver the terminal
frames, so the button sits
- * in `stopping` until they arrive rather than flipping straight back to
`send`.
+ * in `stopping` until they arrive rather than flipping straight back to
`send`. `canSend` follows the
+ * whole streaming window, so the keyboard cannot submit into that same window.
*
* ─── Keyboard ──────────────────────────────────────────────────
* Enter sends, Shift+Enter inserts a newline, and Escape stops while
generating. The `isComposing`
@@ -247,7 +248,10 @@ const Composer = ({
const generating = isStreaming && !stopRequested;
const sendStopState: SendStopState = stopRequested ? 'stopping' : generating
? 'stop' : 'send';
- const canSend = !disabled && !readOnlyRole && llmReady && !generating &&
value.trim().length > 0;
+ // `isStreaming`, not `generating`: while the stop is still in flight the
button is in its
+ // disabled `stopping` state because the stream guard would refuse a new
run, and Enter must not
+ // be a hidden second way in - it used to clear the draft for a send that
silently never left.
+ const canSend = !disabled && !readOnlyRole && llmReady && !isStreaming &&
value.trim().length > 0;
const contextPercent = Math.min(100, Math.round((contextTokens /
CONTEXT_WINDOW_TOKENS) * 100));
const contextColor =
diff --git a/web/src/pages/ai/components/__tests__/Composer.test.tsx
b/web/src/pages/ai/components/__tests__/Composer.test.tsx
index cc8dce226..3c170d7b5 100644
--- a/web/src/pages/ai/components/__tests__/Composer.test.tsx
+++ b/web/src/pages/ai/components/__tests__/Composer.test.tsx
@@ -167,6 +167,23 @@ describe('Composer', () => {
expect(props.onSend).not.toHaveBeenCalled();
});
+ it('keepsTheDraftWhenEnterArrivesWhileTheStopIsStillInFlightTest', () => {
+ const { props } = renderComposer({
+ value: '检查集群状态',
+ isStreaming: true,
+ stopRequested: true,
+ });
+
+ // The run stays in flight until the terminal frames land, so a send
started now would be
+ // refused by the stream guard while the composer had already cleared the
box. Enter must not
+ // be a hidden second way to submit when the button next to it is disabled.
+ fireEvent.keyDown(textarea(), { key: 'Enter' });
+
+ expect(props.onSend).not.toHaveBeenCalled();
+ expect(props.onChange).not.toHaveBeenCalled();
+ expect(textarea()).toHaveValue('检查集群状态');
+ });
+
it('sendsOnEnterAndClearsTheDraftTest', () => {
const { props } = renderComposer({ value: '检查集群状态' });
diff --git a/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
b/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
index 6e4298831..458948389 100644
--- a/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
+++ b/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
@@ -74,4 +74,31 @@ describe('useActiveRunAttach', () => {
await waitFor(() => expect(attach).toHaveBeenCalledWith(9, 77, 0));
expect(attach).toHaveBeenCalledTimes(2);
});
+
+ it('reattachesWhenTheRunAdvancesPastTheAdmissionWindowItWasAttachedInTest',
async () => {
+ const attach = vi.fn().mockResolvedValue(undefined);
+ const { rerender } = renderHook(
+ ({ status }: { status: 'QUEUED' | 'RUNNING' }) =>
+ useActiveRunAttach(
+ 7,
+ { activeRun: { id: 42, status }, lastSeq: 0 },
+ { isStreaming: false, attach },
+ ),
+ { initialProps: { status: 'QUEUED' as 'QUEUED' | 'RUNNING' } },
+ );
+
+ // A reload during admission attaches to a QUEUED row that has no worker
yet: the server closes
+ // that stream with a bare `done` and no status, so nothing was observed.
+ await waitFor(() => expect(attach).toHaveBeenCalledWith(7, 42, 0));
+
+ // The run now has a worker. Without re-attaching, the answer streams with
nobody watching and
+ // the next send is refused with 409.
+ rerender({ status: 'RUNNING' });
+ await waitFor(() => expect(attach).toHaveBeenCalledTimes(2));
+
+ // The same state again is not new information, so the guard still holds
and nothing loops.
+ rerender({ status: 'RUNNING' });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(attach).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/web/src/pages/ai/hooks/useActiveRunAttach.ts
b/web/src/pages/ai/hooks/useActiveRunAttach.ts
index ce4f51764..ab46c8db6 100644
--- a/web/src/pages/ai/hooks/useActiveRunAttach.ts
+++ b/web/src/pages/ai/hooks/useActiveRunAttach.ts
@@ -27,11 +27,14 @@ import type { UseConversationTimelineResult } from
'./useConversationTimeline';
* that row into exactly one `attach(conversationId, runId, lastSeq)` per run
id per visit — `lastSeq`
* being the replay cursor that keeps the attach from rendering persisted rows
twice.
*
- * ─── One attempt per run id per visit ───────────────────────────
+ * ─── One attempt per run state per visit ────────────────────────
* `requestedRunRef` is what stands between a dead attach (idle timeout,
server restart mid-run) and
* a hot re-attach loop: the failure surfaces through the run's error state
and a reload retries.
* The ref is cleared when the conversation changes, so navigating away
mid-run and BACK re-attaches
- * to the same run id — which is the whole point of the exercise.
+ * to the same run id — which is the whole point of the exercise. It also lets
a run that has moved
+ * on from the state this visit attached to be attached once more: a reload
during admission observes
+ * QUEUED, whose stream closes immediately because no worker exists yet, and
the run only becomes
+ * observable when the timeline reports it RUNNING.
*
* The reset effect is declared before the attach effect on purpose: within
one commit effects run
* in declaration order, and arriving at a conversation must clear the
previous visit's marker
@@ -42,7 +45,12 @@ export function useActiveRunAttach(
timeline: Pick<UseConversationTimelineResult, 'activeRun' | 'lastSeq'>,
run: Pick<UseAgentRunResult, 'isStreaming' | 'attach'>,
): void {
- const requestedRunRef = useRef<number | null>(null);
+ // What the last attach observed, not just which run: a reload during
admission attaches to a
+ // QUEUED row that has no worker yet, the server closes that stream with a
bare `done`, and the run
+ // then starts generating with nobody watching. Remembering the status too
lets the next timeline
+ // fetch - which reports the same run as RUNNING - attach again, while a
repeated identical status
+ // still cannot loop.
+ const requestedRunRef = useRef<{ id: number; status: string } | null>(null);
const { activeRun, lastSeq } = timeline;
const { isStreaming, attach } = run;
@@ -53,8 +61,15 @@ export function useActiveRunAttach(
useEffect(() => {
if (conversationId === null || activeRun === null || isStreaming) return;
if (activeRun.status !== 'QUEUED' && activeRun.status !== 'RUNNING')
return;
- if (requestedRunRef.current === activeRun.id) return;
- requestedRunRef.current = activeRun.id;
+ const requested = requestedRunRef.current;
+ if (
+ requested !== null &&
+ requested.id === activeRun.id &&
+ requested.status === activeRun.status
+ ) {
+ return;
+ }
+ requestedRunRef.current = { id: activeRun.id, status: activeRun.status };
void attach(conversationId, activeRun.id, lastSeq);
}, [activeRun, attach, conversationId, isStreaming, lastSeq]);
}