feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复
落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查), 并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+82
-3
@@ -1,7 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { isRunStreamReplayEnabled } from './agent-run-stream.mjs';
|
||||
import { isDirectChatSessionId } from './direct-chat-service.mjs';
|
||||
import { CHAT_INTENT_ROUTE, resolveLegacyRouteFromClassification } from './chat-intent-router.mjs';
|
||||
import { resolveSessionAccess } from './session-broker.mjs';
|
||||
import {
|
||||
loadSnapshotMessages,
|
||||
persistSessionTranscriptFromSnapshot,
|
||||
@@ -226,6 +229,7 @@ function projectRun(row) {
|
||||
export function createAgentRunGateway({
|
||||
pool,
|
||||
userAuth,
|
||||
sessionAccess = null,
|
||||
tkmindProxy,
|
||||
toolGateway = null,
|
||||
directChatService = null,
|
||||
@@ -247,6 +251,7 @@ export function createAgentRunGateway({
|
||||
DEFAULT_RUN_HEARTBEAT_MS,
|
||||
),
|
||||
}) {
|
||||
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
|
||||
const inFlight = new Set();
|
||||
const queuedDispatches = [];
|
||||
const queuedDispatchSet = new Set();
|
||||
@@ -278,6 +283,13 @@ export function createAgentRunGateway({
|
||||
);
|
||||
}
|
||||
|
||||
async function appendRunSnapshot(runId) {
|
||||
if (!isRunStreamReplayEnabled()) return;
|
||||
const row = await getRunById(runId);
|
||||
if (!row) return;
|
||||
await appendEvent(runId, 'run_snapshot', { run: projectRun(row) });
|
||||
}
|
||||
|
||||
async function getRunById(runId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`,
|
||||
@@ -367,6 +379,7 @@ export function createAgentRunGateway({
|
||||
values,
|
||||
);
|
||||
await appendEvent(runId, status, fields);
|
||||
await appendRunSnapshot(runId);
|
||||
}
|
||||
|
||||
function startRunHeartbeat(runId, { attempt }) {
|
||||
@@ -422,6 +435,17 @@ export function createAgentRunGateway({
|
||||
await sessionSnapshotService.remove(sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
async function assertOwnedAgentSession(userId, sessionId) {
|
||||
if (!sessionStore.enabled || !sessionId || isDirectChatSessionId(sessionId)) return;
|
||||
const owns = await sessionStore.validateOwnership(userId, sessionId);
|
||||
if (!owns) {
|
||||
const err = new Error('无权访问该会话');
|
||||
err.code = 'SESSION_FORBIDDEN';
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRunRouting(row, userMessage, runOptions) {
|
||||
if (!chatIntentRouter?.classify) return null;
|
||||
const enabled = chatIntentRouter.isEnabled
|
||||
@@ -445,16 +469,16 @@ export function createAgentRunGateway({
|
||||
const runOptions = getRunOptionsFromMessage(userMessage);
|
||||
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
|
||||
const routing = await resolveRunRouting(row, userMessage, runOptions);
|
||||
const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null;
|
||||
if (routing) {
|
||||
await appendEvent(runId, 'intent_routed', routing);
|
||||
if (routing.route === 'agent_orchestration' && chatIntentRouter?.applyAgentOrchestration) {
|
||||
if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) {
|
||||
const grantedSkills = await resolveGrantedSkills(row.user_id);
|
||||
userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { grantedSkills });
|
||||
}
|
||||
}
|
||||
const routingDecision = routing?.route ?? null;
|
||||
const preferDirectChat =
|
||||
routingDecision === 'direct_chat' ||
|
||||
routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT ||
|
||||
(isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning);
|
||||
const directChatInput = {
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
@@ -487,6 +511,7 @@ export function createAgentRunGateway({
|
||||
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
|
||||
[activeSessionId, nowMs(), runId],
|
||||
);
|
||||
await appendRunSnapshot(runId);
|
||||
},
|
||||
});
|
||||
await appendEvent(runId, 'direct_chat_completed', {
|
||||
@@ -495,6 +520,7 @@ export function createAgentRunGateway({
|
||||
model: result.model ?? null,
|
||||
billed: Boolean(result.billing?.ok),
|
||||
});
|
||||
await appendRunSnapshot(runId);
|
||||
return { sessionId: result.sessionId };
|
||||
} catch (err) {
|
||||
await appendEvent(runId, 'direct_chat_failed', {
|
||||
@@ -577,6 +603,7 @@ export function createAgentRunGateway({
|
||||
toolMode: runOptions.toolMode,
|
||||
taskType: runOptions.taskType,
|
||||
});
|
||||
await appendRunSnapshot(runId);
|
||||
if (escalatedDirectSessionId) {
|
||||
const priorMessages = await loadSnapshotMessages(
|
||||
sessionSnapshotService,
|
||||
@@ -596,6 +623,8 @@ export function createAgentRunGateway({
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await assertOwnedAgentSession(row.user_id, sessionId);
|
||||
}
|
||||
|
||||
const transcriptPersisted = await persistSessionTranscriptFromSnapshot({
|
||||
@@ -895,9 +924,59 @@ export function createAgentRunGateway({
|
||||
};
|
||||
}
|
||||
|
||||
async function listRunEventsForUser(userId, runId, { afterEventId = null, limit = 500 } = {}) {
|
||||
const run = await getRunForUser(userId, runId);
|
||||
if (!run) return null;
|
||||
|
||||
let afterCreatedAt = null;
|
||||
let cursorMiss = false;
|
||||
if (afterEventId) {
|
||||
const [cursorRows] = await pool.query(
|
||||
`SELECT e.created_at
|
||||
FROM h5_agent_run_events e
|
||||
INNER JOIN h5_agent_runs r ON r.id = e.run_id
|
||||
WHERE e.id = ? AND e.run_id = ? AND r.user_id = ?
|
||||
LIMIT 1`,
|
||||
[afterEventId, runId, userId],
|
||||
);
|
||||
if (!cursorRows[0]) {
|
||||
cursorMiss = true;
|
||||
} else {
|
||||
afterCreatedAt = Number(cursorRows[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
afterCreatedAt == null
|
||||
? `SELECT id, event_type, data_json, created_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE run_id = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?`
|
||||
: `SELECT id, event_type, data_json, created_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE run_id = ? AND created_at > ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
afterCreatedAt == null ? [runId, limit] : [runId, afterCreatedAt, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
run,
|
||||
events: rows.map((row) => ({
|
||||
id: row.id,
|
||||
eventType: row.event_type,
|
||||
data: row.data_json ? JSON.parse(row.data_json) : null,
|
||||
createdAt: Number(row.created_at),
|
||||
})),
|
||||
cursorMiss,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createRun,
|
||||
getRunForUser,
|
||||
listRunEventsForUser,
|
||||
dispatchRun,
|
||||
getQueueStatus,
|
||||
dispatchQueuedRuns,
|
||||
|
||||
Reference in New Issue
Block a user