diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java index 867af4a1..291d60cb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java @@ -12,6 +12,7 @@ import vip.mate.audit.service.AuditEventService; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; +import vip.mate.i18n.I18nService; import vip.mate.workspace.conversation.ConversationService; import java.util.LinkedHashMap; @@ -37,6 +38,7 @@ public class AgentRuntimeController { private final SubagentRegistry subagentRegistry; private final AuditEventService auditEventService; private final ConversationService conversationService; + private final I18nService i18nService; @Operation(summary = "Snapshot of every in-flight agent turn") @GetMapping("/snapshot") @@ -62,16 +64,7 @@ public class AgentRuntimeController { requireAdmin(auth); boolean ok = streamTracker.forceRecycle(conversationId); if (ok) { - // Flip stream_status off 'running' so the sidebar drops the - // 生成中 badge immediately. The downstream doOnCancel / - // doOnComplete still fires later (when the agent yields) and may - // re-set this to 'idle' — same value, no-op. - try { - conversationService.updateStreamStatus(conversationId, "idle"); - } catch (Exception e) { - log.warn("recycle: failed to reset stream_status for {}: {}", - conversationId, e.getMessage()); - } + finalizeRecycledConversation(conversationId); } recordAudit(auth, "agent-runtime.recycle", conversationId, Map.of("result", ok)); return R.ok(Map.of("recycled", ok)); @@ -105,12 +98,7 @@ public class AgentRuntimeController { for (String cid : ids) { if (streamTracker.forceRecycle(cid)) { recycled++; - try { - conversationService.updateStreamStatus(cid, "idle"); - } catch (Exception e) { - log.warn("sweep: failed to reset stream_status for {}: {}", - cid, e.getMessage()); - } + finalizeRecycledConversation(cid); } } recordAudit(auth, "agent-runtime.sweep", "all", @@ -118,6 +106,36 @@ public class AgentRuntimeController { return R.ok(Map.of("recycled", recycled, "ids", ids)); } + /** + * Common DB-side cleanup after a successful {@code forceRecycle}: + *
    + *
  1. Flip {@code stream_status} off 'running' so the sidebar drops the + * 生成中 badge immediately. (The late doOnCancel / doOnComplete may + * re-set this to 'idle' when the agent finally yields — same value, + * no-op.)
  2. + *
  3. If the conversation's last message is still a user turn — i.e. + * the agent was disposed before any text streamed and the + * emergencySaveCallback found nothing to persist — write a + * "已被用户中止" assistant marker so the UI shows what happened + * instead of a blank reply.
  4. + *
+ */ + private void finalizeRecycledConversation(String conversationId) { + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception e) { + log.warn("recycle: failed to reset stream_status for {}: {}", + conversationId, e.getMessage()); + } + try { + conversationService.saveStopMarkerIfDangling( + conversationId, i18nService.msg("chat.stopMarker.userAborted"), "stopped"); + } catch (Exception e) { + log.warn("recycle: failed to save stop marker for {}: {}", + conversationId, e.getMessage()); + } + } + private void requireAdmin(Authentication auth) { if (auth == null) { throw new MateClawException(401, "authentication required"); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 9b184983..b3aa64fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -308,7 +308,22 @@ public class ChatController { }) .doOnComplete(() -> { if (!finalized.compareAndSet(false, true)) return; - // RFC-067 §4.6: replay can re-trigger an approval (the approved tool + // Force-recycle short-circuit: see main doOnComplete below. + if (streamTracker.isRecycled(conversationId)) { + log.info("SSE replay doOnComplete skipped for force-recycled conversation: {}", conversationId); + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception e) { + log.debug("recycled-skip: stream_status reset failed for {}: {}", + conversationId, e.getMessage()); + } + ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); + if (cr.allDone()) { + completeEmitterQuietly(emitter, approvalEmitterDone); + } + return; + } + // Replay can re-trigger an approval (the approved tool // call may chain into another guarded tool). Derive status the same // way as the normal stream so awaiting_approval doesn't get masked // as completed. @@ -356,6 +371,22 @@ public class ChatController { }) .doOnError(e -> { if (!finalized.compareAndSet(false, true)) return; + // Force-recycle short-circuit: see main doOnComplete below. + if (streamTracker.isRecycled(conversationId)) { + log.info("SSE replay doOnError skipped for force-recycled conversation: {}, cause={}", + conversationId, e.getMessage()); + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception ex) { + log.debug("recycled-skip: stream_status reset failed for {}: {}", + conversationId, ex.getMessage()); + } + ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); + if (cr.allDone()) { + completeEmitterQuietly(emitter, approvalEmitterDone); + } + return; + } boolean isUserStop = e instanceof java.util.concurrent.CancellationException || (e.getCause() instanceof java.util.concurrent.CancellationException); @@ -503,6 +534,36 @@ public class ChatController { }) .doOnComplete(() -> { if (!finalized.compareAndSet(false, true)) return; + // Force-recycle: the recycle path already wrote a + // "[已被用户中止]" placeholder (or the partial + // content via emergencySave). The agent's flux may + // have completed the same millisecond — skip its + // save + broadcast so we don't append a duplicate + // assistant row below the placeholder. Cleanup + // still runs so queue draining + emitter close + // happen normally. + if (streamTracker.isRecycled(conversationId)) { + log.info("SSE doOnComplete skipped for force-recycled conversation: {}", conversationId); + streamTracker.clearInterruptState(conversationId); + // Defensive: keep DB stream_status consistent with the + // "this turn is over" reality even when we skip the + // save. Force-recycle's controller path already wrote + // 'idle' for the recycled run, so this is normally a + // no-op — but if a register() ever fails to clear the + // marker (e.g. a different turn snuck through), this + // prevents the row leaking at 'running' across refresh. + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception e) { + log.debug("recycled-skip: stream_status reset failed for {}: {}", + conversationId, e.getMessage()); + } + ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); + if (cr.allDone()) { + completeEmitterQuietly(emitter, emitterDone); + } + return; + } // 区分四种完成语义: // 1. 正常完成(stopRequested=false)→ completed // 2. 用户主动停止 → stopped @@ -609,6 +670,22 @@ public class ChatController { boolean wasFirst = finalized.compareAndSet(false, true); log.info("SSE doOnCancel fired: conversationId={}, wasFirst={}", conversationId, wasFirst); if (!wasFirst) return; + // Force-recycle short-circuit: see doOnComplete above. + if (streamTracker.isRecycled(conversationId)) { + log.info("SSE doOnCancel skipped for force-recycled conversation: {}", conversationId); + streamTracker.clearInterruptState(conversationId); + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception e) { + log.debug("recycled-skip: stream_status reset failed for {}: {}", + conversationId, e.getMessage()); + } + ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); + if (cr.allDone()) { + completeEmitterQuietly(emitter, emitterDone); + } + return; + } // 区分用户主动停止和 interrupt-with-followup ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId); boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP; @@ -676,6 +753,23 @@ public class ChatController { log.info("SSE doOnError skipped (finalized by doOnCancel): conversationId={}", conversationId); return; } + // Force-recycle short-circuit: see doOnComplete above. + if (streamTracker.isRecycled(conversationId)) { + log.info("SSE doOnError skipped for force-recycled conversation: {}, cause={}", + conversationId, e.getMessage()); + streamTracker.clearInterruptState(conversationId); + try { + conversationService.updateStreamStatus(conversationId, "idle"); + } catch (Exception ex) { + log.debug("recycled-skip: stream_status reset failed for {}: {}", + conversationId, ex.getMessage()); + } + ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); + if (cr.allDone()) { + completeEmitterQuietly(emitter, emitterDone); + } + return; + } // CancellationException = 用户主动停止或中断续跑 boolean isUserStop = e instanceof java.util.concurrent.CancellationException diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index d0f32f3c..640334c7 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -233,6 +233,22 @@ public class ChatStreamTracker { private final ConcurrentHashMap runs = new ConcurrentHashMap<>(); + /** + * Conversations whose run was force-recycled by an admin. Maps to the + * recycle timestamp so a scheduled cleanup can age entries out (TTL + * matches {@link #DONE_RETENTION_MS} — long enough that any in-flight + * doOnComplete / doOnError firing after the dispose still finds the + * marker, short enough not to leak across sessions). + *

+ * Read by the SSE doOn* handlers in ChatController to skip a duplicate + * saveMessage when the recycle path already wrote the "[已被用户中止]" + * placeholder. Without this, the agent's late-yielding doOnComplete + * inserts a second assistant row carrying whatever the agent produced + * after the user pressed stop — exactly the behavior the user does + * not want when force-recycling. + */ + private final ConcurrentHashMap recycledConversations = new ConcurrentHashMap<>(); + /** 事件 relay:子会话事件转发到父会话(用于 Agent 委派进度可见性) */ private final ConcurrentHashMap>> eventRelays = new ConcurrentHashMap<>(); @@ -451,6 +467,15 @@ public class ChatStreamTracker { log.info("[ChatStreamTracker] Reset stale stopRequested on register: {}", conversationId); } } + // Clear the force-recycle marker on new registration — the recycle + // tombstone is meant to suppress the late doOnComplete of the + // *recycled* run only, not future turns on the same conversation. If + // the user re-prompts ("继续", "重试", a new question, etc.) inside + // the 5-min TTL, this turn must be allowed to save its assistant + // message normally. + if (recycledConversations.remove(conversationId) != null) { + log.info("[ChatStreamTracker] Cleared recycle marker on new register: {}", conversationId); + } startHeartbeat(conversationId); log.debug("Stream registered: {}", conversationId); } @@ -506,6 +531,23 @@ public class ChatStreamTracker { return state != null && state.stopRequested.get(); } + /** + * Whether this conversation was force-recycled by an admin within the + * recycle marker's TTL ({@link #DONE_RETENTION_MS}). The SSE doOn* + * handlers consult this to skip a duplicate saveMessage when the recycle + * path already wrote the placeholder. Survives {@code runs.remove(...)}, + * unlike {@link #isStopRequested(String)}. + */ + public boolean isRecycled(String conversationId) { + Long ts = recycledConversations.get(conversationId); + if (ts == null) return false; + if (System.currentTimeMillis() - ts > DONE_RETENTION_MS) { + recycledConversations.remove(conversationId); + return false; + } + return true; + } + /** * 广播事件到所有订阅者并缓存到 buffer. *

@@ -1454,6 +1496,11 @@ public class ChatStreamTracker { log.info("[SSE] Cleanup completed: evicted {} stale RunState entries, {} remaining", evicted, runs.size()); } + + // Age out the recycled-marker map alongside RunState cleanup. Same + // 5-minute retention so a delayed doOnComplete still hits the marker + // while we don't keep entries around forever. + recycledConversations.entrySet().removeIf(e -> now - e.getValue() > DONE_RETENTION_MS); } /** @@ -1603,6 +1650,10 @@ public class ChatStreamTracker { public boolean forceRecycle(String conversationId) { RunState state = runs.get(conversationId); if (state == null) return false; + // Mark BEFORE dispose so a doOnComplete that fires the same millisecond + // (the upstream agent flux had already buffered/completed concurrently + // with the dispose call) sees the recycled flag and skips its save. + recycledConversations.put(conversationId, System.currentTimeMillis()); // Persist any partial assistant content first — dispose() only severs // the downstream subscription, the agent's worker thread keeps running // and may not yield for minutes. Without this, the conversation row diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 06128861..896bd77c 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -307,6 +307,30 @@ public class ConversationService { } } + /** + * Persist an assistant placeholder marker only when the last message is a + * user turn (i.e., the assistant never got to reply). Used by the admin + * force-recycle path so a torn-down turn leaves a visible "已被用户中止" + * marker instead of an empty conversation. Idempotent: if the previous + * emergency-save path already wrote an assistant row, this is a no-op. + * + * @return the saved message, or {@code null} if the marker was not needed + */ + @Transactional + public MessageEntity saveStopMarkerIfDangling(String conversationId, String markerText, String status) { + List recent = messageMapper.selectList( + new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .orderByDesc(MessageEntity::getCreateTime) + .orderByDesc(MessageEntity::getId) + .last("LIMIT 1")); + if (recent.isEmpty()) return null; + MessageEntity last = recent.get(0); + if (!"user".equals(last.getRole())) return null; + return saveMessage(conversationId, "assistant", markerText, null, + status != null ? status : "stopped"); + } + /** * 获取会话最后一条消息内容(用于 rate limit 防护等场景) */ diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 44084e22..f10d47ca 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -280,3 +280,6 @@ err.wiki.vision.disabled=图片识别功能未启用 err.wiki.vision.no_provider=未配置可用的图片识别 provider err.wiki.vision.provider_failed=图片识别 provider 调用失败 err.wiki.vision.all_failed=所有图片识别 provider 调用失败 + +# --- Chat: assistant stop / interrupt placeholders --- +chat.stopMarker.userAborted=[已被用户中止] diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 8b6ff068..60de88b0 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -287,3 +287,6 @@ err.wiki.vision.disabled=Image vision pipeline is currently disabled err.wiki.vision.no_provider=No image vision provider is configured err.wiki.vision.provider_failed=Image vision provider call failed err.wiki.vision.all_failed=All image vision providers failed + +# --- Chat: assistant stop / interrupt placeholders --- +chat.stopMarker.userAborted=[Stopped by user]