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:
+14
-17
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user