Parcourir la source

fix(codex): 换模型先于建线程,失效线程可恢复且失败不再卡在处理中

日志显示 21:11:20 建线程、21:11:26 应用模型重启子进程、21:11:27 turn/start 就报
thread not found —— 线程建在旧进程里,被我自己那一步重启作废了。这是把模型应用挪到
首次发送时带来的顺序错误。

- send() 里 ensureModelApplied 提前到 ensureSession 之前,并加「当前会话在跑就返回」的守卫,
  不再为换模型把在跑的回合一起带走。
- turnRun 遇到 thread not found 先 thread/resume 再重发本轮(真 codex 实测可救回,历史完整带回);
  resume 也失败说明该线程从没跑完过一轮、Codex 根本没落盘,此时明确提示新建会话,
  不自动换线程 —— 事件缓存、产物归属、重新生成都按 threadId 索引,会话与线程 1:1 是现有前提。
- 失败收尾:resolveTurnStatus 在没有 turn/completed 但已有 error 事件时判 error 而不是 running;
  新增 noteTurnFailure 把错误挂回那条卡在 running 的回合,send() 与 stop() 共用;
  stop() 的 interrupt 失败也先收敛本地状态,不再留下转圈的气标和连打五次的无效请求。
- resumeThread 的 excludeTurns 实测对上游收到的历史没有差别(true/false 都是 8 条、含前两轮),
  因此没有加这个开关,只留注释记录结论。

验证:tsc --noEmit 0 报错;vitest 108 passed;vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;
真 codex.exe + 桥接 + mock 上游跑通「重启 → resume → 第二轮完成」。未跑 itest / build / 界面。
cc il y a 2 semaines
Parent
commit
6fdf70a5ab

+ 35 - 0
.workbuddy/memory/2026-09-24.md

@@ -433,3 +433,38 @@ Responses 只属于 Codex 自己的模型。据此把四态判定砍成 `chat |
 验证:tsc 0 报错;vitest 109 passed(新断言:探测请求体不得带 `messages`、不可达提示必须含 URL);
 vue-tsc 93 条存量、src/codex 与 aiPlugin 命中 0;用应用自身 `probeEndpoint` 打真实端点 27ms 判 `chat`。
 itest / build / 客户端 UI 未跑(渲染进程要 Electron 的 codex IPC 桥,浏览器里进不去)。
+
+## `turn/start: thread not found` 与「正在处理中」不退
+
+日志时间线(pid 18984):`21:11:20` codex 进程 #1 起来(`createSession → thread/start` 用的它)
+→ `21:11:26` `chat-bridge 已启动`(= 我在 `send()` 里的 `ensureModelApplied` 重启了子进程)
+→ `21:11:27` `turnRun failed: thread not found`。
+
+**根因是自己引入的顺序错误**:`send()` 原先是「建线程 → 应用模型 → turnRun」,
+而应用模型会 stop+start app-server,刚建的线程当场作废。线程只存在于当前进程;
+Codex 只在**成功跑完一轮**后才落盘(`state_5.sqlite` 的 `threads` 表),所以从没成功过的会话
+连 `thread/resume` 都报 `no rollout found`。另外 `resumeThread` 此前全仓零调用。
+
+「正在处理中」不退是第二处:`resolveTurnStatus` 只认 `turn/completed`,而 `send()` 失败时
+自己造的 `local-error:<ts>` 分组没有该事件 → 错误气泡本身被判 running;
+`stop()` 又专挑 running 的助手消息去 interrupt → 连打 5 次 `thread not found`。
+
+修复(A+B+C+D1,已拍板):
+
+- **A** `send()`:`ensureModelApplied()` 提前到 `ensureSession()` 之前,并加「当前会话在跑就返回」守卫。
+- **B** `resolveTurnStatus`:无 `turn/completed` 但有 error 事件 ⇒ `error`;
+  新增 store action `noteTurnFailure`,把失败挂回卡在 running 的那一轮 turnId(找不到才自造分组),
+  `send()`/`stop()` 共用;`streaming` getter 读同一状态,因此能退出转圈。
+- **C** `stop()`:interrupt 失败也先本地收尾再抛给页面提示。
+- **D1** `codexCtl.turnRun`:`thread not found` → `resumeThread` → 重发本轮;
+  resume 也失败 → 「该会话的 Codex 线程已失效(中途换过模型或重启过应用),请新建会话继续」。
+  不自动换线程:事件缓存、产物归属、重新生成都按 threadId 索引,会话↔线程 1:1 是现有前提。
+
+实测(真 codex.exe + 桥接 + mock 上游):跑完一轮 → 重启 → `thread not found` 原文复现 →
+`resumeThread` 成功且第二轮完成、历史带回。顺带验掉一个疑点:`thread/resume` 的
+`excludeTurns` true/false **对上游收到的历史没有差别**(都是 8 条、含前两轮 user/assistant),
+所以没加这个开关,只在 `resumeThread` 里留一行注释记录结论。
+
+验证:tsc 0 报错;vitest 108 passed;vue-tsc 93 存量、src/codex 与 aiPlugin 命中 0
+(中途一次「0 报错」是我拆掉前端 junction 造成的假数,已重跑取实数)。
+未跑 itest(会写 data/codex-home 与 provider.json)、build、真实界面点击。

+ 28 - 4
ai-electron/electron/controller/codexCtl.ts

@@ -52,6 +52,11 @@ function requireString(value: unknown, label: string): string {
   return value.trim();
 }
 
+/** Codex 的「线程不在本进程里」只以这句错误文本出现,是识别失效线程的唯一依据 */
+function isThreadMissingError(error: unknown): boolean {
+  return /thread not found/iu.test(error instanceof Error ? error.message : String(error));
+}
+
 function toBytes(value: unknown): Uint8Array {
   if (value instanceof Uint8Array) return value;
   if (value instanceof ArrayBuffer) return new Uint8Array(value);
@@ -282,15 +287,34 @@ class CodexCtl {
       const threadId = requireString(params?.threadId, 'threadId');
       const cwd = params?.cwd?.trim() || undefined;
       if (cwd) registerArtifactRoot(cwd);
-      const result = await runtime.runTurn({
+      const options = {
         threadId,
         prompt: requireString(params?.input, 'input'),
         cwd,
         model: params?.model ?? null,
-        approvalPolicy: params?.approvalPolicy ?? 'never',
+        approvalPolicy: params?.approvalPolicy ?? ('never' as const),
         timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : undefined,
-      });
-      return { turnId: result.turnId, status: result.status, text: result.text };
+      };
+      try {
+        const result = await runtime.runTurn(options);
+        return { turnId: result.turnId, status: result.status, text: result.text };
+      } catch (error: unknown) {
+        // 线程只存在于当前 app-server 进程里:换模型或重启应用后旧 threadId 必然认不出,
+        // 先按 Codex 落盘的历史把它救回来(thread/resume),再重发这一轮
+        if (!isThreadMissingError(error)) throw error;
+        try {
+          await runtime.resumeThread(threadId, {
+            cwd: cwd || (await defaultWorkspaceDir()),
+            model: options.model,
+            approvalPolicy: options.approvalPolicy,
+          });
+        } catch {
+          // 从没成功跑完过一轮的线程 Codex 根本没落盘,救不回来
+          throw new Error('该会话的 Codex 线程已失效(中途换过模型或重启过应用),请新建会话继续');
+        }
+        const retried = await runtime.runTurn(options);
+        return { turnId: retried.turnId, status: retried.status, text: retried.text };
+      }
     });
   }
 

+ 1 - 0
ai-electron/electron/service/codex/codexRuntime.ts

@@ -413,6 +413,7 @@ export class CodexRuntime extends EventEmitter {
       sandbox: options.sandbox ?? 'workspace-write',
       approvalPolicy: options.approvalPolicy ?? 'never',
       ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}),
+      // excludeTurns 不影响上游收到的历史(实测 true/false 两轮上下文都完整带回)
       excludeTurns: true,
     });
     return result.thread.id;

+ 33 - 15
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -720,12 +720,15 @@ export const useCodexChatStore = defineStore('codex-chat', {
       const input = text.trim();
       if (!input) return;
 
+      // 同一会话不允许重复提交;不同会话可以并行跑(各自独立的事件流与回合)
+      if (this.activeThreadId && this.runningThreads.includes(this.activeThreadId)) return;
+      // 应用模型必须赶在建线程之前:它会重启 app-server,
+      // 线程要是建在旧进程里,这一轮就直接 thread not found
+      await this.ensureModelApplied();
+
       const session = await this.ensureSession();
       const threadId = session.threadId;
-      // 同一会话不允许重复提交;不同会话可以并行跑(各自独立的事件流与回合)
       if (this.runningThreads.includes(threadId)) return;
-      // 先确保模型已应用,再落提问:应用失败(端点不通等)不该留下一条问不出去的消息
-      await this.ensureModelApplied();
 
       const now = new Date().toISOString();
       const parsed: CodexUserMessage = {
@@ -761,17 +764,7 @@ export const useCodexChatStore = defineStore('codex-chat', {
           sandbox: session.sandbox ?? this.runDefaults.sandbox,
         });
       } catch (error: any) {
-        const message = error?.message || 'Codex 执行失败';
-        this.lastError = message;
-        this.acceptEvent({
-          id: `local-error:${threadId}:${Date.now()}`,
-          threadId,
-          turnId: `local-error:${Date.now()}`,
-          kind: 'error',
-          title: '运行错误',
-          message,
-          timestamp: new Date().toISOString(),
-        });
+        this.noteTurnFailure(threadId, error?.message || 'Codex 执行失败');
       } finally {
         this.runningThreads = this.runningThreads.filter((id) => id !== threadId);
         await this.ensureEvents(threadId);
@@ -783,6 +776,29 @@ export const useCodexChatStore = defineStore('codex-chat', {
       }
     },
 
+    /**
+     * 记一条本地失败事件。
+     *
+     * 回合卡住时(子进程被换掉、线程失效)Codex 再也不会补 turn/completed,
+     * 所以要把错误挂到那一轮自己的分组上,否则气泡一直停在「正在处理中」。
+     */
+    noteTurnFailure(threadId: string, message: string) {
+      this.lastError = message;
+      const stuck = [...buildCodexMessages(this.events[threadId] ?? [], [])]
+        .reverse()
+        .find((item) => item.role === 'assistant' && item.status === 'running');
+      const stamp = Date.now();
+      this.acceptEvent({
+        id: `local-error:${threadId}:${stamp}`,
+        threadId,
+        turnId: stuck?.role === 'assistant' ? stuck.turnId : `local-error:${stamp}`,
+        kind: 'error',
+        title: '运行错误',
+        message,
+        timestamp: new Date().toISOString(),
+      });
+    },
+
     /** 中断当前回合 */
     async stop() {
       const threadId = this.activeThreadId;
@@ -793,7 +809,9 @@ export const useCodexChatStore = defineStore('codex-chat', {
       try {
         await codexApi.codexTurnInterrupt(threadId, running.turnId);
       } catch (error: any) {
-        this.lastError = error?.message || '中断失败';
+        // 中断失败通常意味着那一轮早就不存在了(进程已重启):
+        // 本地状态照样收尾,不能让用户对着转圈的「正在处理中」无计可施
+        this.noteTurnFailure(threadId, error?.message || '中断失败');
         throw error;
       }
     },

+ 5 - 1
ai-electron/frontend/src/codex/utils/codexEventReducer.ts

@@ -74,7 +74,11 @@ export function trimCodexEvents(events: CodexAgentEvent[], limit = 2000): CodexA
 /** turn/completed 的 message 形如「状态:completed」,据此判定回合结局 */
 function resolveTurnStatus(events: CodexAgentEvent[]): CodexTurnStatus {
   const completed = events.find((e) => e.method === 'turn/completed');
-  if (!completed) return 'running';
+  if (!completed) {
+    // 等不到 turn/completed 但已经有 error 事件 = 这轮不会再有结局
+    //(失败、或子进程被换掉了),还判 running 界面就一直「正在处理中」
+    return events.some((e) => e.kind === 'error') ? 'error' : 'running';
+  }
   const raw = completed.message.toLowerCase();
   if (raw.includes('interrupt') || raw.includes('cancel')) return 'interrupted';
   if (raw.includes('fail') || raw.includes('error')) return 'error';