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
+14 -17
View File
@@ -4,6 +4,7 @@ import { Readable, Transform, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { Agent, fetch as undiciFetch } from 'undici';
import { appendBalanceEvent, createSseBillingTransform } from './sse-billing.mjs';
import { finalizeSessionStreamEvent, writeSseErrorAndEnd } from './sse-event-taxonomy.mjs';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { evaluateProxyRequest, isNativeH5ApiPath } from './policies.mjs';
import {
@@ -14,6 +15,7 @@ import {
} from './user-publish.mjs';
import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
@@ -407,17 +409,7 @@ function sendProxyResponse(res, upstream) {
}
function writeSseProxyErrorAndEnd(res, message) {
if (res.writableEnded) return;
if (res.headersSent) {
try {
res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`);
} catch {
// The client may already be gone. Ending below is still harmless.
}
res.end();
return;
}
res.status(502).json({ message });
writeSseErrorAndEnd(res, message, { status: 502 });
}
function extractSessionId(req, body) {
@@ -593,6 +585,7 @@ function createSessionEventSanitizer(currentUser, { onEvent } = {}) {
} catch {
return line;
}
event = finalizeSessionStreamEvent(event);
if (typeof onEvent === 'function') {
try {
onEvent(event);
@@ -845,10 +838,12 @@ export async function buildVisionPayload({
}
export function createTkmindProxy({
// GOOSED PROXY BOUNDARY: canonical H5 chat → goosed adapter (Patch 5, goosed-proxy-boundary.mjs)
apiTarget,
apiTargets,
apiSecret,
userAuth,
sessionAccess = null,
llmProviderService,
localFetchAsset,
subscriptionService,
@@ -856,6 +851,8 @@ export function createTkmindProxy({
conversationMemoryService,
memoryV2,
}) {
const sessionStore =
sessionAccess ?? createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() });
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
@@ -1170,7 +1167,7 @@ export function createTkmindProxy({
throw new Error('创建会话失败:缺少 session id');
}
await rememberSessionTarget(session.id, startTarget);
await userAuth.registerAgentSession(userId, session.id, startTarget);
await sessionStore.registerAgentSession(userId, session.id, startTarget);
if (resolvedSessionPolicy?.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
method: 'POST',
@@ -1213,7 +1210,7 @@ export function createTkmindProxy({
try {
const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId);
if (routedTarget) return routedTarget;
const { target, node } = await userAuth.getSessionTarget(sessionId);
const { target, node } = await sessionStore.getSessionTarget(sessionId);
// Prefer the pinned URL: stable across reordering/resizing the target list.
// Only honor it if that upstream is still configured; otherwise fall back to
// the legacy integer index, then to primary.
@@ -1330,7 +1327,7 @@ export function createTkmindProxy({
{ toolMode = 'chat', forceDeepReasoning = false } = {},
) {
if (!userId || !sessionId) throw new Error('缺少会话信息');
const owns = await userAuth.ownsSession(userId, sessionId);
const owns = await sessionStore.validateOwnership(userId, sessionId);
if (!owns) throw new Error('无权访问该会话');
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
@@ -1460,7 +1457,7 @@ export function createTkmindProxy({
const session = JSON.parse(text);
if (session?.id) {
await rememberSessionTarget(session.id, startTarget);
await userAuth.registerAgentSession(
await sessionStore.registerAgentSession(
req.currentUser.id,
session.id,
startTarget,
@@ -1527,7 +1524,7 @@ export function createTkmindProxy({
res.status(400).json({ message: '缺少 session_id' });
return;
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await sessionStore.validateOwnership(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: '无权访问该会话' });
return;
@@ -1726,7 +1723,7 @@ export function createTkmindProxy({
res.status(400).json({ message: '缺少 session_id' });
return;
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const owns = await sessionStore.validateOwnership(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: '无权访问该会话' });
return;