Skip to content

fix: 清理供应商同步后的幽灵任务索引 / prune ghost tasks after provider sync#1485

Open
Yuimi-chaya wants to merge 3 commits into
BigPizzaV3:mainfrom
Yuimi-chaya:codex/provider-sync-session-index-cleanup
Open

fix: 清理供应商同步后的幽灵任务索引 / prune ghost tasks after provider sync#1485
Yuimi-chaya wants to merge 3 commits into
BigPizzaV3:mainfrom
Yuimi-chaya:codex/provider-sync-session-index-cleanup

Conversation

@Yuimi-chaya

@Yuimi-chaya Yuimi-chaya commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

中文

2026-07-14 安全重构说明

根据维护者对删除边界的审查,本 PR 已撤销首版“在普通供应商同步中自动清理索引”的实现。现在供应商自动同步、启动前同步和普通 run_provider_sync 都不会删除 session_index.jsonl 内容。

清理改为独立的两阶段流程:管理器先只读预览候选项,用户逐项选择并明确确认后,后端才执行单文件原子清理。该流程要求 Codex App / ChatGPT 已完全退出;如果 App 仍在运行、预览后索引发生变化、确认列表过期或原子写入失败,本次清理会中止。

问题背景

供应商同步会统一 rollout 与 SQLite 中的 model_provider,但 Codex App 还可能保留已经失去会话来源的 session_index.jsonl 条目。重启后,这些孤立条目会重新显示在任务列表中,但无法打开或归档。

实际复现时,用户曾为验证模型路由创建几条临时任务,例如 确认在线回应问候确认在线状态,并在测试结束后将它们删除。随后切换供应商并重启 Codex App,这些任务却以“仅剩标题”的条目重新出现在侧边栏。

作为对照,另一个标题相近但仍在正常使用的任务 回复在吗 必须保留。这个现象说明,清理不能依赖任务标题,而必须根据 thread ID 的实际数据来源判断。

存活来源与 schema 边界

预览阶段会从以下来源联合收集 thread ID;任一来源存在即保留:

  • sessionsarchived_sessions 中可解析 rollout 的 session_meta.payload.id
  • 即使 rollout 内容损坏,只要标准文件名末尾包含合法 UUID,也保留该 ID。
  • SQLite 主表:threads.idlocal_thread_catalog.thread_idautomation_runs.thread_idinbox_items.thread_id
  • SQLite 兼容/关联表:sessions.idmessages.session_idthread_dynamic_tools.thread_idthread_goals.thread_idthread_spawn_edges.parent_thread_id/child_thread_idstage1_outputs.thread_idagent_job_items.assigned_thread_id
  • SQLite 数据库路径发现同步兼容 #1438local_thread_catalog,并覆盖仅含关联表的候选数据库。

只有严格匹配当前已知三字段 schema(idthread_nameupdated_at)的 JSON 对象才会进入候选列表。无法解析的行、缺字段记录、带额外字段的未来结构和其他未知对象全部原样保留。

预览与显式确认

  • 普通供应商同步永不清理索引;云端任务、延迟落盘任务不会因一次普通同步被自动删除。
  • 手动同步完成后只执行 dry-run 预览,不写文件、不创建备份。
  • 管理器完整展示全部候选项及 thread ID,默认不勾选。
  • 用户可以逐项选择或全选;后端只接收实际勾选的 ID,不会清理未展示或未选择的候选。
  • 弹窗明确提醒候选项也可能是云端或尚未落盘的任务,并要求清理前退出 Codex App / ChatGPT。

并发、原子写入与备份

  • 预览返回原始索引的 SHA-256 快照;应用时重新规划来源并校验快照。
  • 创建备份后、写入前再次逐字节比较原文件;Codex 新建、重命名或同步任务导致文件变化时立即中止。
  • 实际用户目录在应用前和原子替换前都会检查 Codex/ChatGPT 进程;运行中拒绝清理,关闭外部整文件写竞争。
  • 使用同目录临时文件原子替换;Windows 使用 MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH),其他平台使用同文件系统 rename
  • 写入失败会清理临时文件,原索引不会被提前截断。
  • 修改前完整备份 session_index.jsonlbackups_state/provider-sync/...,并记录快照和清理数量。
  • 备份目录一旦创建,后续元数据、并发校验或写入失败都会在命令结果中保留实际 backupDir 和明确恢复提示;不再使用静默忽略的索引回滚写入。

验证

  • cargo test -q -p codex-plus-data --test provider_sync --test storage_adapter -j 222/22 + 18/18 通过。
  • cargo test -q -p codex-plus-core --test relay_config -j 295/95 通过。
  • Windows replace-existing 原子写专项测试通过。
  • cargo check -q -p codex-plus-manager -j 2
  • npm run check
  • node tools/i18n-verify.mjs:普通文本 602/602、模板文本 50/50
  • 回归覆盖:local_thread_catalog、automation/inbox、仅关联表数据库、损坏 rollout 文件名、未知带 id 结构、普通同步零删除、逐项确认、预览后并发修改、原子写失败保留原文件和暴露备份路径。

English

Safety redesign on 2026-07-14

Following maintainer review of the deletion boundaries, this PR removes the initial behavior that automatically pruned the index during ordinary provider sync. Automatic provider sync, pre-launch sync, and normal run_provider_sync calls never delete content from session_index.jsonl now.

Cleanup is a separate two-phase flow. The manager first performs a read-only preview; the backend applies a single-file atomic cleanup only after the user reviews and explicitly selects candidate entries. Codex App / ChatGPT must be fully exited. Cleanup aborts if the App is running, the index changes after preview, the confirmed selection becomes stale, or atomic replacement fails.

Background

Provider sync normalizes model_provider across rollout files and SQLite, while Codex App may still retain session_index.jsonl entries whose underlying conversation sources have already disappeared. Those orphaned entries can return after restart but cannot be opened or archived.

During reproduction, the user created and later deleted several temporary model-routing tasks, including 确认在线, 回应问候, and 确认在线状态. After switching providers and restarting Codex App, they returned as title-only sidebar entries.

In contrast, the similarly named active task 回复在吗 had to remain. Cleanup therefore cannot use titles and must rely on actual thread-ID sources.

Live sources and schema boundary

Preview collects thread IDs from every source below. Any match preserves the entry:

  • Parseable session_meta.payload.id values in sessions and archived_sessions rollouts.
  • Valid UUID suffixes in standard rollout filenames, even when rollout contents are damaged.
  • Primary SQLite tables: threads.id, local_thread_catalog.thread_id, automation_runs.thread_id, and inbox_items.thread_id.
  • Compatible/related SQLite tables: sessions.id, messages.session_id, thread_dynamic_tools.thread_id, thread_goals.thread_id, both sides of thread_spawn_edges, stage1_outputs.thread_id, and agent_job_items.assigned_thread_id.
  • SQLite discovery is coordinated with fix: sync providers in local_thread_catalog #1438 for local_thread_catalog and also includes databases containing only related session tables.

Only JSON objects that exactly match the currently known three-field schema (id, thread_name, and updated_at) can become candidates. Malformed lines, missing-field records, future records with extra fields, and all other unknown structures are preserved verbatim.

Preview and explicit confirmation

  • Ordinary provider sync never prunes the index, so cloud-only or delayed local persistence cannot be deleted by a normal sync run.
  • Manual sync performs a read-only dry run first, with no file write or backup side effect.
  • The manager displays every candidate and its thread ID; nothing is selected by default.
  • Users can select individual entries or select all. The backend receives only explicitly selected IDs.
  • The dialog warns that candidates may still represent cloud-only or not-yet-persisted tasks and requires Codex App / ChatGPT to be closed first.

Concurrency, atomic replacement, and backup

  • Preview returns a SHA-256 snapshot of the original index; apply recomputes live sources and validates that snapshot.
  • After backup and immediately before writing, the original file is compared byte-for-byte again. A concurrent create, rename, or sync aborts cleanup.
  • For the real user home, Codex/ChatGPT process checks run both before apply and immediately before atomic replacement. Cleanup is refused while the App is running.
  • Replacement uses a temporary file in the same directory. Windows uses MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); other platforms use same-filesystem rename.
  • Failed writes remove the temporary file and never truncate the original index first.
  • The complete original session_index.jsonl is backed up under backups_state/provider-sync/... with the snapshot and selected count.
  • Once a backup directory exists, metadata, concurrency, and write failures preserve the actual backupDir in the command result with an explicit recovery warning. The previous silently ignored rollback writes no longer exist.

Verification

  • cargo test -q -p codex-plus-data --test provider_sync --test storage_adapter -j 2: 22/22 + 18/18 passed.
  • cargo test -q -p codex-plus-core --test relay_config -j 2: 95/95 passed.
  • Windows replace-existing atomic-write regression test passed.
  • cargo check -q -p codex-plus-manager -j 2.
  • npm run check.
  • node tools/i18n-verify.mjs: plain 602/602, template 50/50.
  • Regression coverage includes local_thread_catalog, automation/inbox, relation-only databases, damaged rollout filename fallback, unknown structures containing id, zero deletion during ordinary sync, explicit selection, concurrent index changes after preview, and atomic-write failure with original preservation and visible backup path.

@BigPizzaV3

Copy link
Copy Markdown
Owner

感谢贡献。这个问题确实存在,但当前实现涉及删除用户任务索引,仍有几个阻塞风险,暂不建议合并。

  1. 存活来源收集不完整,可能误删有效任务

collect_live_thread_ids() 最终只从 rollout 文件和 threads.id 收集 ID,遗漏:

  • 新版 Codex 的 local_thread_catalog
  • automation_runs.thread_id
  • inbox_items.thread_id

当前主分支的 codex_session_db_paths_from_home() 甚至不会把仅含 local_thread_catalog 的数据库列为候选。#1438 正在补这一新版 schema,#1485 需要与 #1438 协调或直接包含相同的来源发现逻辑,否则由这些表单独支撑的任务会被判定为 stale。

  1. Codex 运行时存在整文件覆盖竞争

plan_session_index_cleanup() 先读取完整 session_index.jsonl,之后还会扫描数据库、创建备份,最后直接 fs::write() 覆盖整个索引。管理器的 sync_providers_now 没有停止 Codex,也没有获取 Codex 的文件锁。

如果 Codex 在计划和写入之间新增或重命名任务,新内容会被旧快照覆盖。建议:

  • 写入前重新比较原始字节或 mtime/hash,发生变化时中止并重新规划;
  • 使用项目已有的原子写入方式;
  • 增加并发修改回归测试。
  1. “未知结构保留”与实现不一致

当前逻辑只要某个合法 JSON 对象带非空 id,且 ID 不在来源集合里,就会删除;没有验证 thread_name 或其他已知 session-index 结构。未来如果索引加入另一种同样带 id 的记录,也会被误删。

请只删除明确识别为当前已知 session-index schema 的记录,其余未知结构必须保留并补测试。

  1. 尚未证明所有合法索引都一定有本地 rollout/SQLite 来源

需要确认云端任务、尚未完整落盘的任务、延迟同步任务是否可能只存在于 session_index.jsonl。我对当前环境做了只读模拟:23 条索引中,该 PR 会把 6 条判为 stale。它们可能确实是幽灵任务,但这个比例说明首次同步的删除面不小,建议至少提供 dry-run/预览和显式确认,而不是隐藏在普通“供应商同步”操作里直接执行。

  1. 回滚失败被静默忽略

两处恢复索引都使用 let _ = fs::write(...)。如果恢复失败,最终只返回普通 Skipped,同时丢失已经创建的 backup_dir 信息,用户不知道需要从哪里手动恢复。

请传播或聚合恢复错误,并在失败结果中保留备份目录及明确警告。

  1. 测试缺口

请补充以下用例:

  • local_thread_catalog 来源保留
  • automation/inbox 来源保留
  • Codex 并发修改索引时不覆盖新内容
  • 未知但带 id 的 JSON 记录保留
  • 回滚写入失败可见
  • 云端或延迟落盘任务边界

三平台 CI 虽然全绿,但删除判定和并发写入边界还没有被覆盖。修复以上阻塞项后再考虑合并。

@Yuimi-chaya

Copy link
Copy Markdown
Contributor Author

感谢详细审查。指出的风险成立,我已在提交 cbc0251 中重新设计索引清理流程,而不是继续在原有自动清理逻辑上追加判断。

1. 补全存活来源

已纳入:

  • threads.id
  • local_thread_catalog.thread_id
  • automation_runs.thread_id
  • inbox_items.thread_id
  • sessions.id
  • messages.session_id
  • thread_dynamic_tools.thread_id
  • thread_goals.thread_id
  • thread_spawn_edges.parent_thread_id/child_thread_id
  • stage1_outputs.thread_id
  • agent_job_items.assigned_thread_id
  • 两类 rollout 的 session_meta.payload.id
  • 损坏 rollout 文件名末尾的合法 UUID

SQLite 路径发现也已对齐 #1438local_thread_catalog,并覆盖仅含关联表的数据库。

2. 处理 Codex 运行时覆盖竞争

普通供应商同步和启动前自动同步现在完全不会清理 session_index.jsonl

清理被拆分为独立的“预览 → 用户确认 → 应用”流程。实际用户目录执行清理时,会在开始和原子替换前分别检查 Codex App / ChatGPT 进程;App 未完全退出时拒绝执行。

同时加入 SHA-256 快照校验和写入前逐字节复核。预览后若索引被新建、重命名或同步任务改动,本次清理会直接中止。

写入使用同目录临时文件原子替换。Windows 使用 MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH),其他平台使用同文件系统 rename

3. 收紧已知 schema

现在只有严格匹配 idthread_nameupdated_at 三字段的当前已知记录才会进入候选列表。

无法解析的行、缺少字段、包含额外字段以及其他未知结构都会原样保留。任务标题只用于预览,不参与删除判定。

4. 增加 dry-run、完整预览和显式确认

普通同步永不删除索引。

管理器会先执行无副作用预览,完整展示全部候选记录及 thread ID,默认不勾选。用户可以逐项选择或全选,后端只接收实际勾选的 ID。

弹窗也明确提示候选项可能是云端或尚未落盘的任务,并要求清理前退出 Codex App / ChatGPT。

5. 消除静默回滚问题

索引清理现在是独立的单文件原子操作,不再先覆盖索引后依赖静默回滚。

修改前会完整备份原始 session_index.jsonl。备份目录一旦创建,后续元数据写入、并发校验或原子替换失败都会在结果中保留实际 backupDir,并给出明确的人工恢复提示。

6. 补充测试

当前验证包括:

  • provider_sync:22/22
  • storage_adapter:18/18
  • relay_config:95/95
  • Windows replace-existing 原子写专项测试
  • Tauri manager cargo check
  • TypeScript 检查
  • i18n:602/602、50/50

回归用例覆盖 local_thread_catalog、automation/inbox、仅有关联表的数据库、损坏 rollout 文件名、未知但带 id 的结构、普通同步零删除、预览后并发修改、原子写失败保留原文件和备份路径可见等边界。

PR 正文也已经按照当前实现重新整理。新一轮三平台 CI 正在运行,请继续审查。

@BigPizzaV3

Copy link
Copy Markdown
Owner

感谢根据上一轮审查重构。自动删除已经移除,存活来源、严格 schema、预览确认、快照校验、备份和原子替换等主要风险目前都处理得比较完整。

二次审查仍发现一个阻塞边界:Windows 上的“Codex App / ChatGPT 必须完全退出”检查会漏掉便携版或非 WindowsApps 安装的 ChatGPT.exe

当前 find_codex_processes_from_snapshot() 只收集:

  • WindowsApps 支持包路径中的进程;
  • 文件名严格为 Codex.exe 的本地/便携进程。

代码注释还明确排除了普通独立 ChatGPT.exe。但本仓库已经支持 OpenAI.ChatGPT-Desktop_* 和便携启动场景;当这类 ChatGPT/Codex 集成应用不位于 WindowsApps 时,清理仍可能继续执行,而该进程可能同时写入 session_index.jsonl。两次快照比较只能缩小竞争窗口,不能替代正确的运行进程拦截。

建议为删除流程增加更严格、独立的进程保护:清理期间宁可把任何受支持的 ChatGPT.exe 视为正在运行并拒绝操作,也不要复用 watcher 中为了避免误判普通 ChatGPT 而放宽的检测逻辑。请同时补充 Windows 回归测试,至少覆盖 WindowsApps ChatGPT、便携版 ChatGPT.exeCodex.exe 和 CLI codex.exe

这个安全边界补齐后再考虑合并。

@Yuimi-chaya

Copy link
Copy Markdown
Contributor Author

感谢指出。已在最新提交中增加独立于 Watcher 的删除流程进程保护:任何 WindowsApps 或便携版 ChatGPT.exe、桌面 Codex.exe 运行时都会拒绝清理,CLI codex.exe 不受影响,原 Watcher 的识别策略保持不变。

同时将第二次进程检查移动到最终字节复核之后、原子替换之前。已补充覆盖 WindowsApps ChatGPT、便携 ChatGPT、Codex App 和 CLI 的 Windows 回归测试。

当前验证结果:

  • Watcher:18/18
  • provider-sync:22/22
  • manager / launcher Cargo check 通过
  • Windows、macOS x64、macOS arm64 CI 全部通过

对应远端提交为 788b1c6,请继续审查。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants