Jelajahi Sumber

feat(codex): 本会话产物升级为混合口径(清单精确 + mtime 兜底)

mtime 过滤只回答「什么时候改的」回答不了「谁改的」——后来会话产出的新文件会混进更早会话的列表。升级为混合口径:CodexSessionMeta 新增 artifactManifest,回合结束时把历次事件流里 patch 段落头提取的改动路径累计进清单(精确归属、随会话持久化);loadSessionFiles 取清单 ∪ mtime≥创建时间的并集——升级前的历史会话、exec 直接写文件、patch 解析失败仍由 mtime 兜底。

验证:pnpm type:check 89 条基线 / 本包 0 报错;pnpm build exit=0。
cc 2 minggu lalu
induk
melakukan
e8bca72b27

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

@@ -295,3 +295,19 @@ composer 的 `cwd` prop 仅用于「切会话清空草稿」的 watch,不渲
 
 验证:前端 type:check 89 基线 / 本包 0 报错 + build exit=0;
 主进程 tsc --noEmit 0 报错 + 91 单测全过 + build-electron exit=0(listAll 已进 bundle)。
+
+## 本会话产物升级为混合口径(C+A)
+
+mtime 过滤的盲区:**后来的会话产出的新文件会混进更早会话的列表**(时间戳只回答「什么时候改的」,
+回答不了「谁改的」)。升级为 C+A 混合:
+
+- **C(精确)**:`CodexSessionMeta.artifactManifest` —— 每回合结束时
+  `accumulateArtifactManifest(threadId)` 把该会话**历次**事件流里 `*** Add/Update/Delete File:`
+  段落头提取的路径累计进清单(Set 语义、去重、随会话持久化)。以事件流为准而非只看最后一轮:
+  文件一旦被改过就一直归属该会话。
+- **A(兜底)**:`loadSessionFiles` 取**并集** —— `manifest.has(relative) || mtime >= createdAt`。
+  清单覆盖不到的场景(升级前的历史会话 / exec 直接写文件 / patch 解析失败)由 mtime 兜住。
+
+方案讨论时对比过 B(每会话产物目录:协议没有输出目录概念,靠 prompt 约束不可靠)、
+D(主进程 watcher:只覆盖活会话,要养常驻进程)、E(按回合组织:交互重做)——都否了。
+提交 `(本次)`;验证 type:check 89 基线 / 本包 0 报错 + build exit=0。

+ 2 - 1
ai-electron/frontend/src/codex/components/codexArtifactPanel.vue

@@ -144,7 +144,8 @@
   /**
    * 产物栏:双视图 + 预览 / 另存为 + 本轮回合的变更清单。
    *
-   *  - **本会话产物**(默认):工作空间里 mtime ≥ 会话创建时间的文件(含子目录)。
+   *  - **本会话产物**(默认):**混合口径** —— 会话产物清单(Codex 每回合 patch 亲口记录的
+   *    改动文件,精确归属)∪ mtime ≥ 会话创建时间(兜底:历史会话 / exec 写入 / 解析失败)。
    *    会话共用同一个案件工作空间,不做这份过滤的话每个会话看到的都是同一堆文件;
    *  - **全部文件**:按目录浏览工作空间(原有能力)。
    *

+ 46 - 4
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -68,6 +68,9 @@ function readSessions(): CodexSessionMeta[] {
         sandbox: item.sandbox ?? DEFAULT_RUN_OPTIONS.sandbox,
         approvalPolicy: item.approvalPolicy ?? DEFAULT_RUN_OPTIONS.approvalPolicy,
         external: item.external === true,
+        artifactManifest: Array.isArray(item.artifactManifest)
+          ? item.artifactManifest.filter((p: unknown): p is string => typeof p === 'string' && !!p)
+          : undefined,
       }));
   } catch {
     return [];
@@ -712,6 +715,8 @@ export const useCodexChatStore = defineStore('codex-chat', {
       } finally {
         this.runningThreads = this.runningThreads.filter((id) => id !== threadId);
         await this.ensureEvents(threadId);
+        // Codex 亲口改过的文件累计进会话产物清单(精确归属,跨回合持久)
+        this.accumulateArtifactManifest(threadId);
         // 回合结束后标记新产物(与上一轮的快照对比),并刷新本会话产物
         await this.refreshArtifacts(undefined, true);
         await this.loadSessionFiles();
@@ -778,10 +783,44 @@ export const useCodexChatStore = defineStore('codex-chat', {
     },
 
     /**
-     * 加载「本会话产物」。
+     * 把该会话**历次**回合里 patch 记录过的文件路径累计进 `artifactManifest`。
      *
-     * 判定口径:mtime ≥ 会话创建时间。会话创建后 Codex 产出或改过的文件都会命中;
-     * 别的会话更早生成的文件不会混进来。并行会话改同一个文件时会同时出现在两边,属可接受误差。
+     * 以事件流为准(不是只看最后一轮):文件一旦被本会话改过就一直归属它,
+     * 不会因为后续回合没再动它而消失。路径取自 `*** Add/Update/Delete File:` 段落头,
+     * 认不出的段落不记——宁缺勿错。
+     */
+    accumulateArtifactManifest(threadId: string) {
+      const events = this.events[threadId] ?? [];
+      if (!events.length) return;
+      const session = this.sessions.find((item) => item.threadId === threadId);
+      if (!session) return;
+      const manifest = new Set(session.artifactManifest ?? []);
+      let added = false;
+      for (const event of events) {
+        if (event.kind !== 'file') continue;
+        for (const change of collectCodexArtifactChanges([event])) {
+          if (change.path) {
+            const before = manifest.size;
+            manifest.add(change.path);
+            if (manifest.size !== before) added = true;
+          }
+        }
+      }
+      if (!added) return;
+      this.sessions = this.sessions.map((item) =>
+        item.threadId === threadId ? { ...item, artifactManifest: [...manifest] } : item,
+      );
+      this.persistSessions();
+    },
+
+    /**
+     * 加载「本会话产物」——混合口径(精确优先,时间兜底)。
+     *
+     *  1. **精确**:`artifactManifest`(Codex 每回合 patch 里亲口记录的改动文件,累计持久);
+     *  2. **兜底**:清单没覆盖到的情况——历史会话(升级前没有清单)、exec 直接写文件
+     *     (不产生 patch 事件)、patch 解析失败——回落到 mtime ≥ 会话创建时间。
+     *
+     * 两者取并集:清单保证「自己改过的必在」,mtime 保证「兜底场景不空」。
      */
     async loadSessionFiles() {
       const root = this.resolveCwd();
@@ -793,9 +832,12 @@ export const useCodexChatStore = defineStore('codex-chat', {
       this.sessionFiles.loading = true;
       try {
         const result = await codexArtifactApi.codexArtifactListAll({ root });
+        const manifest = new Set(session.artifactManifest ?? []);
         const since = Date.parse(session.createdAt);
         this.sessionFiles = {
-          entries: result.entries.filter((item) => Date.parse(item.mtime) >= since),
+          entries: result.entries.filter(
+            (item) => manifest.has(item.relative) || Date.parse(item.mtime) >= since,
+          ),
           truncated: result.truncated,
           loading: false,
         };

+ 6 - 0
ai-electron/frontend/src/codex/types/codexTypes.ts

@@ -97,6 +97,12 @@ export interface CodexSessionMeta {
    * 标出来是为了「打开时如实提示」,而不是装作能恢复。
    */
   external?: boolean;
+  /**
+   * 产物清单:本会话历次回合里 Codex 通过 apply_patch 亲口改过的文件相对路径。
+   * 「本会话产物」视图的**精确**数据源;清单为空或不覆盖时回落到 mtime 过滤(见
+   * store.loadSessionFiles)。
+   */
+  artifactManifest?: string[];
 }
 
 /** 运行参数(沙箱 / 审批策略)。可被全局默认或单个会话覆盖 */