Fix target session thread submission

This commit is contained in:
coso
2026-08-10 11:17:19 +08:00
parent 22a96e5e1d
commit a5aae4e45b
6 changed files with 77 additions and 10 deletions
@@ -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 messageenabled 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。
@@ -43,7 +43,7 @@ export interface AgentStreamPreparedSendEnv {
sessionIdRef: MutableRefObject<string | null>;
runPreparedSubmit: <T>(task: () => Promise<T>) => Promise<T>;
getWorkspaceIdForSubmit: () => string | undefined;
getThreadIdForSubmit: () => string | undefined;
getThreadIdForSubmit: (targetSessionId?: string) => string | undefined;
getSyncedSessionModelPreference: (
sessionId: string,
) => SessionModelPreference | null;
@@ -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<string, string>();
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<string | null>,
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),
@@ -63,7 +63,7 @@ interface ExecuteAgentStreamSubmitOptions {
refreshSessionReadModel: (targetSessionId?: string) => Promise<boolean>;
sessionIdRef: MutableRefObject<string | null>;
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,无法启动流式任务");
@@ -519,6 +519,9 @@ export function useAgentSession(options: UseAgentSessionOptions) {
const threadTurnsRef = useRef<AgentThreadTurn[]>(threadTurns);
const threadItemsRef = useRef<AgentThreadItem[]>(threadItems);
const threadReadRef = useRef<AgentRuntimeThreadReadModel | null>(threadRead);
const canonicalThreadIdBySessionIdRef = useRef<Map<string, string>>(
new Map(),
);
const sessionHistoryWindowRef = useRef<AgentSessionHistoryWindow | null>(
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,
@@ -145,7 +145,7 @@ interface UseAgentStreamOptions {
currentStreamingEventNameRef: MutableRefObject<string | null>;
warnedKeysRef: MutableRefObject<Set<string>>;
getWorkspaceIdForSubmit: () => string | undefined;
getThreadIdForSubmit: () => string | undefined;
getThreadIdForSubmit: (targetSessionId?: string) => string | undefined;
setWorkspacePathMissing: Dispatch<
SetStateAction<WorkspacePathMissingState | null>
>;