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:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+82 -3
View File
@@ -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,