From a5aae4e45bfd8b4e7379b0ba19c6a2af67080d01 Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 10 Aug 2026 11:17:19 +0800 Subject: [PATCH] Fix target session thread submission --- internal/exec-plans/release-v1.125.0-plan.md | 12 +++++ .../chat/hooks/agentStreamPreparedSendEnv.ts | 2 +- .../hooks/agentStreamSubmitExecution.test.ts | 20 ++++++++- .../chat/hooks/agentStreamSubmitExecution.ts | 6 +-- .../agent/chat/hooks/useAgentSession.ts | 45 +++++++++++++++++-- .../agent/chat/hooks/useAgentStream.ts | 2 +- 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/internal/exec-plans/release-v1.125.0-plan.md b/internal/exec-plans/release-v1.125.0-plan.md index e257761b3..db4089e20 100644 --- a/internal/exec-plans/release-v1.125.0-plan.md +++ b/internal/exec-plans/release-v1.125.0-plan.md @@ -79,3 +79,15 @@ - Gate B failure path 已补只读诊断,失败时保存 `thread/read`、`log/list`、`log/persistedTail`、`diagnostics/server/read`、renderer invoke trace、provider request 与 MCP ledger,供下一次 Windows runner 定位 turn 终态/错误;不改变通过条件。 - runner `31345998150` 使用完整 SHA `c228ffde93f14000d7ee6daa99113c96164a0a7a`;Checkout、插件路径契约、sherpa runtime、Electron Windows 包、N-1 Squirrel 下载与安装 smoke 均通过,Plugin Gate B 仍在 `submit-renderer-form` 等待 90 秒后失败。失败诊断确认 enabled turn 已 `completed/idle`、provider request 为 `0`、MCP ledger 仅有 runtime `initialize`,且失败截图显示 disabled boundary 文本;尚未取得结构化 turn/item 错误字段。 - 为下一轮 Windows runner 增加安全的 `thread/read` turn/item 状态摘要与 localhost provider connection diagnostics;本地 Plugin Gate B 已通过。下一步用该证据确认是 runtime turn 终态错误、Plugin snapshot/Skill 解析,还是 renderer session 投影漂移,再实施窄产品修复。 + +## Windows target session 投影修复 + +状态:`fix-validated-locally / windows-runner-pending` + +- runner `31347609969` 使用完整 SHA `22a96e5e1df9b771120ba6ea26ab7f562d5eafcd`;Windows path contract、sherpa runtime、Electron Windows package、N-1 Squirrel 安装 smoke 均通过,Plugin Gate B 仍在等待 `mcp_elicitation` 90 秒后失败。 +- 失败证据确认 enabled canonical turn 已 `completed`、thread 已 `idle`,items 只有 user message 与空 agent message;enabled provider 只有 `/v1/models`,没有 `/v1/chat/completions`,而 disabled boundary provider 收到后续请求。失败截图标题已进入 enabled 会话,但正文仍显示 disabled boundary 结果,证明 renderer 的目标 session 与提交 thread 发生漂移。 +- 根因:显式 `targetSessionId` 发送链完成 `ensureSession(target)` 后,canonical thread lookup 仍读取全局 `threadReadRef`。Windows 慢时序下该 ref 可能仍绑定旧会话,导致 enabled 请求提交到 disabled boundary thread/provider。 +- 修复:`useAgentSession` 按 session 记录 canonical `thread_id`;显式目标提交只允许按目标 session 查询,映射缺失时刷新该目标的 read model,禁止回退旧会话 thread;同步收紧 prepared send / stream 类型契约并补时序回归测试。 +- 窄写集:`src/components/agent/chat/hooks/useAgentSession.ts`、`agentStreamSubmitExecution.ts`、`agentStreamPreparedSendEnv.ts`、`useAgentStream.ts`、`agentStreamSubmitExecution.test.ts` 与本计划。上述部分文件同时含并发工作树改动,提交前必须重新确认 release candidate 范围。 +- 本地验证:相关 Vitest `10/10` 通过;`npm run typecheck` 通过;`npm run smoke:plugin-package-electron-gate-b -- --timeout-ms 180000 --keep-temp` 通过,enabled provider request `2`、MCP elicitation accepted、provider final text observed、production mock fallback `0`。 +- 下一步:提交并推送完整依赖闭包后,以新完整 SHA 重触发 `build-windows-test.yml`,持续跟踪到 Plugin Gate B 与 artifact 结论;不移动已发布的 `v1.125.0` tag。 diff --git a/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts b/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts index f3bae86ac..9fc05dc83 100644 --- a/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts +++ b/src/components/agent/chat/hooks/agentStreamPreparedSendEnv.ts @@ -43,7 +43,7 @@ export interface AgentStreamPreparedSendEnv { sessionIdRef: MutableRefObject; runPreparedSubmit: (task: () => Promise) => Promise; getWorkspaceIdForSubmit: () => string | undefined; - getThreadIdForSubmit: () => string | undefined; + getThreadIdForSubmit: (targetSessionId?: string) => string | undefined; getSyncedSessionModelPreference: ( sessionId: string, ) => SessionModelPreference | null; diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts index ff26ab974..3285a9e42 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts @@ -400,6 +400,18 @@ describe("agentStreamSubmitExecution", () => { const unlisten = vi.fn(); const submitOp = vi.fn(async () => {}); const ensureSession = vi.fn(async () => "session-materialized"); + const threadIdBySessionId = new Map(); + const getThreadIdForSubmit = vi.fn((targetSessionId?: string) => + targetSessionId + ? threadIdBySessionId.get(targetSessionId) + : "thread-previous", + ); + const refreshSessionReadModel = vi.fn(async (targetSessionId?: string) => { + if (targetSessionId) { + threadIdBySessionId.set(targetSessionId, "thread-materialized"); + } + return true; + }); const registerListener = vi.fn(); const activateStream = vi.fn(); const runtime = { @@ -420,12 +432,12 @@ describe("agentStreamSubmitExecution", () => { runtime, ensureSession, attemptSilentTurnRecovery: async () => false, - refreshSessionReadModel: async () => true, + refreshSessionReadModel, sessionIdRef: { current: "session-previous", } as MutableRefObject, getWorkspaceIdForSubmit: () => "workspace-1", - getThreadIdForSubmit: () => "thread-materialized", + getThreadIdForSubmit, getSyncedSessionExecutionStrategy: () => "react", getSyncedSessionRecentPreferences: () => null, effectiveAccessMode: "read-only", @@ -482,6 +494,10 @@ describe("agentStreamSubmitExecution", () => { skipSessionRestore: true, skipSessionStartHooks: true, }); + expect(getThreadIdForSubmit).toHaveBeenCalledWith("session-materialized"); + expect(refreshSessionReadModel).toHaveBeenCalledWith( + "session-materialized", + ); expect(runtime.listenToTurnEvents).toHaveBeenCalledWith( "event-materialized", expect.any(Function), diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts index 198e74694..161c4312a 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts @@ -63,7 +63,7 @@ interface ExecuteAgentStreamSubmitOptions { refreshSessionReadModel: (targetSessionId?: string) => Promise; sessionIdRef: MutableRefObject; getWorkspaceIdForSubmit: () => string | undefined; - getThreadIdForSubmit: () => string | undefined; + getThreadIdForSubmit: (targetSessionId?: string) => string | undefined; getSyncedSessionExecutionStrategy: ( sessionId: string, ) => AgentExecutionStrategy | null; @@ -292,10 +292,10 @@ export async function executeAgentStreamSubmit( if (!resolvedActiveSessionId) { throw new Error("缺少会话 ID,无法启动流式任务"); } - let resolvedThreadId = getThreadIdForSubmit()?.trim(); + let resolvedThreadId = getThreadIdForSubmit(targetSessionId)?.trim(); if (!resolvedThreadId) { await refreshSessionReadModel(resolvedActiveSessionId); - resolvedThreadId = getThreadIdForSubmit()?.trim(); + resolvedThreadId = getThreadIdForSubmit(resolvedActiveSessionId)?.trim(); } if (!resolvedThreadId) { throw new Error("缺少 canonical threadId,无法启动流式任务"); diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index a0450e753..7b1197138 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -519,6 +519,9 @@ export function useAgentSession(options: UseAgentSessionOptions) { const threadTurnsRef = useRef(threadTurns); const threadItemsRef = useRef(threadItems); const threadReadRef = useRef(threadRead); + const canonicalThreadIdBySessionIdRef = useRef>( + new Map(), + ); const sessionHistoryWindowRef = useRef( sessionHistoryWindow, ); @@ -733,6 +736,14 @@ export function useAgentSession(options: UseAgentSessionOptions) { threadTurnsRef.current = stableSnapshot.threadTurns; threadItemsRef.current = stableSnapshot.threadItems; threadReadRef.current = resolvedThreadRead; + const resolvedSessionId = stableSnapshot.sessionId?.trim(); + const canonicalThreadId = resolvedThreadRead?.thread_id?.trim(); + if (resolvedSessionId && canonicalThreadId) { + canonicalThreadIdBySessionIdRef.current.set( + resolvedSessionId, + canonicalThreadId, + ); + } executionRuntimeRef.current = stableSnapshot.executionRuntime; sessionWorkingDirRef.current = stableSnapshot.workingDir; setSessionId(stableSnapshot.sessionId); @@ -757,13 +768,33 @@ export function useAgentSession(options: UseAgentSessionOptions) { mergeAgentSessionReadModelThreadItems(currentItems, snapshot), ); threadReadRef.current = snapshot.threadRead; + const currentSessionId = sessionIdRef.current?.trim(); + const canonicalThreadId = snapshot.threadRead.thread_id?.trim(); + if (currentSessionId && canonicalThreadId) { + canonicalThreadIdBySessionIdRef.current.set( + currentSessionId, + canonicalThreadId, + ); + } setThreadRead(snapshot.threadRead); }, - [setThreadItemsState], + [setThreadItemsState, sessionIdRef], ); const getThreadIdForSubmit = useCallback( - () => threadReadRef.current?.thread_id, - [], + (targetSessionId?: string) => { + const resolvedTargetSessionId = targetSessionId?.trim(); + if (resolvedTargetSessionId) { + return canonicalThreadIdBySessionIdRef.current.get( + resolvedTargetSessionId, + ); + } + return ( + canonicalThreadIdBySessionIdRef.current.get( + sessionIdRef.current?.trim() ?? "", + ) || threadReadRef.current?.thread_id + ); + }, + [sessionIdRef], ); const hasActiveStreamingTimelineNow = useCallback( @@ -1505,6 +1536,14 @@ export function useAgentSession(options: UseAgentSessionOptions) { persistSessionRestoreCandidate(resolvedSessionId); if (detail) { + const canonicalThreadId = + detail.thread_read?.thread_id?.trim() || detail.thread_id?.trim(); + if (canonicalThreadId) { + canonicalThreadIdBySessionIdRef.current.set( + resolvedSessionId, + canonicalThreadId, + ); + } setTopics((prev) => upsertTopicFromSessionDetail( prev, diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index 10980753a..7ecc1c2f2 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -145,7 +145,7 @@ interface UseAgentStreamOptions { currentStreamingEventNameRef: MutableRefObject; warnedKeysRef: MutableRefObject>; getWorkspaceIdForSubmit: () => string | undefined; - getThreadIdForSubmit: () => string | undefined; + getThreadIdForSubmit: (targetSessionId?: string) => string | undefined; setWorkspacePathMissing: Dispatch< SetStateAction >;