mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
preflight selected queued Goals before agent execution
This commit is contained in:
parent
59d66a673e
commit
3a7bbe4645
@ -79,7 +79,7 @@ public record ChatOrigin(
|
||||
@Nullable Long requesterUserId,
|
||||
@Nullable Long originMessageId,
|
||||
@Nullable ExecutionAttribution executionAttribution,
|
||||
/** Goal selected when approval was created: null=legacy unknown, 0=observed unselected. */
|
||||
/** Managed Goal captured for this turn: null=legacy unknown, 0=observed unselected. */
|
||||
@Nullable Long selectedGoalId
|
||||
) {
|
||||
|
||||
|
||||
@ -1539,32 +1539,23 @@ public class ChatController {
|
||||
// ended. Keep the user's text and require a fresh authenticated turn.
|
||||
if (preConsumedInput.selectedGoalId() == null && goalApprovalRuns != null
|
||||
&& goalApprovalRuns.hasManagedGoalHistory(conversationId, String.valueOf(agentId))) {
|
||||
if (preConsumedInput.persistedMessageId() == null) {
|
||||
MessageEntity saved = conversationService.saveMessage(conversationId, "user",
|
||||
preConsumedInput.message(), preConsumedInput.contentParts(), "queued");
|
||||
if (saved == null || !inputQueue.bindMessage(preConsumedInput.id(), queueClaimId,
|
||||
saved.getId(), LocalDateTime.now())) {
|
||||
inputQueue.release(preConsumedInput.id(), queueClaimId, LocalDateTime.now());
|
||||
throw new IllegalStateException("Legacy queued input could not be preserved");
|
||||
}
|
||||
}
|
||||
if (!inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now()))
|
||||
throw new IllegalStateException("Legacy queued input claim was lost");
|
||||
broadcastEvent(conversationId, "warning", Map.of(
|
||||
"message", "排队消息缺少Goal选择快照,内容已保存,请重新发送"));
|
||||
broadcastEvent(conversationId, "queued_input_skipped", Map.of(
|
||||
"conversationId", conversationId,
|
||||
"message", preConsumedInput.message() == null ? "" : preConsumedInput.message(),
|
||||
"reason", "managed_goal_selection_unknown"));
|
||||
if (hasQueuedInput(conversationId)) {
|
||||
sseExecutor.execute(() -> startQueuedMessage(conversationId, emitter, emitterDone,
|
||||
requesterId, baseUrl));
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
}
|
||||
skipQueuedInput(preConsumedInput, queueClaimId, conversationId, emitter, emitterDone,
|
||||
requesterId, baseUrl, "managed_goal_selection_unknown",
|
||||
"排队消息缺少Goal选择快照,内容已保存,请重新发送");
|
||||
return;
|
||||
}
|
||||
if (preConsumedInput.selectedGoalId() != null && preConsumedInput.selectedGoalId() > 0) {
|
||||
var selectedOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId,
|
||||
preConsumedInput.createdBy(), queuedConversation.getWorkspaceId(), null,
|
||||
baseUrl, preConsumedInput.requesterUserId())
|
||||
.withAgent(agentId).withSelectedGoalId(preConsumedInput.selectedGoalId());
|
||||
if (goalApprovalRuns == null || !goalApprovalRuns.queuedSelectionStillCurrent(selectedOrigin)) {
|
||||
skipQueuedInput(preConsumedInput, queueClaimId, conversationId, emitter, emitterDone,
|
||||
requesterId, baseUrl, "managed_goal_selection_stale",
|
||||
"排队消息的Goal或账户已失效,内容已保存,请重新发送");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate Limit 防护:如果上一轮以 rate limit 错误结束,不立即续跑排队消息(必然再次 429)。
|
||||
// 改为持久化用户消息 + 通知前端"稍后重试",避免连锁 429 浪费配额。
|
||||
@ -1760,6 +1751,42 @@ public class ChatController {
|
||||
() -> emergencySaveAccumulator(conversationId, accumulator));
|
||||
}
|
||||
|
||||
private void skipQueuedInput(ConversationInputQueueStore.QueuedInput input, String claimId,
|
||||
String conversationId, SseEmitter emitter, AtomicBoolean emitterDone,
|
||||
String requesterId, String baseUrl, String reason, String warning) {
|
||||
if (input.persistedMessageId() == null) {
|
||||
MessageEntity saved = conversationService.saveMessage(conversationId, "user",
|
||||
input.message(), input.contentParts(), "queued");
|
||||
if (saved == null || !inputQueue.bindMessage(input.id(), claimId,
|
||||
saved.getId(), LocalDateTime.now())) {
|
||||
inputQueue.release(input.id(), claimId, LocalDateTime.now());
|
||||
throw new IllegalStateException("Skipped queued input could not be preserved");
|
||||
}
|
||||
}
|
||||
if (!inputQueue.consume(input.id(), claimId, LocalDateTime.now()))
|
||||
throw new IllegalStateException("Skipped queued input claim was lost");
|
||||
// The preceding turn may have removed its RunState already, so a
|
||||
// tracker broadcast can silently disappear. The held emitter is the
|
||||
// authoritative response for this queued input.
|
||||
try {
|
||||
sendEvent(emitter, "warning", Map.of("message", warning));
|
||||
sendEvent(emitter, "queued_input_skipped", Map.of(
|
||||
"conversationId", conversationId,
|
||||
"message", input.message() == null ? "" : input.message(),
|
||||
"reason", reason));
|
||||
} catch (IOException disconnected) {
|
||||
log.debug("Queued-input skip notification could not be delivered for {}: {}",
|
||||
conversationId, disconnected.getMessage());
|
||||
}
|
||||
if (hasQueuedInput(conversationId)) {
|
||||
sseExecutor.execute(() -> startQueuedMessage(conversationId, emitter, emitterDone,
|
||||
requesterId, baseUrl));
|
||||
} else {
|
||||
conversationService.updateStreamStatus(conversationId, "idle");
|
||||
completeEmitterQuietly(emitter, emitterDone);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasQueuedInput(String conversationId) {
|
||||
return inputQueue.countQueued(conversationId) > 0;
|
||||
}
|
||||
|
||||
@ -53,6 +53,24 @@ public class GoalApprovalRunService {
|
||||
return origin.withSelectedGoalId(selected.getFirst());
|
||||
}
|
||||
|
||||
/** A queued selected turn must still have its original Goal and account before execution starts. */
|
||||
public boolean queuedSelectionStillCurrent(ChatOrigin origin) {
|
||||
if (origin == null || origin.selectedGoalId() == null || origin.selectedGoalId() <= 0
|
||||
|| origin.requesterUserId() == null || origin.requesterId() == null)
|
||||
return false;
|
||||
try {
|
||||
return acceptance.withAuthenticatedUser(origin.requesterUserId(), origin.requesterId(), current -> {
|
||||
var scope = acceptance.authorizedGoal(origin.selectedGoalId(), current, true);
|
||||
return scope.required() && java.util.List.of("active", "paused").contains(scope.status())
|
||||
&& Objects.equals(scope.conversationId(), origin.conversationId())
|
||||
&& Objects.equals(scope.workspaceId(), origin.workspaceId())
|
||||
&& Objects.equals(scope.agentId(), origin.agentId());
|
||||
});
|
||||
} catch (vip.mate.exception.MateClawException stale) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean requiresHandoff(ChatOrigin origin) {
|
||||
var link = origin == null ? null : origin.executionAttribution();
|
||||
if (link == null || link.goalId() == null || link.approvalId() == null) return false;
|
||||
|
||||
@ -59,7 +59,7 @@ Built-in shell/code execution is not OS-isolated from the service host. Selectin
|
||||
|
||||
Recovery attempts receive guidance to inspect existing evidence before repeating work. If the first recovered segment is deferred before execution, its recovery context is retained for the next claim. Ordinary continuation after an executed segment does not become a new recovery.
|
||||
|
||||
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. A queued turn keeps that identity if the Goal ends before dequeue; approval then refuses to execute the stale selection. An old queue row without a selection snapshot is saved as user text and requires a fresh request when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check.
|
||||
From V199, queued Web input stores the authenticated account ID at enqueue time, and ordinary Web replay carries the conversation workspace. V201 also stores the selected managed Goal ID at enqueue time. Before a selected queued turn starts, the server rechecks its account and Goal. If either is no longer current, it saves the user text and asks for a fresh request without starting the agent. An old queue row without a selection snapshot is handled the same way when its conversation has managed Goal history. The chat interface clears that queued item and prompts the user to resend; later queued items continue. Managed operations still recheck the account, ownership and current requirements. Legacy queue items do not gain an asserted identity from a username; users must resend an authenticated request for managed JSON operations. Persistent Goal workers retain their existing attempt-owner validation when consuming input; this does not introduce an account path without a lease check.
|
||||
|
||||
Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone.
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效,
|
||||
|
||||
内置 shell/code 执行没有与服务宿主做操作系统隔离。选择 JSON 验收不会把这些工具变成沙箱;此协议不能抵抗能访问数据库凭据或文件的宿主代码,环境变量名称过滤和工作区路径检查也不能替代隔离。租约截止从绝对时刻计算,覆盖夏令时回拨;调度显示字段仍使用本地时间戳。
|
||||
|
||||
Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;V201还在入队时保存选定的受管Goal ID。若Goal在出队前终结,排队请求仍保留其身份,随后批准会拒绝旧选择。升级前没有选择快照的队列行,若会话有受管Goal历史,就保存为用户文字并要求重新发送。聊天页面会移除这条排队状态并提示重新发送,后续排队消息继续处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验,没有转换成免租约的账户路径。
|
||||
Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;V201还在入队时保存选定的受管Goal ID。选定Goal的排队消息在开始执行前复查账户与Goal;若已失效,只保存用户文字并提示重新发送,不启动Agent。升级前没有选择快照的队列行,若会话有受管Goal历史,也按此方式处理。聊天页面会移除这条排队状态并提示重新发送,后续排队消息继续处理。受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验,没有转换成免租约的账户路径。
|
||||
|
||||
恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。
|
||||
|
||||
|
||||
@ -80,9 +80,13 @@ class ChatControllerDurableQueueTest {
|
||||
when(conversations.findByConversationId("conv")).thenReturn(conversation);
|
||||
when(agents.chatStructuredStream(eq(2L), eq("queued"), eq("conv"), any(), any(), any()))
|
||||
.thenReturn(reactor.core.publisher.Flux.never());
|
||||
var runs = mock(vip.mate.goal.service.GoalApprovalRunService.class);
|
||||
when(runs.queuedSelectionStillCurrent(any())).thenReturn(true);
|
||||
when(runs.captureSelectedGoal(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
ChatController controller = new ChatController(agents, conversations, mock(ApprovalWorkflowService.class), streams,
|
||||
new ObjectMapper(), mock(ConversationCompletionPublisher.class), mock(MemoryOwnerResolver.class),
|
||||
mock(ChatUploadLocationResolver.class), mock(OfficePreviewService.class), queue);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(controller, "goalApprovalRuns", runs);
|
||||
org.springframework.test.util.ReflectionTestUtils.invokeMethod(controller, "startQueuedMessage", "conv",
|
||||
new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(),
|
||||
new java.util.concurrent.atomic.AtomicBoolean(true), "previous-turn-user", "http://localhost");
|
||||
@ -121,15 +125,15 @@ class ChatControllerDurableQueueTest {
|
||||
mock(OfficePreviewService.class), queue);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(controller, "goalApprovalRuns", runs);
|
||||
|
||||
var emitter = new RecordingEmitter();
|
||||
org.springframework.test.util.ReflectionTestUtils.invokeMethod(controller, "startQueuedMessage", "conv",
|
||||
new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(),
|
||||
emitter,
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false), "alice", "http://localhost");
|
||||
|
||||
org.mockito.Mockito.verify(conversations).saveMessage("conv", "user", "old queued text", List.of(), "queued");
|
||||
org.mockito.Mockito.verify(queue).bindMessage(eq(92L), any(), eq(101L), any());
|
||||
org.mockito.Mockito.verify(queue).consume(eq(92L), any(), any());
|
||||
org.mockito.Mockito.verify(streams).broadcast(eq("conv"), eq("queued_input_skipped"),
|
||||
org.mockito.ArgumentMatchers.contains("old queued text"));
|
||||
assertThat(emitter.events.toString()).contains("queued_input_skipped", "old queued text");
|
||||
org.mockito.Mockito.verifyNoInteractions(agents);
|
||||
}
|
||||
|
||||
@ -173,4 +177,13 @@ class ChatControllerDurableQueueTest {
|
||||
org.mockito.Mockito.verify(queue).consume(eq(93L), any(), any());
|
||||
}
|
||||
|
||||
private static final class RecordingEmitter extends org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
|
||||
private final StringBuilder events = new StringBuilder();
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) {
|
||||
builder.build().forEach(part -> events.append(part.getData()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -381,6 +381,15 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", message));
|
||||
}
|
||||
JsonNode pending = request("GET", "/api/v1/chat/" + conversation + "/pending-approvals", token, null).path("data");
|
||||
if (queuedTerminal) {
|
||||
assertEquals(0, pending.size(), waiting);
|
||||
assertTrue(waiting.contains("queued_input_skipped"), waiting);
|
||||
assertEquals(0, calls.get(), "A terminal queued Goal must not invoke the model");
|
||||
assertEquals(GoalStatus.ABANDONED, goals.getById(goal.getId()).getStatus());
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_tool_approval WHERE conversation_id=?", Integer.class, conversation));
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
|
||||
return;
|
||||
}
|
||||
assertEquals(1, pending.size(), waiting);
|
||||
String pendingId = pending.get(0).path("pendingId").asText();
|
||||
assertEquals("getManagedGoalJsonSlots", pending.get(0).path("toolName").asText());
|
||||
|
||||
@ -98,7 +98,7 @@ export interface UseChatOptions {
|
||||
*/
|
||||
onStreamEnd?: (meta: StreamEndMeta) => void
|
||||
/** A legacy queued message was saved as text but needs a fresh request. */
|
||||
onQueuedInputSkipped?: () => void
|
||||
onQueuedInputSkipped?: (reason: string) => void
|
||||
}
|
||||
|
||||
/** Metadata emitted when a stream ends */
|
||||
@ -1857,7 +1857,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
createUserMessage(content, queued?.contentParts, data.conversationId || streamConversationId)
|
||||
}
|
||||
streamPhase.value = messageQueue.hasQueued.value ? 'queued' : 'idle'
|
||||
onQueuedInputSkipped?.()
|
||||
onQueuedInputSkipped?.(data.reason || '')
|
||||
})
|
||||
|
||||
// ===== Async task completion events (video / image / music generation) =====
|
||||
|
||||
@ -640,6 +640,7 @@ export default {
|
||||
queuedReplace: 'Message queued. Press Enter to replace...',
|
||||
queuedBadge: '{count} queued',
|
||||
queuedLegacyResend: 'Queued message was saved, but its Goal selection is unknown. Please send it again.',
|
||||
queuedSelectionStaleResend: 'Queued message was saved, but its Goal or account is no longer available. Please send it again.',
|
||||
// Stream status
|
||||
streamStopAction: 'Stop generation',
|
||||
streamQueueAction: 'Send after current response',
|
||||
|
||||
@ -640,6 +640,7 @@ export default {
|
||||
queuedReplace: '消息已排队,按回车替换...',
|
||||
queuedBadge: '{count} 条排队',
|
||||
queuedLegacyResend: '排队消息已保存,但无法确认原来的目标选择。请重新发送。',
|
||||
queuedSelectionStaleResend: '排队消息已保存,但原目标或账户已失效。请重新发送。',
|
||||
// 流状态
|
||||
streamStopAction: '停止生成',
|
||||
streamQueueAction: '当前回复结束后发送',
|
||||
|
||||
@ -848,7 +848,8 @@ const {
|
||||
}
|
||||
}
|
||||
},
|
||||
onQueuedInputSkipped: () => mcToast.warning(t('chat.queuedLegacyResend')),
|
||||
onQueuedInputSkipped: (reason) => mcToast.warning(t(reason === 'managed_goal_selection_stale'
|
||||
? 'chat.queuedSelectionStaleResend' : 'chat.queuedLegacyResend')),
|
||||
})
|
||||
|
||||
const teamRunRouteQuery = computed(() => readTeamRunRouteQuery(route.query))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user