From 2ad2235dc19a66d58b25c1bc446c0dc08a2e9883 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Mon, 28 Sep 2026 10:27:58 +0800 Subject: [PATCH 1/4] fix(core): sync anchored paginated rollout groups --- .../e2e/desktop-production-boundary.spec.mjs | 87 ++++ docs/WORKING_PRINCIPLE_ZH.md | 4 + .../0047-paginated-provider-associations.md | 31 ++ .../architecture/NODE_CORE_ARCHITECTURE_ZH.md | 11 +- .../architecture/contracts/CLI_CONTRACT_ZH.md | 2 + .../contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md | 2 + docs/architecture/contracts/ERROR_CODES_ZH.md | 2 + docs/migration/BEHAVIOR_FIXTURES_ZH.md | 4 +- .../core/src/application/provider-sync.js | 41 +- .../src/infrastructure/node-core-ports.js | 2 + src/operation-revision.js | 46 +- src/provider-skips.js | 32 +- src/session-files.js | 14 +- src/sqlite-state.js | 13 + test-support/desktop-sync-switch-fixture.mjs | 107 +++- test/provider-paginated-groups.test.js | 465 ++++++++++++++++++ test/provider-preparation-facts.test.js | 43 ++ test/provider-skip-associations.test.js | 65 ++- 18 files changed, 928 insertions(+), 43 deletions(-) create mode 100644 docs/adr/0047-paginated-provider-associations.md create mode 100644 test/provider-paginated-groups.test.js diff --git a/apps/desktop/e2e/desktop-production-boundary.spec.mjs b/apps/desktop/e2e/desktop-production-boundary.spec.mjs index 3af88fa..30be04d 100644 --- a/apps/desktop/e2e/desktop-production-boundary.spec.mjs +++ b/apps/desktop/e2e/desktop-production-boundary.spec.mjs @@ -786,6 +786,93 @@ test("production or unpacked desktop completes real Sync and Restore through Uti } }); +test("production desktop syncs one paginated thread group, noops, and restores through Utility Core", async () => { + test.setTimeout(PRODUCTION_SMOKE_TIMEOUT_MS); + const fixture = await createDesktopSyncSwitchFixture({ paginatedGroup: true }); + await claimDailyUpdateCheck(fixture.userData); + const baseline = await fixture.inspectPaginatedGroup(); + expect(baseline.rollouts.map((rollout) => rollout.ordinal)).toEqual([7, 7]); + expect(baseline.rollouts.every((rollout) => rollout.metadata.history_mode === "paginated")).toBe(true); + expect(baseline.sqlite.rolloutPath).toBe(fixture.rolloutPath); + let electronApp; + try { + electronApp = await launchProductionDesktop({ + args: [ + ...(packagedExecutable ? [] : [path.join(desktopRoot, "out", "main", "index.js")]), + `--user-data-dir=${fixture.userData}`, + "--lang=en-US" + ], + env: { + ...process.env, + CODEX_HOME: fixture.codexHome, + CPS_DESKTOP_E2E: "1", + CPS_DESKTOP_WINDOW_DISPLAY: "hidden", + ELECTRON_ENABLE_SECURITY_WARNINGS: "true" + } + }); + const page = await electronApp.firstWindow(); + await waitForProductionReady(page); + + const result = page.getByRole("dialog", { name: "Operation result" }); + const directSync = page.getByRole("button", { name: "Sync now", exact: true }); + await expect(directSync).toBeEnabled(); + await directSync.click(); + await expect(result.getByRole("heading", { name: "Completed", exact: true })).toBeVisible({ + timeout: PRODUCTION_OPERATION_TIMEOUT_MS + }); + const synced = await fixture.inspectPaginatedGroup(); + expect(synced.rollouts).toHaveLength(2); + for (const [index, rollout] of synced.rollouts.entries()) { + const { model_provider: provider, ...metadata } = rollout.metadata; + const { model_provider: originalProvider, ...originalMetadata } = baseline.rollouts[index].metadata; + expect(provider).toBe("prov_a"); + expect(originalProvider).toBe("openai"); + expect(metadata).toEqual(originalMetadata); + expect(rollout.headerWithoutProvider).toEqual(baseline.rollouts[index].headerWithoutProvider); + expect(rollout.bodyHash).toBe(baseline.rollouts[index].bodyHash); + expect(rollout.ordinal).toBe(baseline.rollouts[index].ordinal); + expect(rollout.turnContext).toEqual(baseline.rollouts[index].turnContext); + expect(rollout.body).toBe(baseline.rollouts[index].body); + } + expect(synced.sqlite).toEqual({ ...baseline.sqlite, provider: "prov_a" }); + expect(synced.sqlite.rolloutPath).toBe(fixture.rolloutPath); + expect(synced.backupIds).toHaveLength(1); + const syncBackupId = synced.backupIds[0]; + const syncLogs = await page.evaluate(() => window.codexProvider.operationLogs.list({ schemaVersion: 1, page: 1, pageSize: 50, operation: "sync" })); + const completedSync = syncLogs.entries.find((entry) => entry.status === "completed"); + expect(completedSync.counts.changedSessionFiles).toBe(2); + expect(completedSync.counts.sqliteRowsUpdated).toBe(1); + await result.getByRole("button", { name: "Close", exact: true }).last().click(); + + await expect(directSync).toBeEnabled({ timeout: PRODUCTION_OPERATION_TIMEOUT_MS }); + await directSync.click(); + await expect(result.getByRole("heading", { name: "Completed", exact: true })).toBeVisible({ + timeout: PRODUCTION_OPERATION_TIMEOUT_MS + }); + await result.getByRole("button", { name: "Close", exact: true }).last().click(); + const afterNoop = await fixture.inspectPaginatedGroup(); + expect(afterNoop.rollouts).toEqual(synced.rollouts); + expect(afterNoop.sqlite).toEqual(synced.sqlite); + expect(afterNoop.backupIds).toEqual([syncBackupId]); + + await page.getByRole("button", { name: "Backups / Restore", exact: true }).click(); + await page.getByRole("button", { name: new RegExp(syncBackupId) }).click(); + await page.getByRole("button", { name: "Preview restore", exact: true }).click(); + const restoreDialog = page.getByRole("dialog", { name: "Confirm restore" }); + await expect(restoreDialog).toBeVisible(); + await restoreDialog.getByRole("button", { name: "Confirm restore", exact: true }).click(); + await expect(restoreDialog).toBeHidden({ timeout: PRODUCTION_OPERATION_TIMEOUT_MS }); + await expect(result.getByRole("heading", { name: "Completed", exact: true })).toBeVisible({ + timeout: PRODUCTION_OPERATION_TIMEOUT_MS + }); + const restored = await fixture.inspectPaginatedGroup(); + expect(restored.rollouts).toEqual(baseline.rollouts); + expect(restored.sqlite).toEqual(baseline.sqlite); + } finally { + try { await electronApp?.close(); } finally { await fixture.close(); } + } +}); + for (const mixed of [false, true]) test(mixed ? "production desktop syncs healthy mixed data and restores" : "production desktop syncs and restores large session metadata", async () => { test.setTimeout(PRODUCTION_SMOKE_TIMEOUT_MS); diff --git a/docs/WORKING_PRINCIPLE_ZH.md b/docs/WORKING_PRINCIPLE_ZH.md index e8fd9dd..ba30e74 100644 --- a/docs/WORKING_PRINCIPLE_ZH.md +++ b/docs/WORKING_PRINCIPLE_ZH.md @@ -4,6 +4,10 @@ 问题会话会被跳过,其关联索引保留原样;正常会话继续同步/切换。有跳过会显示“部分完成”,在预览、结果和操作日志可查看原因及本机完整路径(最多 200 项)。处理问题后重新预览即可纳入;全部历史跳过时,切换仍会备份并更新配置。数据库/目录/备份等全局故障仍停止,已写入时保留备份。诊断导出移除路径和索引标识。 +同一线程有多个 rollout 文件时,只有每个文件首行都明确标为分页历史、线程 ID 与 SQLite 一致,而且 SQLite 记录的 rollout 路径能唯一指向组内一个有效文件,才会把它们作为同一线程同步。文件的 ordinal 只是位置,重复并不表示冲突或先后关系。缺少分页标记、路径无法唯一确认、跨线程或多个记录争用时,工具会保留索引并提示部分完成。组内任一文件被锁定、变化或写入失败,该线程的 SQLite Provider 也会保留,下一次重新同步再收敛;如果文件已同步而 SQLite 未同步,重试只会补齐 SQLite,之后的同步是 noop,不会新建备份。 + +Windows 会识别普通路径与 `\\?\`、UNC 与 `\\?\UNC\` 的等价写法后再检查是否仍在 Codex Home 内;不会改写数据库保存的路径,也不会因此接受设备、越界或 WSL 路径。同步期间新出现的文件本轮只做有限首行确认并延后处理;未知或损坏的新文件会保守保留相关 SQLite 行。 + > 适用于当前 V1 Node Core(CLI / Web / Electron)。Legacy .NET 的历史行为不作为新实现规范。 > 用户安装见 [README](../README.md);开发约束、模块映射及测试入口以 [Node Core 当前架构](architecture/NODE_CORE_ARCHITECTURE_ZH.md) 为准,本文只解释原理。 diff --git a/docs/adr/0047-paginated-provider-associations.md b/docs/adr/0047-paginated-provider-associations.md new file mode 100644 index 0000000..d6dda86 --- /dev/null +++ b/docs/adr/0047-paginated-provider-associations.md @@ -0,0 +1,31 @@ +# ADR-0047:分页 rollout 的受锚定同线程关联 + +- 状态:Accepted +- 日期:2026-09-28 +- 范围:共享 Node Core 的 Provider Sync、Switch、Watch;不改变公开 API、CLI 参数、协议版本、错误码或 Legacy .NET。 + +## 背景 + +同一 Codex 线程可留下多个 rollout 文件。已有逐文件跳过规则会在缺少明确归属时保留冲突保护,但不能把“同 ID”本身当作多文件可写的证明。特别是回退可创建新的同线程文件,`ordinal` 只是记录位置,可能重复,不能用于身份、唯一性或重建历史继承链。 + +## 决策 + +同一 ID 的多个 rollout 只有同时满足下列条件,才作为一个分页组参与 Provider 同步:每个成员首行的 `payload.history_mode` 都明确为 `paginated`;可信首行 ID 与 SQLite `threads.id` 相同;该 SQLite 行的 `rollout_path` 经过 Windows 等价路径规范化和 Home 边界验证后,锚定组内恰好一个有效成员;不存在跨 ID 路径或多个 SQLite owner 对同一文件/组的争用。该组内所有成员是同一 SQLite 行的写前提,任何成员跳过、变化、删除、锁定或写失败,都保留该行并返回 partial;健康的其他组仍按既有规则继续。 + +缺少 marker、legacy/非 paginated、多文件但没有路径锚点、跨 ID,或多个 owner 的情形仍是 `association-conflict`/`association-unknown`,不推测归属。单文件候选沿用 ADR-0045 的旧选择规则。`ordinal` 不参与上述判断。 + +Windows 比较路径时先统一普通 DOS 与 `\\?\` DOS、普通 UNC 与 `\\?\UNC\` 的等价表示,再做现有 Home 边界检查。数据库保存的原始 `rollout_path` 不改写;设备路径、越界路径和 WSL 限制不放宽。 + +首行扫描、冻结计划与跳过事实在内部保留可信 ID 和 `historyMode`。锁定、变化、消失等后续事实保持原归属,以便准确排除 SQLite 行与 Restore 范围。 + +Apply 新发现的文件只读取有界首行确认归属,始终 deferred,不能扩大本轮写集合。已知同 ID 的新增或变化成员阻止对应行更新;未知/坏新增文件保守阻止所有已知 paginated 候选(包括单文件候选)的 SQLite 更新。传统单文件已有 unknown 语义保持。SQLite 提交前复核整个相关组,包括原本已对齐、无需改写的成员;区分本轮流式替换后的新身份与外部并发修改。该复核降低竞态,但不承诺最后检查后与外部写者的原子隔离。 + +SQLite 路径归属在备份前按当前快照重新选择,并在既有原生写事务中按全部当前索引行再次核验,包含已对齐行和执行期间新增行。新增或变更 owner 争用分页成员时保留受影响行;写候选只能从冻结集合缩减,健康组仍可继续。 + +PIO-1~PIO-6、原地与流式写策略、UndoBackup、partial、原生 SQLite 事务及既有写前/写后校验保持。SQLite-only 的半同步状态在组内全部文件已对齐时可于重试补齐行;随后 noop 不创建备份。 + +## 验证与文档边界 + +新增 `test/provider-paginated-groups.test.js` 以及 Provider facts/现有 I/O 单元回归,Windows 使用 `D:\Temp` 合成 Home,其他平台使用系统临时目录:正常多文件、SQLite-only 半同步、合法重复 ordinal、传统重复 ID/错锚/多 owner、锁定/删除/首行变化/写失败、新增/未知归属、普通与归档路径别名及越界反例。覆盖等长原地、变长流式、Restore 的正文、其他 metadata、文件身份与恢复范围。生产 Electron fixture 还应执行隐藏窗口的 Sync → noop → Restore。 + +实施变更须运行 `npm run architecture:check` 与 `npm test`,并重建生产 Electron、检查 production bundle 后进行该合成 fixture。以上是要求的验证,不构成已通过、跨平台 CI、实际安装或真实用户现场的声明。 diff --git a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md index b611e5f..49ec663 100644 --- a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md +++ b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md @@ -2,9 +2,9 @@ Provider 首行新增严格 UTF-8 和对象 payload 校验;处理容量失败仅在序列化/语义比较边界逐文件跳过,不设固定嵌套上限。默认共享 Repair/Restore 读取规则不变。 -当前 Provider 跳过合同见 [ADR-0045](../adr/0045-isolated-provider-data-skips.md):内部逐文件/逐行计划绑定、关联索引排除、已知未写恢复范围及有界本机日志。`test/provider-skip-data.test.js` 覆盖混合数据、未知归属、冻结排除、删除及全部跳过。全局故障和 Repair/Restore 仍严格;不完整状态不能宣称对齐。 +当前 Provider 跳过合同见 [ADR-0045](../adr/0045-isolated-provider-data-skips.md),分页同线程多文件关联见 [ADR-0047](../adr/0047-paginated-provider-associations.md):内部逐文件/逐行计划绑定、可信 ID/historyMode、关联索引排除、已知未写恢复范围及有界本机日志。分页组只在全部成员明确 `history_mode=paginated`、SQLite 同 ID 且规范化 `rollout_path` 唯一锚定组内成员时放行;ordinal 不用于身份或唯一性。任一成员跳过即保留该行,未知新增保守阻止已知分页候选行。`test/provider-skip-data.test.js` 与 `test/provider-paginated-groups.test.js` 覆盖混合数据、未知归属、冻结排除、删除及组收敛。全局故障和 Repair/Restore 仍严格;不完整状态不能宣称对齐。 -> 状态:Accepted,适用于 V1 当前代码;最近校对:2026-09-08(已纳入 ADR-0038)。 +> 状态:Accepted,适用于 V1 当前代码;最近校对:2026-09-28(已纳入 ADR-0047)。 > 本文是 Node Core 日常开发入口,不是发布证明。Electron 总体路线仍见 [vNext 架构基线](../VNEXT_ELECTRON_NODE_ARCHITECTURE_ZH.md),阶段及发布状态只记在[执行索引](../migration/VNEXT_MIGRATION_EXECUTION_INDEX_ZH.md)。 ## 1. 文档职责与变更规则 @@ -74,6 +74,8 @@ Windows 写目标占用探测的私有协议位于 `src/windows-lock-probe.js` Sync 目标始终取 `config.toml` 根级 `model_provider`,缺失时为 `openai`。公共输入不接受 `provider/model/fast/syncMode`。正常扫描经 `collectProviderChanges`,只解析第一行 `session_meta`(上限 128 MiB UTF-8 内容字节,不含 LF/CRLF;见 [ADR-0044](../adr/0044-large-session-metadata.md));不得为模型、cwd、用户事件、加密字段、会话序号或历史显示索引扫描正文。 +按 ADR-0047,首行事实保留可信 ID 与 `payload.history_mode`,只允许明确 `paginated` 的同 ID 多文件按受 SQLite `rollout_path` 唯一锚定的组归属;不以 `ordinal` 判断文件身份、先后或继承。Windows 等价路径先统一 DOS/`\\?\` DOS、UNC/`\\?\UNC\` 后做既有 Home 边界检查,原数据库路径不改写,设备/越界/WSL 仍拒绝。缺 marker、legacy、多 owner、跨 ID 或无锚点多文件保持冲突保护;单文件规则不变。 + 这里“只读首行”指**业务扫描边界**:底层按 64 KiB 分块,可能在包含换行的块中预读少量尾部。按 [ADR-0037](../adr/0037-switch-history-and-provider-preparation-facts.md),Sync/Switch Prepare 的分布、revision 和 change descriptor 复用同一次首行事实,短期记录不进入计划 ledger/公开输入/持久缓存。Apply 锁内复核、实际目标重扫和落盘前仍重新读取。不能声称整个 Sync 只读一次或只读取 Provider 的几个字节。备份/哈希及变长复制有各自 I/O,但不能借此恢复正文业务扫描。 ### PIO-2:合格的等长更新必须原地写 @@ -111,6 +113,10 @@ Sync 不改根 config、历史 model、cwd、user-event、workspace roots、titl → SQLite 原生事务提交 → 结果 / 受管备份清理 → 释放 Home 锁 ``` +分页组的 SQLite 行只有全体成员均已对齐或本次成功写入才可更新。Apply 新增文件仅有界读首行并 deferred,不扩大写集合;已知同 ID 新增/变化阻止对应行,未知/坏新增保守阻止全部已知分页候选行。提交前重核相关整组,包含原先已对齐成员,并区分本轮流式替换和外部变化;这是重试收敛保护,不是外部并发原子隔离。 + +路径归属在备份前和既有 SQLite 写事务内按全部当前行重验,包含已对齐行与新行。新增或改变 owner 导致争用时只缩减冻结候选,保留受影响行,不阻止无关健康组。 + 只使用 `/tmp/provider-sync.lock`;跨 Home 的 SQLite 竞争交给原生事务。预检不预留未来数据库写锁:之后仍可能 busy。首次 mutation 前失败零业务写入;mutation 后失败为带 backupId/failedStage/failureCode/retryRecommended 的 `partial`,重新 Prepare/执行收敛,或用户手动 Restore。 不创建普通跨文件 journal,不自动全量回滚。SQLite 自身的 ROLLBACK 与单文件字节写入失败的局部复原不是全操作回滚。锁定/变化 rollout 分别报告,不能吞掉或误报 completed。全被跳过也可 partial 且无备份,不能因“没写”就报健康。 @@ -163,6 +169,7 @@ npm test | 字节描述符、歧义输入、原地失败与恢复兼容 | [in-place-transaction.test.js](../../test/in-place-transaction.test.js) | | Windows 原生原地写、共享 worker 和占用/变化 | [windows-rewrite-worker.test.js](../../test/windows-rewrite-worker.test.js) | | Prepare 单轮首行事实、Apply 重新复核及时间戳 | [provider-preparation-facts.test.js](../../test/provider-preparation-facts.test.js) | +| 分页同线程组、路径锚点、半同步收敛及新增文件防护 | [provider-paginated-groups.test.js](../../test/provider-paginated-groups.test.js) | | 可选数值计时投影、观测错误隔离和 Desktop 日志 | [file-update-timing-json.test.js](../../test/file-update-timing-json.test.js)、[contracts 检查](../../packages/contracts/checks/file-update-timing.contract.mjs)、[Desktop 日志测试](../../apps/desktop/tests/file-update-timing-log.test.mjs) | | Plan/revision/单次消费/取消、Home 协调 | [plan-apply.test.js](../../test/plan-apply.test.js)、[status-coordination.test.js](../../test/status-coordination.test.js) | | Restore 独立 crash matrix | [restore-v2-state-machine.test.js](../../test/restore-v2-state-machine.test.js) | diff --git a/docs/architecture/contracts/CLI_CONTRACT_ZH.md b/docs/architecture/contracts/CLI_CONTRACT_ZH.md index 16ab9cb..437c592 100644 --- a/docs/architecture/contracts/CLI_CONTRACT_ZH.md +++ b/docs/architecture/contracts/CLI_CONTRACT_ZH.md @@ -4,6 +4,8 @@ ADR-0045 当前增量:Provider Sync/Switch/Watch 将单条首行无效、128 MiB 输入/输出超限、占用/不可读、消失/变化及明确未损坏源文件的写入失败列为跳过,正常候选继续;对应 SQLite 行及不确定关联保持原样。计划排除集合冻结,新增数据留待下次;全局安全、目录枚举、配置/存储、数据库和备份故障仍停止。部分完成新增有界 `skipSummary`(最多 200 项本机完整路径/安全行标识、原因/阶段/可重试性及总数/省略/未确认数);诊断导出单独移除路径和标识。全部跳过的 Sync 无备份,Switch 仍备份并切换配置。JSON 部分完成退出 3,Human 保持既有行为;协议结构版本不变。保留 128 MiB 与 PIO,Repair/Restore 边界不变。详见 [ADR-0045](../../adr/0045-isolated-provider-data-skips.md)。 +ADR-0047 当前增量:分页同线程组在内部按可信 ID、明确 `history_mode=paginated` 和 SQLite `rollout_path` 的唯一规范化锚点选择,`ordinal` 不影响 CLI 行为。成员跳过、新增/变化或未知新增导致关联行保留时,仍返回既有 partial/`skipSummary` 和 JSON exit 3;半同步重试只补 SQLite 后随后的 noop 不建备份。未增加命令、参数、JSON schema、协议版本或错误码;Human 输出保持既有 partial 表述。Windows 路径别名只在内部比较时规范化,CLI 不改写数据库路径。详见 [ADR-0047](../../adr/0047-paginated-provider-associations.md)。 + ADR-0041 增量:Status/Diagnostics JSON 可选 `staleLockDetected:boolean` 表示发现已证明失效的 Home 锁,不表示已删除锁;检查保持只读。正常写命令重新验证后回收失效锁。未知 owner 仍为 `LOCK_UNVERIFIABLE`,写命令 JSON 退出码仍为 5。 > 状态:Accepted(Phase 0 Human 兼容基线;ADR-0016 C2/C3 增量已实现) diff --git a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md index 11fc8da..9c32b40 100644 --- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md +++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md @@ -4,6 +4,8 @@ ADR-0045 当前增量:Provider Sync/Switch/Watch 将单条首行无效、128 MiB 输入/输出超限、占用/不可读、消失/变化及明确未损坏源文件的写入失败列为跳过,正常候选继续;对应 SQLite 行及不确定关联保持原样。计划排除集合冻结,新增数据留待下次;全局安全、目录枚举、配置/存储、数据库和备份故障仍停止。部分完成新增有界 `skipSummary`(最多 200 项本机完整路径/安全行标识、原因/阶段/可重试性及总数/省略/未确认数);诊断导出单独移除路径和标识。全部跳过的 Sync 无备份,Switch 仍备份并切换配置。JSON 部分完成退出 3,Human 保持既有行为;协议结构版本不变。保留 128 MiB 与 PIO,Repair/Restore 边界不变。详见 [ADR-0045](../../adr/0045-isolated-provider-data-skips.md)。 +ADR-0047 增量:同 ID 多文件只有所有首行明确 `payload.history_mode=paginated`、可信 ID 匹配 SQLite 且规范化 `rollout_path` 唯一锚定组内有效成员、无跨 ID/多 owner 时才可共享一行;`ordinal` 不参与身份或唯一性。组内任一成员跳过即保留该行,文件均已对齐时允许 SQLite-only 重试补齐,下一次为 noop 且不建备份。Apply 新增文件只作有界首行读取并 deferred;已知同 ID 新增/变化阻止对应行,未知/坏新增保守阻止所有已知 paginated 候选行。提交前复核全组(含原已对齐成员),不把本轮流式替换误判成外部变化。Windows 仅将 DOS/`\\?\` DOS、UNC/`\\?\UNC\` 视为等价后再做既有边界检查;DB 原路径、设备/越界/WSL 规则不变。无 marker/legacy/无锚点多文件仍冲突,单文件旧规则不变。公开 schema/API、错误码及 partial 语义不变。详见 [ADR-0047](../../adr/0047-paginated-provider-associations.md)。 + 预览时已对齐的 SQLite 行也参与执行前复核:Provider/关联路径变化或行消失时保留现场并报告部分完成。已有 SQLite 写事务内再次核对完整预览快照,实际 UPDATE 始终限于原确认候选;纯观察不新增写事务或备份。 ## 2026-09-11:轻量状态相关性(ADR-0043) diff --git a/docs/architecture/contracts/ERROR_CODES_ZH.md b/docs/architecture/contracts/ERROR_CODES_ZH.md index 88b0fc1..195d590 100644 --- a/docs/architecture/contracts/ERROR_CODES_ZH.md +++ b/docs/architecture/contracts/ERROR_CODES_ZH.md @@ -4,6 +4,8 @@ ADR-0045 当前增量:Provider Sync/Switch/Watch 将单条首行无效、128 MiB 输入/输出超限、占用/不可读、消失/变化及明确未损坏源文件的写入失败列为跳过,正常候选继续;对应 SQLite 行及不确定关联保持原样。计划排除集合冻结,新增数据留待下次;全局安全、目录枚举、配置/存储、数据库和备份故障仍停止。部分完成新增有界 `skipSummary`(最多 200 项本机完整路径/安全行标识、原因/阶段/可重试性及总数/省略/未确认数);诊断导出单独移除路径和标识。全部跳过的 Sync 无备份,Switch 仍备份并切换配置。JSON 部分完成退出 3,Human 保持既有行为;协议结构版本不变。保留 128 MiB 与 PIO,Repair/Restore 边界不变。详见 [ADR-0045](../../adr/0045-isolated-provider-data-skips.md)。 +ADR-0047 修订关联选择而不新增错误码:缺少分页 marker、无路径锚点、跨 ID 或多 owner 的同 ID 多文件继续使用既有 `association-conflict`/`association-unknown` 跳过语义;组成员锁定、变化、消失、写失败及 Apply 的 deferred 新增继续使用既有原因、阶段和 partial/exit 3。SQLite-only 半同步补齐和后续 noop 不产生新结果码。详见 [ADR-0047](../../adr/0047-paginated-provider-associations.md)。 + ADR-0041 修订:全部 owner 已证明失效时,内部锁观察返回 `stale`,不再把该情形当作 `LOCK_UNVERIFIABLE`;Status 不创建虚假的进行中操作。任何未知、变动或不可验证 owner 仍按原码阻止写入,未新增错误码。 > **状态:Accepted(阶段 0 合同;ADR-0016 C2/C3 轻量写增量已实施)** diff --git a/docs/migration/BEHAVIOR_FIXTURES_ZH.md b/docs/migration/BEHAVIOR_FIXTURES_ZH.md index e7cefed..df4b0ac 100644 --- a/docs/migration/BEHAVIOR_FIXTURES_ZH.md +++ b/docs/migration/BEHAVIOR_FIXTURES_ZH.md @@ -2,7 +2,7 @@ `test/provider-header-validation.test.js` 覆盖非法 UTF-8、数组 payload、序列化及语义比较容量失败、有效替换字符和跨块编码、BOM、普通/归档混合数据、冻结排除、写前变化、关联索引保留及恢复范围。日志/UI/生产 Electron 混合数据验收覆盖新增原因码,继续执行 128 MiB 和 PIO 门禁。 -当前 Provider 跳过合同见 [ADR-0045](../adr/0045-isolated-provider-data-skips.md):内部逐文件/逐行计划绑定、关联索引排除、已知未写恢复范围及有界本机日志。`test/provider-skip-data.test.js` 覆盖混合数据、未知归属、冻结排除、删除及全部跳过。全局故障和 Repair/Restore 仍严格;不完整状态不能宣称对齐。 +当前 Provider 跳过合同见 [ADR-0045](../adr/0045-isolated-provider-data-skips.md),分页组选择见 [ADR-0047](../adr/0047-paginated-provider-associations.md):内部逐文件/逐行计划绑定、可信 ID/historyMode、关联索引排除、已知未写恢复范围及有界本机日志。`test/provider-skip-data.test.js` 与 `test/provider-paginated-groups.test.js` 覆盖混合数据、未知归属、冻结排除、删除、受锚定分页组和 SQLite-only 收敛。全局故障和 Repair/Restore 仍严格;不完整状态不能宣称对齐。 `test/in-place-transaction.test.js` 的 POSIX 回归覆盖写前新增硬链接按变化跳过,以及短写、零进度、fsync 故障在验证恢复成功后返回 `SKIP_NOT_APPLIED`;同时断言原字节、inode、大小、mtime 和跳过原因。写后身份冲突或恢复失败仍停止,禁止回退整文件替换。 @@ -10,6 +10,8 @@ `provider-skip-data.test.js` 验证预览时已对齐的 SQLite 行在执行前改 Provider 或消失:有/无 `rollout_path`、无写目标/混合健康目标均返回部分完成,变化行和对应 rollout 保持原样,空写集合不创建备份。 +`test/provider-paginated-groups.test.js` 是 ADR-0047 的专用合成 Home fixture(Windows 使用 `D:\Temp`,其他平台使用系统临时目录):受锚定的同 ID `history_mode=paginated` 多文件须一次写全组及一条 SQLite 行;“文件已同步、SQLite 未同步”须只补该行、下一次 noop 且不建备份;重复 ordinal 仍可成功。传统重复 ID、缺 marker、错指/越界路径、跨 ID 和多 owner 必须保留冲突保护。锁定、删除、首行变化、写入失败、新增已知同 ID 与未知/坏新增分别验证 partial、关联行保留、重新预览后的收敛;预览后和 rollout 写期间新增/改变已对齐 owner 的路径争用保留受影响行,无关健康分页组继续。普通/归档目录覆盖 DOS/`\\?\` DOS、UNC/`\\?\UNC\` 等价路径及边界反例。配套 Provider facts/PIO fixture 覆盖等长原地、变长流式、Restore 的正文 byte、其他 metadata、文件身份和恢复范围。生产 Electron 验收要求隐藏窗口真实 Utility Core 的 Sync → noop → Restore;这些是要求的验证项目,不代表已执行。 + ## ADR-0044:大首行与明确错误 `test/large-session-metadata.test.js` 覆盖 8 MiB 完整 Status→Sync/Switch→Restore、原地/变长正文不变、128 MiB LF/CRLF/EOF 读取边界与线性合并、超限/无效 Prepare 零写入及安全错误。`CPS_LARGE_HEADER_MIB=128` 可显式运行完整近上限读写。`provider-preparation-facts.test.js`、`status-coordination.test.js` 保留无效/超限拒绝及 Status 不完整检查。生产 Electron smoke 使用 8 MiB 首行验证大首行同步与恢复。 diff --git a/packages/core/src/application/provider-sync.js b/packages/core/src/application/provider-sync.js index 8446a2b..a9267ae 100644 --- a/packages/core/src/application/provider-sync.js +++ b/packages/core/src/application/provider-sync.js @@ -8,7 +8,7 @@ import { isConfiguredSqliteHome, missingConfiguredStateDbError, codexStorage, - summarizeSkips, rolloutSkip, uniqueSkips, selectProviderRows, updateSessionBackupManifest + summarizeSkips, rolloutSkip, uniqueSkips, selectProviderRows, providerPathKey, updateSessionBackupManifest } from "../infrastructure/node-core-ports.js"; import { executeOrdinaryWrite } from "./ordinary-write-runtime.js"; import { preparePlanContext } from "./plan-context.js"; @@ -18,6 +18,7 @@ import { operationRuntime, sqliteTransaction } from "./runtime-context.js"; const { applySessionChanges, collectProviderPreparationFacts, + captureAppliedProviderBinding, summarizeProviderCounts } = codexStorage.sessions; const { readCurrentProviderFromConfigText, configDeclaresProvider } = codexStorage.config; @@ -56,9 +57,12 @@ function providerResult({ context, state, current, targetProvider, scan, initial const sqliteResult = state.outputs.sqlite ?? state.data.sqliteResult ?? emptySqliteMutationResult(Boolean(context.storage.stateDbLocation)); const skippedLockedRolloutFiles = sortedUnique([ ...initiallySkipped, - ...(applyResult.skippedLockedPaths ?? []) + ...(applyResult.skippedLockedPaths ?? []), + ...(state.data.skippedItems ?? []).filter(item => item.kind === "rollout" && item.reason === "locked").map(item => item.path) ]); - const skippedChangedRolloutFiles = sortedUnique([...(scan.skippedItems ?? []).filter(item => ["changed", "missing"].includes(item.reason)).map(item => item.path), ...(applyResult.skippedChangedPaths ?? [])]); + const skippedChangedRolloutFiles = sortedUnique([...(scan.skippedItems ?? []), ...(state.data.skippedItems ?? [])] + .filter(item => item.kind === "rollout" && ["changed", "missing"].includes(item.reason)).map(item => item.path) + .concat(applyResult.skippedChangedPaths ?? [])); const skipSummary = summarizeSkips([...(scan.skippedItems ?? []), ...initiallySkipped.map(filePath => rolloutSkip(filePath, "locked", "revalidate")), ...(state.data.skippedItems ?? []), ...(sqliteResult.skippedItems ?? [])], (state.data.unconfirmed ?? 0) + (state.data.sqliteUnconfirmed ?? 0)); @@ -163,6 +167,11 @@ export async function buildProviderWriteProgram(context, settings = {}) { throw new CoreError("STALE_STATE", "The thread index changed before backup.", { details: { reason: "state-db" } }); } const currentRows = new Map(currentState.rows.map(row => [String(row.id), row])); + const currentAssociations = selectProviderRows(context.codexHome, scan, currentState, targetProvider, + initiallySkipped.map(filePath => rolloutSkip(filePath, "locked", "revalidate"))); + const currentEligible = new Set(currentAssociations.rows.map(row => String(row.id))); + selection.rows = selection.rows.filter(row => currentEligible.has(String(row.id))); + selection.skippedItems.push(...currentAssociations.skippedItems.filter(item => expectedRowIds.has(item.id))); const changedRows = new Set(); for (const expected of sqliteState.rows) { const id = String(expected.id); @@ -178,6 +187,11 @@ export async function buildProviderWriteProgram(context, settings = {}) { } } const sqliteRowsToWrite = selection.rows.length; + const paginatedPaths = new Set(scan.files.filter(file => file.historyMode === "paginated") + .map(file => providerPathKey(file.path))); + const protectedPaths = new Set(selection.rows.flatMap(row => row.paths) + .filter(filePath => paginatedPaths.has(providerPathKey(filePath))).map(providerPathKey)); + const finalBindings = new Map(facts.rollout.fileBindings.map(file => [providerPathKey(file.path), file])); selection.skippedItems.push(...(context.expectedPlanState?.providerScope?.excludedRows ?? [])); const initialSkips = uniqueSkips([...(scan.skippedItems ?? []), ...initiallySkipped.map(filePath => rolloutSkip(filePath, "locked", "revalidate")), ...selection.skippedItems]); @@ -281,6 +295,15 @@ export async function buildProviderWriteProgram(context, settings = {}) { partialRollout.appliedChanges += 1; if (mutation.result === "APPLIED_IN_PLACE") partialRollout.inPlaceChanges += 1; writeContext.markMutation(); + const key = providerPathKey(change.path); + if (protectedPaths.size > 0) { + const binding = await captureAppliedProviderBinding(change, finalBindings.get(key), mutation.result); + finalBindings.set(key, binding); + if (binding.skip) { + writeSkips.push(binding.skip); + state.data.skippedItems.push(binding.skip); + } + } await writeContext.faultInjector?.({ point: "after_rollout_mutation_before_applied", path: change.path, mutation }); }, onUnwritten: change => { definitelyUnwritten.add(change.path); }, @@ -291,6 +314,8 @@ export async function buildProviderWriteProgram(context, settings = {}) { : result === "SKIP_UNREADABLE" ? "unreadable" : result === "SKIP_NOT_APPLIED" ? "write-not-applied" : "changed"; const skip = rolloutSkip(change.path, reason, "write", change.threadId); writeSkips.push(skip); + const key = providerPathKey(change.path); + if (finalBindings.has(key)) finalBindings.set(key, { ...finalBindings.get(key), skip }); state.data.skippedItems.push(skip); partialRollout.skippedPaths.push(change.path); if (reason === "locked") partialRollout.skippedLockedPaths.push(change.path); @@ -313,11 +338,16 @@ export async function buildProviderWriteProgram(context, settings = {}) { stage: "update_sqlite", complete: (result) => ({ updatedRows: result.updatedRows }), run: async ({ context: writeContext, state }) => { - const finalSelection = selectProviderRows(context.codexHome, scan, sqliteState, targetProvider, + // Reuse the bounded reader/manifest. Only the originally + // admitted rows may survive; new files are association-only. + const finalScan = protectedPaths.size > 0 + ? (await collectProviderPreparationFacts(context.codexHome, targetProvider, { expectedFiles: [...finalBindings.values()] })).scan + : scan; + const finalSelection = selectProviderRows(context.codexHome, finalScan, sqliteState, targetProvider, [...initiallySkipped.map(filePath => rolloutSkip(filePath, "locked", "revalidate")), ...writeSkips]); const eligible = new Set(selection.rows.map(row => String(row.id))); finalSelection.rows = finalSelection.rows.filter(row => eligible.has(String(row.id))); - state.data.skippedItems = uniqueSkips([...(state.data.skippedItems ?? []), ...selection.skippedItems, ...finalSelection.skippedItems]); + state.data.skippedItems = uniqueSkips([...(state.data.skippedItems ?? []), ...(finalScan.skippedItems ?? []), ...selection.skippedItems, ...finalSelection.skippedItems]); const result = await sqliteTransaction.updateProvider(writeContext.storage, targetProvider, { busyTimeoutMs: writeContext.sqliteBusyTimeoutMs, plannedRows: finalSelection.rows, @@ -325,6 +355,7 @@ export async function buildProviderWriteProgram(context, settings = {}) { expectedSchema: sqliteState.schema, expectedIdentity: sqliteState.identity, expectedRowIds: [...expectedRowIds], + providerAssociations: { codexHome: context.codexHome, files: finalScan.files, skippedItems: finalScan.skippedItems }, onCommitAttempt: result => { state.data.sqliteUnconfirmed = result.updatedRows; if (result.updatedRows > 0) writeContext.markMutation(); diff --git a/packages/core/src/infrastructure/node-core-ports.js b/packages/core/src/infrastructure/node-core-ports.js index f12edce..e7e7498 100644 --- a/packages/core/src/infrastructure/node-core-ports.js +++ b/packages/core/src/infrastructure/node-core-ports.js @@ -50,6 +50,7 @@ import { captureBackupRevision, captureOperationRevisions, collectProviderPreparationFacts, + captureAppliedProviderBinding, captureStorageRevision, revisionMismatch, sha256Revision, @@ -126,6 +127,7 @@ export const codexStorage = createCodexStorage({ collectDiagnosticsFacts, collectProviderChanges, collectProviderPreparationFacts, + captureAppliedProviderBinding, collectRepairChanges, collectSessionChanges, collectStatusRolloutMetadata, diff --git a/src/operation-revision.js b/src/operation-revision.js index 2b87591..c16a60f 100644 --- a/src/operation-revision.js +++ b/src/operation-revision.js @@ -4,7 +4,7 @@ import path from "node:path"; import { CoreError } from "./core-error.js"; import { fileReadSkipReason, rolloutSkip, providerPathKey } from "./provider-skips.js"; -import { collectProviderChanges, readProviderRevisionHeader } from "./session-files.js"; +import { collectProviderChanges, readProviderRevisionHeader, providerAssociationFromHeader } from "./session-files.js"; import { readSqliteProviderRevisionState } from "./sqlite-state.js"; const SESSION_SCOPES = ["sessions", "archived_sessions"]; @@ -241,7 +241,18 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c if (expected && (!prior || prior.skip)) { const skip = prior?.skip ?? rolloutSkip(filePath, "deferred", "revalidate"); revision = { skipped: skip.reason }; - record = { skip }; + record = { skip, id: prior?.id ?? null, historyMode: prior?.historyMode ?? null }; + // New targets stay deferred. A bounded header read establishes only + // which existing index row must be protected, never write eligibility. + if (!prior && mode === "provider") { + try { + await captureProviderHeader(filePath, fsImpl, undefined, (_, value) => { + record = { ...value, ...(value.record ? providerAssociationFromHeader(value.record.firstLine) : {}), skip }; + }); + } catch (error) { + if (!fileReadSkipReason(error)) throw error; + } + } } else { try { revision = mode === "provider" || mode === "status" @@ -250,7 +261,7 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c ? await captureStableMetadata(filePath, fsImpl, { allowLocked: true }) : await captureStableFile(filePath, fsImpl, { allowLocked: true }); if (revision.skipReason) record = { skip: rolloutSkip(filePath, revision.skipReason, expected ? "revalidate" : "scan", prior?.id) }; - if (prior && (prior.headerHash !== revision.headerHash || prior.realPath !== revision.realPath + if (prior && !revision.skipReason && (prior.headerHash !== revision.headerHash || prior.realPath !== revision.realPath || prior.ino !== revision.ino || prior.dev !== revision.dev || prior.nlink !== revision.nlink || BigInt(revision.observedSize ?? 0) < BigInt(prior.observedSize ?? 0))) { record = { skip: rolloutSkip(filePath, "changed", "revalidate", prior.id) }; @@ -262,6 +273,7 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c revision = { skipped: reason }; } } + if (record?.skip && prior) record = { ...record, id: prior.id ?? null, historyMode: prior.historyMode ?? null }; onProviderHeader?.(filePath, record); fileBindings.push({ path: filePath, ...revision, ...(record?.skip ? { skip: record.skip } : {}) }); const { observedSize, ...binding } = revision; @@ -272,7 +284,7 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c } if (expected) for (const [key, prior] of expected) if (!seen.has(key)) { const skip = prior.skip ?? rolloutSkip(prior.path, "missing", "revalidate", prior.id); - onProviderHeader?.(prior.path, { skip }); + onProviderHeader?.(prior.path, { skip, id: prior.id ?? null, historyMode: prior.historyMode ?? null }); fileBindings.push({ ...prior, skip }); } manifest.sort((left, right) => left.path.localeCompare(right.path)); @@ -299,6 +311,7 @@ export async function collectProviderPreparationFacts(codexHome, targetProvider, const files = new Map(scan.files.map(file => [providerPathKey(file.path), file])); rollout.fileBindings = rollout.fileBindings.map(binding => ({ ...binding, id: files.get(providerPathKey(binding.path))?.id ?? binding.id ?? null, + historyMode: files.get(providerPathKey(binding.path))?.historyMode ?? binding.historyMode ?? null, ...(skips.has(providerPathKey(binding.path)) ? { skip: skips.get(providerPathKey(binding.path)) } : {}) })); rollout.rolloutScanComplete = scan.skippedItems.length === 0; return { rollout, scan }; @@ -312,6 +325,31 @@ export async function collectProviderPreparationFacts(codexHome, targetProvider, } } +// Private storage acknowledgement for a successful Provider write. Capture its +// new identity now so final group validation accepts our own atomic replacement +// but rejects a later external replacement. No body reads or public DTO fields. +export async function captureAppliedProviderBinding(change, previous, result, { fsImpl = fs } = {}) { + try { + let record; + const revision = await captureProviderHeader(change.path, fsImpl, undefined, (_, value) => { record = value; }); + const expected = Buffer.from((result === "APPLIED_IN_PLACE" ? change.originalFirstLine : change.updatedFirstLine) + + change.originalSeparator, "utf8"); + if (result === "APPLIED_IN_PLACE") Buffer.from(change.inPlaceMutation.replacementBase64, "base64").copy(expected, change.inPlaceMutation.byteOffset); + const minimumSize = BigInt(change.originalSize) + BigInt(expected.length - Buffer.byteLength(change.originalFirstLine + change.originalSeparator, "utf8")); + if (revision.skipReason) return { ...previous, skip: rolloutSkip(change.path, revision.skipReason, "write", previous.id) }; + if (!record?.record || revision.headerHash !== sha256Revision(expected) + || BigInt(revision.observedSize) < minimumSize || revision.realPath !== previous.realPath + || (result === "APPLIED_IN_PLACE" && (revision.ino !== previous.ino || revision.dev !== previous.dev || revision.nlink !== previous.nlink))) { + return { ...previous, skip: rolloutSkip(change.path, "changed", "write", previous.id) }; + } + return { ...previous, ...revision }; + } catch (error) { + const reason = fileReadSkipReason(error); + if (!reason) throw error; + return { ...previous, skip: rolloutSkip(change.path, reason, "write", previous.id) }; + } +} + export async function captureStateDbRevision(storage, { fsImpl = fs, platform = process.platform, mode = "content", schemaOnly = false } = {}) { const stateDbPath = storage.stateDbLocation?.path ?? null; if (!stateDbPath) { diff --git a/src/provider-skips.js b/src/provider-skips.js index 9bad47f..68a6dff 100644 --- a/src/provider-skips.js +++ b/src/provider-skips.js @@ -16,8 +16,20 @@ export function fileReadSkipReason(error) { return null; } +function comparableProviderPath(value) { + if (process.platform !== "win32") return value; + // Only fold ordinary DOS/UNC namespace aliases. Device namespaces are not + // filesystem association evidence, even when they contain a sessions name. + const windows = value.replaceAll("/", "\\"); + if (/^\\\\\?\\UNC\\[^\\]+\\[^\\]+(?:\\|$)/i.test(windows)) return `\\\\${windows.slice(8)}`; + if (/^\\\\\?\\[A-Za-z]:\\/.test(windows)) return windows.slice(4); + if (/^\\\\[?.]\\/.test(windows)) return null; + return value; +} + export function providerPathKey(value) { - const normalized = path.resolve(value); + const comparable = comparableProviderPath(value); + const normalized = path.resolve(comparable ?? value); return process.platform === "win32" ? normalized.toLowerCase() : normalized; } @@ -51,8 +63,11 @@ export function summarizeSkips(items, unconfirmed = 0) { // Resolve only paths within a known rollout tree. No filename-based identity inference. function rowRolloutPath(home, value) { if (typeof value !== "string" || !value || value.includes("\0")) return null; - const absolute = path.resolve(home, value); - const relative = path.relative(home, absolute).split(path.sep); + const comparableHome = comparableProviderPath(home); + const comparableValue = comparableProviderPath(value); + if (comparableHome === null || comparableValue === null) return null; + const absolute = path.resolve(comparableHome, comparableValue); + const relative = path.relative(comparableHome, absolute).split(path.sep); if (!["sessions", "archived_sessions"].includes(relative[0]) || relative.includes("..")) return null; return providerPathKey(absolute); } @@ -68,7 +83,7 @@ export function selectProviderRows(home, scan, state, targetProvider, additional matches.push(file); byId.set(file.id, matches); } for (const item of skipped) if (item.kind === "rollout" && !byPath.has(providerPathKey(item.path))) { - const file = { path: item.path, id: item.id ?? null }; + const file = { path: item.path, id: item.id ?? null, historyMode: item.historyMode ?? null }; byPath.set(providerPathKey(item.path), file); if (file.id) { const matches = byId.get(file.id) ?? []; matches.push(file); byId.set(file.id, matches); } } @@ -81,7 +96,10 @@ export function selectProviderRows(home, scan, state, targetProvider, additional const pathKey = rowRolloutPath(home, row.rollout_path); const pathMatch = pathKey ? byPath.get(pathKey) : null; const matches = [...new Map([...idMatches, ...(pathMatch ? [pathMatch] : [])].map(file => [providerPathKey(file.path), file])).values()]; - const conflict = matches.length > 1 || (pathMatch?.id && String(row.id) !== pathMatch.id && state.key !== "rowid"); + const paginatedGroup = state.key !== "rowid" && pathMatch?.id === String(row.id) + && matches.every(file => file.id === String(row.id) && file.historyMode === "paginated"); + const conflict = (matches.length > 1 && !paginatedGroup) + || (pathMatch?.id && String(row.id) !== pathMatch.id && state.key !== "rowid"); rowMatches.set(row, { matches, conflict }); for (const match of matches) { const key = providerPathKey(match.path); @@ -101,7 +119,7 @@ export function selectProviderRows(home, scan, state, targetProvider, additional // the file. Do not manufacture that certainty for corrupt/no-ID headers. for (const key of skippedPaths) { const file = byPath.get(key); - if (file?.id && byId.get(file.id)?.length === 1 && !ownersByPath.has(key)) associatedBadPaths.add(key); + if (file?.id && (state.key !== "rowid" || (byId.get(file.id)?.length === 1 && !ownersByPath.has(key)))) associatedBadPaths.add(key); } const unknown = [...skippedPaths].some(key => !associatedBadPaths.has(key)); const selected = []; @@ -113,7 +131,7 @@ export function selectProviderRows(home, scan, state, targetProvider, additional const skippedMatch = matches.find(file => skippedPaths.has(providerPathKey(file.path))); const fileSkip = skippedMatch ? skipped.find(item => item.kind === "rollout" && providerPathKey(item.path) === providerPathKey(skippedMatch.path)) : null; if (!reason && fileSkip) reason = fileSkip.reason; - if (!reason && unknown && matches.length === 0) reason = "association-unknown"; + if (!reason && unknown && (matches.length === 0 || matches.some(file => file.historyMode === "paginated"))) reason = "association-unknown"; if (reason) rowSkips.push({ kind: "sqlite", id: String(row.id), reason, stage: "plan", retryable: !conflict && fileSkip?.retryable === true }); else selected.push({ ...row, paths: matches.map(file => file.path) }); } diff --git a/src/session-files.js b/src/session-files.js index ccc476f..65d2d2f 100644 --- a/src/session-files.js +++ b/src/session-files.js @@ -1797,7 +1797,8 @@ export async function collectSessionChanges(codexHome, targetProvider, options = const prepared = preparationRecords?.get(rolloutPath); if (prepared?.skip) { skippedItems.push(prepared.skip); - files.push({ path: rolloutPath, id: prepared.skip.id ?? null }); + files.push({ path: rolloutPath, id: prepared.id ?? prepared.skip.id ?? null, + historyMode: prepared.historyMode ?? null }); if (prepared.skip.reason === "locked") lockedPaths.push(rolloutPath); continue; } @@ -1839,7 +1840,8 @@ export async function collectSessionChanges(codexHome, targetProvider, options = continue; } const currentProvider = parsed.payload.model_provider ?? "(missing)"; - files.push({ path: rolloutPath, id: typeof parsed.payload.id === "string" && parsed.payload.id ? parsed.payload.id : null, provider: currentProvider }); + files.push({ path: rolloutPath, id: typeof parsed.payload.id === "string" && parsed.payload.id ? parsed.payload.id : null, + historyMode: parsed.payload.history_mode === "paginated" ? "paginated" : null, provider: currentProvider }); if (typeof parsed.payload.id === "string" && parsed.payload.id) nativeSessionIds.add(parsed.payload.id); // Selected repair work is addressed by Codex's native session id, never // by a rollout filename/path. Files without that identity stay out. @@ -2025,6 +2027,14 @@ export async function readProviderRevisionHeader(filePath, { fsImpl = fsp } = {} }); } +export function providerAssociationFromHeader(firstLine) { + const parsed = parseSessionMetaRecord(firstLine, true); + return { + id: typeof parsed?.payload.id === "string" && parsed.payload.id ? parsed.payload.id : null, + historyMode: parsed?.payload.history_mode === "paginated" ? "paginated" : null + }; +} + // Fourth argument is an internal, call-local Prepare seam. It is never an // options/Facade field and must not be passed to an Apply or retained in a plan. export async function collectProviderChanges(codexHome, targetProvider, options = {}, preparationRecords = null) { diff --git a/src/sqlite-state.js b/src/sqlite-state.js index 2bc653c..e386d22 100644 --- a/src/sqlite-state.js +++ b/src/sqlite-state.js @@ -6,6 +6,7 @@ import { DB_FILE_BASENAME, SESSION_DIRS, SQLITE_DIR_BASENAME } from "./constants import { CoreError } from "./core-error.js"; import { openDatabase } from "./sqlite.js"; import { resolveStorageLayout } from "./storage-layout.js"; +import { selectProviderRows } from "./provider-skips.js"; const DEFAULT_BUSY_TIMEOUT_MS = 0; const SQLITE_TITLE_QUERY_CHUNK_SIZE = 500; @@ -710,6 +711,18 @@ export async function updateSqliteProvider(storageOrLocation, targetProvider, af const update = db.prepare(`UPDATE threads SET model_provider = ? WHERE ${key} = ? AND model_provider IS ?${hasPath ? " AND rollout_path IS ?" : ""}`); let changes = 0; const changedRows = new Set(); + // Check ownership against every row inside the same native transaction, + // including new rows and already-aligned owners. Use the shared pure + // association algorithm; the admitted write set can only shrink. + if (options.providerAssociations) { + const currentRows = db.prepare(`SELECT ${key} AS id, model_provider${hasPath ? ", rollout_path" : ""} FROM threads`).all(); + const associations = selectProviderRows(options.providerAssociations.codexHome, options.providerAssociations, + { key, rows: currentRows }, targetProvider); + const eligible = new Set(associations.rows.map(row => String(row.id))); + const plannedIds = new Set(options.plannedRows.map(row => String(row.id))); + for (const id of plannedIds) if (!eligible.has(id)) changedRows.add(id); + skippedItems.push(...associations.skippedItems.filter(item => plannedIds.has(item.id)).map(item => ({ ...item, stage: "sqlite" }))); + } if (Array.isArray(options.expectedRows)) { for (const row of options.expectedRows) { const current = read.get(row.id); diff --git a/test-support/desktop-sync-switch-fixture.mjs b/test-support/desktop-sync-switch-fixture.mjs index 1d984bb..ff20a78 100644 --- a/test-support/desktop-sync-switch-fixture.mjs +++ b/test-support/desktop-sync-switch-fixture.mjs @@ -85,7 +85,7 @@ async function sqliteFileDigests(stateDbPath) { ])); } -function createSyntheticStateDatabase(stateDbPath, provider, model) { +function createSyntheticStateDatabase(stateDbPath, provider, model, { sessionId = "c7-desktop-session", rolloutPath = null } = {}) { const database = new DatabaseSync(stateDbPath); try { database.exec(` @@ -98,16 +98,16 @@ function createSyntheticStateDatabase(stateDbPath, provider, model) { model TEXT, has_user_event INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL DEFAULT 0 + updated_at_ms INTEGER NOT NULL DEFAULT 0${rolloutPath === null ? "" : ",\n rollout_path TEXT"} ); `); database.prepare(` INSERT INTO threads ( id, model_provider, cwd, archived, first_user_message, model, - has_user_event, updated_at, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + has_user_event, updated_at, updated_at_ms${rolloutPath === null ? "" : ", rollout_path"} + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?${rolloutPath === null ? "" : ", ?"}) `).run( - "c7-desktop-session", + sessionId, provider, "C:\\synthetic\\desktop-project", 0, @@ -115,7 +115,8 @@ function createSyntheticStateDatabase(stateDbPath, provider, model) { model, 1, 1787702400, - 1787702400000 + 1787702400000, + ...(rolloutPath === null ? [] : [rolloutPath]) ); } finally { database.close(); @@ -131,6 +132,15 @@ function readSessionMeta(text) { throw new Error("Writable desktop fixture has no session_meta entry."); } +function readSessionOrdinal(text) { + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + const entry = JSON.parse(line); + if (entry?.type === "session_meta") return entry.ordinal; + } + throw new Error("Writable desktop fixture has no session_meta entry."); +} + function readTurnContext(text) { for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; @@ -140,18 +150,31 @@ function readTurnContext(text) { throw new Error("Writable desktop fixture has no turn_context entry."); } -export async function createDesktopSyncSwitchFixture() { +function readEventMessage(text) { + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + const entry = JSON.parse(line); + if (entry?.type === "event_msg") return entry.payload?.message; + } + throw new Error("Writable desktop fixture has no event_msg entry."); +} + +export async function createDesktopSyncSwitchFixture({ paginatedGroup = false } = {}) { const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "provider-sync-c7-desktop-")); const codexHome = path.join(fixtureRoot, "codex-home"); const userData = path.join(fixtureRoot, "user-data"); - const rolloutPath = path.join( + const rolloutDirectory = path.join( codexHome, "sessions", "2026", "08", - "26", - "rollout-c7-desktop.jsonl" + "26" ); + const rolloutPath = path.join(rolloutDirectory, paginatedGroup ? "rollout-c7-paginated-a.jsonl" : "rollout-c7-desktop.jsonl"); + const rolloutPaths = paginatedGroup + ? [rolloutPath, path.join(rolloutDirectory, "rollout-c7-paginated-b.jsonl")] + : [rolloutPath]; + const sessionId = paginatedGroup ? "c7-desktop-paginated-session" : "c7-desktop-session"; const stateDbPath = path.join(codexHome, "sqlite", "state_5.sqlite"); const targetSqliteHome = path.join(fixtureRoot, "relocation-target-sqlite"); const targetStateDbPath = path.join(targetSqliteHome, "state_5.sqlite"); @@ -174,7 +197,7 @@ export async function createDesktopSyncSwitchFixture() { }; } }); - await fs.mkdir(path.dirname(rolloutPath), { recursive: true }); + await fs.mkdir(rolloutDirectory, { recursive: true }); await fs.mkdir(path.join(codexHome, "archived_sessions"), { recursive: true }); await fs.mkdir(path.dirname(stateDbPath), { recursive: true }); await fs.mkdir(targetSqliteHome, { recursive: true }); @@ -198,7 +221,7 @@ export async function createDesktopSyncSwitchFixture() { await fs.writeFile( configPath, [ - 'model_provider = "openai"', + `model_provider = "${paginatedGroup ? "prov_a" : "openai"}"`, 'model = "gpt-5"', "", "[model_providers.relay]", @@ -217,16 +240,18 @@ export async function createDesktopSyncSwitchFixture() { }, null, 2)}\n`; await fs.writeFile(globalStatePath, globalState, "utf8"); await fs.writeFile(globalStateBackupPath, globalState, "utf8"); - await fs.writeFile(rolloutPath, `${[ + const rolloutEntries = (bodyMarker = "C7_DESKTOP_BODY_ONLY_MARKER") => [ { type: "session_meta", timestamp: "2026-08-26T00:00:00.000Z", payload: { - id: "c7-desktop-session", + id: sessionId, cwd: "C:\\synthetic\\desktop-project", - model_provider: "legacy-provider", - model: "legacy-model" - } + model_provider: paginatedGroup ? "openai" : "legacy-provider", + model: "legacy-model", + ...(paginatedGroup ? { history_mode: "paginated" } : {}) + }, + ...(paginatedGroup ? { ordinal: 7 } : {}) }, { type: "turn_context", @@ -239,10 +264,19 @@ export async function createDesktopSyncSwitchFixture() { { type: "event_msg", timestamp: "2026-08-26T00:01:00.000Z", - payload: { type: "user_message", message: "C7_DESKTOP_BODY_ONLY_MARKER" } + payload: { type: "user_message", message: bodyMarker } } - ].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); - createSyntheticStateDatabase(stateDbPath, "legacy-provider", "legacy-model"); + ]; + for (const [index, filePath] of rolloutPaths.entries()) { + const text = `${rolloutEntries(paginatedGroup ? `C7_PAGINATED_BODY_${index + 1}` : undefined).map((entry) => JSON.stringify(entry)).join("\n")}\n`; + await fs.writeFile(filePath, text, "utf8"); + } + createSyntheticStateDatabase( + stateDbPath, + paginatedGroup ? "openai" : "legacy-provider", + "legacy-model", + { sessionId, rolloutPath: paginatedGroup ? rolloutPath : null } + ); createSyntheticStateDatabase(targetStateDbPath, "target-before", "target-model"); let closed = false; return { @@ -250,6 +284,8 @@ export async function createDesktopSyncSwitchFixture() { codexHome, userData, rolloutPath, + rolloutPaths, + sessionId, stateDbPath, targetSqliteHome, targetStateDbPath, @@ -325,6 +361,35 @@ export async function createDesktopSyncSwitchFixture() { }); return core.applyRestore({ schemaVersion: 1, planId: plan.planId }); }, + async inspectPaginatedGroup() { + if (!paginatedGroup) throw new Error("The desktop fixture was not created with paginatedGroup enabled."); + const rollouts = await Promise.all(rolloutPaths.map(async (filePath) => { + const bytes = await fs.readFile(filePath); + const text = bytes.toString("utf8"); + const headerWithoutProvider = JSON.parse(text.slice(0, text.indexOf("\n"))); + delete headerWithoutProvider.payload.model_provider; + return { + path: filePath, + headerWithoutProvider, + bodyHash: digest(bytes.subarray(bytes.indexOf(0x0a) + 1)), + metadata: readSessionMeta(text), + ordinal: readSessionOrdinal(text), + turnContext: readTurnContext(text), + body: readEventMessage(text) + }; + })); + const db = new DatabaseSync(stateDbPath, { readOnly: true }); + let sqlite; + try { + sqlite = db.prepare( + "SELECT model_provider AS provider, model, updated_at AS updatedAt, updated_at_ms AS updatedAtMs, rollout_path AS rolloutPath FROM threads WHERE id = ?" + ).get(sessionId); + } finally { + db.close(); + } + const { backupIds } = await this.inspect(); + return { rollouts, sqlite, backupIds }; + }, async inspect() { const configText = await fs.readFile(configPath, "utf8"); const rolloutText = await fs.readFile(rolloutPath, "utf8"); @@ -335,7 +400,7 @@ export async function createDesktopSyncSwitchFixture() { try { sqlite = db.prepare( "SELECT model_provider AS provider, model, updated_at AS updatedAt, updated_at_ms AS updatedAtMs FROM threads WHERE id = ?" - ).get("c7-desktop-session"); + ).get(sessionId); } finally { db.close(); } diff --git a/test/provider-paginated-groups.test.js b/test/provider-paginated-groups.test.js new file mode 100644 index 0000000..6d7698b --- /dev/null +++ b/test/provider-paginated-groups.test.js @@ -0,0 +1,465 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { afterEach } from "node:test"; + +import { applyRestore, applySync, prepareRestore, prepareSync } from "../src/service.js"; +import { openDatabase } from "../src/sqlite.js"; + +// These tests deliberately use D:\\Temp rather than a user profile. Besides +// exercising the Windows path rules, keeping every fixture beneath one checked +// root makes the cleanup safe when a test fails halfway through Apply. +const TEMP_ROOT = process.platform === "win32" ? path.resolve("D:/Temp") : path.resolve(os.tmpdir()); +const cleanups = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +function assertTemporaryChild(value) { + const relative = path.relative(TEMP_ROOT, value); + assert.notEqual(relative, "", "a fixture must not use D:/Temp itself"); + assert.ok(!relative.startsWith("..") && !path.isAbsolute(relative), `fixture escaped D:/Temp: ${value}`); +} + +async function temporaryRoot() { + await fs.mkdir(TEMP_ROOT, { recursive: true }); + const root = await fs.mkdtemp(path.join(TEMP_ROOT, "provider-paginated-")); + assertTemporaryChild(root); + cleanups.push(async () => { + assertTemporaryChild(root); + await fs.rm(root, { recursive: true, force: true }); + }); + return root; +} + +function header({ id, provider, ordinal = 0, historyMode = "paginated", extra = {} }) { + return JSON.stringify({ + type: "session_meta", + ordinal, + payload: { id, model_provider: provider, history_mode: historyMode, fixture: "keep", ...extra } + }); +} + +function contents(spec) { + return `${header(spec)}\n${JSON.stringify({ + type: "event_msg", + payload: { message: `body:${spec.name}`, model_provider: "body-must-not-change", nested: { keep: true } } + })}\n`; +} + +function providerOf(fileContents) { + return JSON.parse(String(fileContents).split(/\r?\n/, 1)[0]).payload.model_provider; +} + +function nonProviderHeader(fileContents) { + const parsed = JSON.parse(String(fileContents).split(/\r?\n/, 1)[0]); + delete parsed.payload.model_provider; + return parsed; +} + +function bodyOf(fileContents) { + const bytes = Buffer.isBuffer(fileContents) ? fileContents : Buffer.from(fileContents); + return bytes.subarray(bytes.indexOf(0x0a) + 1); +} + +function hash(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function paginatedFixture({ configProvider = "prov_a", files, rows }) { + const root = await temporaryRoot(); + const home = path.join(root, "home"); + const dbPath = path.join(home, "sqlite", "state_5.sqlite"); + await Promise.all([ + fs.mkdir(path.join(home, "sessions"), { recursive: true }), + fs.mkdir(path.join(home, "archived_sessions"), { recursive: true }), + fs.mkdir(path.dirname(dbPath), { recursive: true }) + ]); + await fs.writeFile(path.join(home, "config.toml"), [ + `model_provider = "${configProvider}"`, + "[model_providers.custom]", + 'name = "Custom"', + "[model_providers.prov_a]", + 'name = "Provider A"', + "[model_providers.provider_long]", + 'name = "Long Provider"', + "" + ].join("\n")); + + const paths = new Map(); + for (const spec of files) { + const directory = spec.archived ? "archived_sessions" : "sessions"; + const file = path.join(home, directory, spec.name); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, contents(spec)); + paths.set(spec.name, file); + } + + const db = await openDatabase(dbPath); + try { + db.exec("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT, rollout_path TEXT, archived INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL)"); + const insert = db.prepare("INSERT INTO threads(id, model_provider, rollout_path, archived, updated_at) VALUES (?, ?, ?, ?, ?)"); + for (const row of rows) insert.run(row.id, row.provider, row.rolloutPath ?? paths.get(row.file) ?? null, row.archived ?? 0, row.updatedAt ?? 701); + } finally { + db.close(); + } + + return { + home, + dbPath, + file: name => paths.get(name), + async read(name) { return fs.readFile(paths.get(name)); }, + async rows() { + const database = await openDatabase(dbPath); + try { + return database.prepare("SELECT id, model_provider, rollout_path, updated_at FROM threads ORDER BY id").all(); + } finally { + database.close(); + } + }, + async provider(id) { + return (await this.rows()).find(row => row.id === id)?.model_provider; + } + }; +} + +const apply = plan => applySync({ schemaVersion: 1, planId: plan.planId }); + +async function mutateRows(dbPath, mutate) { + const database = await openDatabase(dbPath); + try { mutate(database); } + finally { database.close(); } +} + +function twoFileGroup(id = "thread-paged", providers = ["openai", "custom"]) { + return [ + { name: `rollout-${id}-active.jsonl`, id, provider: providers[0], ordinal: 9 }, + { name: `rollout-${id}-archived.jsonl`, id, provider: providers[1], ordinal: 9, archived: true } + ]; +} + +test("one paginated thread may own active and archived rollouts, including repeated ordinals, and Restore returns the whole group", async () => { + const files = twoFileGroup(); + const value = await paginatedFixture({ + files, + rows: [{ id: "thread-paged", provider: "openai", file: files[0].name, updatedAt: 817 }] + }); + const before = await Promise.all(files.map(async item => ({ + bytes: await value.read(item.name), + stat: await fs.stat(value.file(item.name), { bigint: true }) + }))); + + const plan = await prepareSync({ codexHome: value.home }); + assert.equal(plan.impact.rolloutFilesToChange, 2); + assert.equal(plan.impact.sqliteRowsToChange, 1); + const synced = await apply(plan); + assert.equal(synced.outcome, "completed"); + assert.equal(synced.result.changedSessionFiles, 2); + assert.equal(synced.result.inPlaceSessionFiles, 2); + assert.equal(synced.result.sqliteRowsUpdated, 1); + assert.equal(await value.provider("thread-paged"), "prov_a"); + + for (const [index, item] of files.entries()) { + const after = await value.read(item.name); + const afterStat = await fs.stat(value.file(item.name), { bigint: true }); + assert.equal(providerOf(after), "prov_a"); + assert.deepEqual(nonProviderHeader(after), nonProviderHeader(before[index].bytes)); + assert.deepEqual(bodyOf(after), bodyOf(before[index].bytes)); + assert.equal(afterStat.ino, before[index].stat.ino); + assert.equal(afterStat.size, before[index].stat.size); + } + assert.equal((await value.rows())[0].updated_at, 817); + + const noop = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(noop.outcome, "completed"); + assert.equal(noop.backup, null); + assert.equal(noop.result.noop, true); + + const restorePlan = await prepareRestore({ codexHome: value.home, backupId: synced.backup.backupId }); + const restored = await applyRestore({ schemaVersion: 1, planId: restorePlan.planId }); + assert.equal(restored.outcome, "completed"); + assert.equal(await value.provider("thread-paged"), "openai"); + for (const [index, item] of files.entries()) assert.deepEqual(await value.read(item.name), before[index].bytes); +}); + +test("a paginated group uses streaming replacement for a variable-length Provider and still preserves bodies and Restore scope", async () => { + const files = twoFileGroup("thread-stream", ["custom", "custom"]); + const value = await paginatedFixture({ + configProvider: "provider_long", + files, + rows: [{ id: "thread-stream", provider: "custom", file: files[1].name }] + }); + const before = await Promise.all(files.map(item => value.read(item.name))); + const beforeHashes = before.map(file => hash(bodyOf(file))); + const result = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(result.outcome, "completed"); + assert.equal(result.result.changedSessionFiles, 2); + assert.equal(result.result.inPlaceSessionFiles, 0); + assert.equal(result.result.rewrittenSessionFiles, 2); + assert.equal(await value.provider("thread-stream"), "provider_long"); + for (const [index, item] of files.entries()) { + const after = await value.read(item.name); + assert.equal(providerOf(after), "provider_long"); + assert.deepEqual(nonProviderHeader(after), nonProviderHeader(before[index])); + assert.equal(hash(bodyOf(after)), beforeHashes[index]); + } + const restorePlan = await prepareRestore({ codexHome: value.home, backupId: result.backup.backupId }); + await applyRestore({ schemaVersion: 1, planId: restorePlan.planId }); + for (const [index, item] of files.entries()) assert.deepEqual(await value.read(item.name), before[index]); +}); + +test("a prior file-only paginated sync repairs only SQLite on retry and then becomes a backup-free noop", async () => { + const files = twoFileGroup("thread-half", ["custom", "custom"]); + const value = await paginatedFixture({ + configProvider: "custom", + files, + rows: [{ id: "thread-half", provider: "openai", file: files[0].name }] + }); + const before = await Promise.all(files.map(item => value.read(item.name))); + const plan = await prepareSync({ codexHome: value.home }); + assert.equal(plan.impact.rolloutFilesToChange, 0); + assert.equal(plan.impact.sqliteRowsToChange, 1); + const repaired = await apply(plan); + assert.equal(repaired.outcome, "completed"); + assert.equal(repaired.result.changedSessionFiles, 0); + assert.equal(repaired.result.sqliteRowsUpdated, 1); + assert.ok(repaired.backup?.backupId); + assert.equal(await value.provider("thread-half"), "custom"); + for (const [index, item] of files.entries()) assert.deepEqual(await value.read(item.name), before[index]); + + const noop = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(noop.outcome, "completed"); + assert.equal(noop.backup, null); + assert.equal(noop.result.noop, true); +}); + +test("a non-applied member keeps its paginated row unchanged for locked, missing, changed, and write-failure outcomes", async () => { + for (const mode of ["locked", "missing", "changed", "write-failure"]) { + const files = twoFileGroup(`thread-${mode}`); + const value = await paginatedFixture({ + files, + rows: [{ id: `thread-${mode}`, provider: "openai", file: files[0].name }] + }); + const second = value.file(files[1].name); + let plan; + + if (mode === "locked") { + const originalOpen = fs.open; + fs.open = async (target, ...args) => { + if (path.resolve(String(target)) === path.resolve(second)) { + throw Object.assign(new Error("synthetic lock"), { code: "EBUSY" }); + } + return originalOpen(target, ...args); + }; + try { plan = await prepareSync({ codexHome: value.home }); } + finally { fs.open = originalOpen; } + } else { + plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point, path: file }) => { + if (point !== "before_rollout_apply" || file !== second) return; + if (mode === "missing") await fs.unlink(second); + if (mode === "changed") await fs.writeFile(second, `${header({ ...files[1], provider: "old" })}\nexternal body\n`); + if (mode === "write-failure") throw new Error("synthetic write failure"); + } + }); + } + + const result = await apply(plan); + assert.equal(result.outcome, "partial", mode); + assert.equal(await value.provider(`thread-${mode}`), "openai", mode); + assert.ok(result.result.skipSummary.total > 0 || result.result.partialReason === "mutation-failed", mode); + } +}); + +test("partial Restore returns only a written paginated member and preserves a later repair of the unwritten member", async () => { + const files = twoFileGroup("thread-restore-scope"); + const value = await paginatedFixture({ + files, + rows: [{ id: "thread-restore-scope", provider: "openai", file: files[0].name }] + }); + const firstBefore = await value.read(files[0].name); + const second = value.file(files[1].name); + const result = await apply(await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point, path: file }) => { + if (point === "before_rollout_apply" && file === second) await fs.unlink(second); + } + })); + assert.equal(result.outcome, "partial"); + assert.ok(result.backup?.backupId); + await fs.writeFile(second, `${header({ ...files[1], provider: "prov_a", extra: { later: "repair" } })}\nlater repair body\n`); + const laterRepair = await fs.readFile(second); + + const restorePlan = await prepareRestore({ codexHome: value.home, backupId: result.backup.backupId }); + await applyRestore({ schemaVersion: 1, planId: restorePlan.planId }); + assert.deepEqual(await value.read(files[0].name), firstBefore); + assert.deepEqual(await fs.readFile(second), laterRepair); +}); + +test("a newly discovered member of the same paginated thread is deferred and blocks that row until a new preview", async () => { + const files = twoFileGroup("thread-added"); + const value = await paginatedFixture({ + files, + rows: [{ id: "thread-added", provider: "openai", file: files[0].name }] + }); + const added = path.join(value.home, "sessions", "rollout-thread-added-later.jsonl"); + let addedOnce = false; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "before_rollout_apply" || addedOnce) return; + addedOnce = true; + await fs.writeFile(added, contents({ name: "thread-added-later", id: "thread-added", provider: "openai", ordinal: 9 })); + } + }); + const result = await apply(plan); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-added"), "openai"); + assert.equal(providerOf(await fs.readFile(added)), "openai"); + assert.ok(result.result.skipSummary.items.some(item => item.kind === "sqlite" && item.id === "thread-added")); + + const converged = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(converged.outcome, "completed"); + assert.equal(await value.provider("thread-added"), "prov_a"); + assert.equal(providerOf(await fs.readFile(added)), "prov_a"); +}); + +test("a newly discovered different thread is deferred without blocking the original paginated row", async () => { + const files = twoFileGroup("thread-known"); + const value = await paginatedFixture({ + files, + rows: [{ id: "thread-known", provider: "openai", file: files[0].name }] + }); + const added = path.join(value.home, "sessions", "rollout-thread-other-later.jsonl"); + let addedOnce = false; + const result = await apply(await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "before_rollout_apply" || addedOnce) return; + addedOnce = true; + await fs.writeFile(added, contents({ name: "thread-other-later", id: "thread-other", provider: "openai" })); + } + })); + assert.equal(await value.provider("thread-known"), "prov_a"); + assert.equal(providerOf(await fs.readFile(added)), "openai"); + assert.ok(result.result.skipSummary.items.some(item => item.kind === "rollout" && item.path === added)); +}); + +test("an unknown rollout added during Apply conservatively preserves every known paginated SQLite candidate, including a single-file group", async () => { + const group = twoFileGroup("thread-group"); + const single = [{ name: "rollout-thread-single.jsonl", id: "thread-single", provider: "openai", ordinal: 77 }]; + const files = [...group, ...single]; + const value = await paginatedFixture({ + files, + rows: [ + { id: "thread-group", provider: "openai", file: group[0].name }, + { id: "thread-single", provider: "openai", file: single[0].name } + ] + }); + const unknown = path.join(value.home, "archived_sessions", "rollout-unknown-later.jsonl"); + let addedOnce = false; + const result = await apply(await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "before_rollout_apply" || addedOnce) return; + addedOnce = true; + await fs.writeFile(unknown, '{"type":"event_msg","payload":{}}\n'); + } + })); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-group"), "openai"); + assert.equal(await value.provider("thread-single"), "openai"); + assert.ok(result.result.skipSummary.unconfirmed > 0); + + await fs.unlink(unknown); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed"); + assert.equal(await value.provider("thread-group"), "prov_a"); + assert.equal(await value.provider("thread-single"), "prov_a"); +}); + +test("SQLite commit revalidates every group member, including an initially aligned member changed by another writer", async () => { + const files = twoFileGroup("thread-revalidate", ["custom", "prov_a"]); + const value = await paginatedFixture({ + files, + rows: [{ id: "thread-revalidate", provider: "openai", file: files[0].name }] + }); + const aligned = value.file(files[1].name); + let replaced = false; + const result = await apply(await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "after_rollout_apply" || replaced) return; + replaced = true; + await fs.writeFile(aligned, `${header({ ...files[1], provider: "custom" })}\nexternal replacement\n`); + } + })); + assert.equal(replaced, true); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-revalidate"), "openai"); + assert.ok(result.result.skippedChangedRolloutFiles.includes(aligned)); + assert.ok(result.result.skipSummary.items.some(item => item.kind === "sqlite" && item.id === "thread-revalidate")); +}); + +test("SQLite owner drift after Prepare preserves the affected paginated row while an independent group converges", async () => { + for (const timing of ["add-before-apply", "change-before-apply", "add-after-rollout"]) { + const affected = twoFileGroup("thread-owner-a"); + const healthy = twoFileGroup("thread-owner-healthy"); + const files = [...affected, ...healthy]; + const value = await paginatedFixture({ + files, + rows: [ + { id: "thread-owner-a", provider: "openai", file: affected[0].name }, + { id: "thread-owner-healthy", provider: "openai", file: healthy[0].name }, + // An already-aligned foreign row must still participate in ownership + // checks; otherwise it could silently steal A's rollout after Prepare. + { id: "thread-owner-b", provider: "prov_a", rolloutPath: null } + ] + }); + const ownerPath = value.file(affected[0].name); + let changedDuringRollout = false; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (timing !== "add-after-rollout" || point !== "after_rollout_apply" || changedDuringRollout) return; + changedDuringRollout = true; + await mutateRows(value.dbPath, database => { + database.prepare("INSERT INTO threads(id, model_provider, rollout_path, archived, updated_at) VALUES (?, ?, ?, 0, 701)") + .run("thread-owner-b-late", "prov_a", ownerPath); + }); + } + }); + assert.equal(plan.impact.sqliteRowsToChange, 2, timing); + + if (timing === "add-before-apply") { + await mutateRows(value.dbPath, database => { + database.prepare("INSERT INTO threads(id, model_provider, rollout_path, archived, updated_at) VALUES (?, ?, ?, 0, 701)") + .run("thread-owner-b-late", "prov_a", ownerPath); + }); + } + if (timing === "change-before-apply") { + await mutateRows(value.dbPath, database => { + database.prepare("UPDATE threads SET rollout_path = ? WHERE id = 'thread-owner-b'").run(ownerPath); + }); + } + + const result = await apply(plan); + assert.equal(result.outcome, "partial", timing); + assert.equal(await value.provider("thread-owner-a"), "openai", timing); + assert.equal(await value.provider("thread-owner-healthy"), "prov_a", timing); + assert.ok(result.result.skipSummary.items.some(item => item.kind === "sqlite" + && item.id === "thread-owner-a" && item.reason === "association-conflict"), timing); + if (timing === "add-after-rollout") assert.equal(changedDuringRollout, true); + + await mutateRows(value.dbPath, database => { + database.prepare("DELETE FROM threads WHERE id IN ('thread-owner-b', 'thread-owner-b-late')").run(); + }); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed", timing); + assert.equal(await value.provider("thread-owner-a"), "prov_a", timing); + } +}); diff --git a/test/provider-preparation-facts.test.js b/test/provider-preparation-facts.test.js index caeeedf..a9d74db 100644 --- a/test/provider-preparation-facts.test.js +++ b/test/provider-preparation-facts.test.js @@ -55,6 +55,49 @@ function headerReadProbe(file) { return { counts: () => ({ opens, bytes }), restore() { fs.open = originalOpen; fs.readFile = originalReadFile; fsSync.createReadStream = originalStream; } }; } +test("paginated ID and history mode remain frozen when a member changes, disappears or becomes unreadable", async t => { + const value = await fixture(t); + await fs.writeFile(value.file, JSON.stringify({ type: "session_meta", payload: { id: "fixture", history_mode: "paginated", model_provider: "custom" } }) + "\n"); + const facts = await collectProviderPreparationFacts(value.home, "openai"); + assert.equal(facts.rollout.fileBindings[0].historyMode, "paginated"); + const expectedFiles = facts.rollout.fileBindings; + await fs.writeFile(value.file, JSON.stringify({ type: "session_meta", payload: { id: "different", model_provider: "custom" } }) + "\n"); + const changed = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles }); + assert.deepEqual(changed.scan.files.map(file => [file.id, file.historyMode]), [["fixture", "paginated"]]); + assert.equal(changed.scan.skippedItems[0].reason, "changed"); + const locked = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles, fsImpl: { ...fs, + async open(target, ...args) { + if (String(target) === value.file) throw Object.assign(new Error("synthetic locked member"), { code: "EBUSY" }); + return fs.open(target, ...args); + } + } }); + assert.deepEqual(locked.scan.files.map(file => [file.id, file.historyMode]), [["fixture", "paginated"]]); + assert.equal(locked.scan.skippedItems[0].reason, "locked"); + await fs.unlink(value.file); + const missing = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles }); + assert.deepEqual(missing.scan.files.map(file => [file.id, file.historyMode]), [["fixture", "paginated"]]); + assert.equal(missing.scan.skippedItems[0].reason, "missing"); +}); + +test("new files lend bounded association facts but never enter the frozen write set", async t => { + const value = await fixture(t); + const original = await collectProviderPreparationFacts(value.home, "openai"); + const added = path.join(value.home, "sessions", "rollout-added.jsonl"); + await fs.writeFile(added, Buffer.concat([Buffer.from(JSON.stringify({ type: "session_meta", payload: { + id: "fixture", history_mode: "paginated", model_provider: "custom" + } }) + "\n"), value.body])); + const probe = headerReadProbe(added); + let facts; + try { + facts = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles: original.rollout.fileBindings }); + assert.deepEqual(probe.counts(), { opens: 1, bytes: 64 * 1024 }); + } finally { probe.restore(); } + assert.deepEqual(facts.scan.files.find(file => file.path === added), { path: added, id: "fixture", historyMode: "paginated" }); + assert.ok(facts.scan.skippedItems.some(item => item.path === added && item.reason === "deferred")); + assert.ok(facts.scan.changes.every(change => change.path !== added)); + assert.equal(facts.rollout.fileBindings.find(file => file.path === added).historyMode, "paginated"); +}); + test("Provider facts reuse the exact revision and change algorithm with one bounded header read instead of three", async t => { const value = await fixture(t); const probe = headerReadProbe(value.file); diff --git a/test/provider-skip-associations.test.js b/test/provider-skip-associations.test.js index e8e9eb6..1f05c2a 100644 --- a/test/provider-skip-associations.test.js +++ b/test/provider-skip-associations.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import test from "node:test"; -import { selectProviderRows, rolloutSkip, summarizeSkips } from "../src/provider-skips.js"; +import { selectProviderRows, rolloutSkip, summarizeSkips, providerPathKey } from "../src/provider-skips.js"; import { isSkipSummary } from "../packages/contracts/dist/index.js"; const home = path.resolve("D:/synthetic-associations"); const good = path.join(home, "sessions", "rollout-good.jsonl"); @@ -20,6 +20,69 @@ test("a skipped known ID without an index row does not block unrelated SQLite-on { key: "id", rows: [row("index-only", null)] }, "openai"); assert.deepEqual(result.rows.map(r => r.id), ["index-only"]); }); + +test("an anchored paginated group has one owner regardless of ordinal", () => { + const files = [good, bad].map(filePath => ({ path: filePath, id: "same", historyMode: "paginated", ordinal: 0 })); + const result = selectProviderRows(home, { files, skippedItems: [] }, { key: "id", rows: [row("same", bad)] }, "openai"); + assert.deepEqual(result.rows.map(r => [r.id, r.paths]), [["same", [good, bad]]]); + assert.deepEqual(result.skippedItems, []); + for (const state of [ + { key: "id", rows: [row("same", null)] }, + { key: "id", rows: [row("same", bad), row("other", good)] } + ]) { + assert.equal(selectProviderRows(home, { files, skippedItems: [] }, state, "openai").rows.length, 0); + } + const rowIdSelection = selectProviderRows(home, { files, skippedItems: [] }, { key: "rowid", rows: [row("same", bad)] }, "openai"); + assert.deepEqual(rowIdSelection.rows[0].paths, [bad], "A legacy rowid path association must not become a thread-ID group."); + const mixed = files.map((file, index) => index ? { ...file, historyMode: null } : file); + assert.equal(selectProviderRows(home, { files: mixed, skippedItems: [] }, { key: "id", rows: [row("same", bad)] }, "openai").rows.length, 0); + const wrong = { path: path.join(home, "sessions", "rollout-other.jsonl"), id: "other", historyMode: "paginated" }; + assert.equal(selectProviderRows(home, { files: [...files, wrong], skippedItems: [] }, { key: "id", rows: [row("same", wrong.path)] }, "openai").rows.length, 0); +}); + +test("skipped paginated members protect the owner and unknown files protect even paginated singletons", () => { + const files = [good, bad].map(filePath => ({ path: filePath, id: "same", historyMode: "paginated" })); + const state = { key: "id", rows: [row("same", good)] }; + const result = selectProviderRows(home, { files, skippedItems: [rolloutSkip(bad, "locked", "write", "same")] }, state, "openai"); + assert.equal(result.rows.length, 0); + assert.equal(result.skippedItems[0].reason, "locked"); + assert.equal(result.skippedItems[0].retryable, true); + const legacy = { path: path.join(home, "sessions", "rollout-legacy.jsonl"), id: "legacy" }; + const unknown = rolloutSkip(bad, "deferred", "revalidate"); + const restricted = selectProviderRows(home, { files: [files[0], legacy], skippedItems: [unknown] }, + { key: "id", rows: [...state.rows, row("legacy", legacy.path), row("sqlite-only", null)] }, "openai"); + assert.deepEqual(restricted.rows.map(r => r.id), ["legacy"]); + assert.deepEqual(restricted.skippedItems.map(r => [r.id, r.reason]), [["same", "association-unknown"], ["sqlite-only", "association-unknown"]]); +}); + +test("a deferred known ID that makes its own group ambiguous does not block another paginated group", () => { + const other = { path: path.join(home, "sessions", "rollout-other.jsonl"), id: "other", historyMode: "paginated" }; + const files = [{ path: good, id: "same", historyMode: "paginated" }, { path: bad, id: "same", historyMode: null }, other]; + const result = selectProviderRows(home, { files, skippedItems: [rolloutSkip(bad, "deferred", "revalidate", "same")] }, + { key: "id", rows: [row("same", good), row("other", other.path)] }, "openai"); + assert.deepEqual(result.rows.map(r => r.id), ["other"]); + assert.deepEqual(result.skippedItems.map(r => [r.id, r.reason]), [["same", "association-conflict"]]); +}); + +test("Windows DOS and UNC namespace aliases associate without changing stored paths", { skip: process.platform !== "win32" }, () => { + for (const testHome of [home, "\\\\server\\share\\fixture"]) { + const file = path.join(testHome, "sessions", "rollout-fixture.jsonl"); + const alias = path.toNamespacedPath(file); + assert.equal(providerPathKey(file), providerPathKey(alias)); + const scan = { files: [{ path: file, id: null }], skippedItems: [rolloutSkip(file, "locked")] }; + const result = selectProviderRows(path.toNamespacedPath(testHome), scan, { key: "id", rows: [row("test", alias)] }, "openai"); + assert.equal(result.rows.length, 0); + assert.equal(result.skippedItems[0].reason, "locked"); + const paginated = { files: [file, path.join(testHome, "archived_sessions", "rollout-old.jsonl")] + .map(p => ({ path: p, id: "test", historyMode: "paginated" })), skippedItems: [] }; + const selected = selectProviderRows(testHome, paginated, { key: "id", rows: [row("test", alias)] }, "openai"); + assert.equal(selected.rows.length, 1); + assert.equal(selected.rows[0].rollout_path, alias); + for (const invalid of [path.toNamespacedPath(path.join(testHome, "..", "outside.jsonl")), "\\\\?\\GLOBALROOT\\Device\\sessions\\rollout-fixture.jsonl", "\\\\.\\D:\\sessions\\rollout-fixture.jsonl", "sessions\\..\\..\\outside.jsonl", "sessions\\rollout\0-fixture.jsonl"]) { + assert.equal(selectProviderRows(testHome, paginated, { key: "id", rows: [row("test", invalid)] }, "openai").rows.length, 0); + } + } +}); test("unknown bad metadata restricts updates to positive healthy associations", () => { const scan = { files: [{ path: good, id: "good" }, { path: bad, id: null }], skippedItems: [rolloutSkip(bad, "metadata-invalid")] }; for (const unknownPath of [null, path.join(home, "..", "outside.jsonl"), "../../sessions/rollout-bad.jsonl"]) { From d4919906811ac1582202390ca9d977f490eccf7a Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Mon, 28 Sep 2026 10:38:24 +0800 Subject: [PATCH 2/4] chore: prepare version 1.0.4 local test package --- CHANGELOG.md | 6 ++++++ apps/desktop/package.json | 2 +- desktop/CodexProviderSync.App/CodexProviderSync.App.csproj | 6 +++--- .../CodexProviderSync.Application.csproj | 6 +++--- .../CodexProviderSync.Automation.csproj | 6 +++--- .../CodexProviderSync.Core/CodexProviderSync.Core.csproj | 6 +++--- .../CodexProviderSync.GuiE2E.csproj | 6 +++--- desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj | 6 +++--- package-lock.json | 6 +++--- package.json | 2 +- 10 files changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e1d6c..92dd8ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。 +## [1.0.4] - 未发布 + +- 修复同一线程有多个分页 rollout 时,文件已同步但 SQLite 因关联冲突无法更新的问题(#107)。归属明确的分页文件组可一次同步;已有半同步状态可重试补齐 SQLite,随后无需再次创建备份。 +- 修复 Windows 普通路径与 `\\?\` 路径别名的关联比较。缺少归属证据、锁定或执行期间发生变化的文件仍跳过并保留关联索引,正常数据继续处理。 +- 本地 Windows x64 便携测试包版本为 1.0.4,正式发布与安装验收尚未完成。 + ## [1.0.3] - 2026-09-15 - 已下载更新由用户决定安装:存储配置、写操作、Watch 和恢复状态不再阻止“重启并安装”。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 90f1321..375c32a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@codex-provider-sync/desktop", - "version": "1.0.3", + "version": "1.0.4", "description": "Desktop app for synchronizing Codex Provider metadata and local session history.", "homepage": "https://github.com/Dailin521/codex-provider-sync#readme", "author": "Dailin521", diff --git a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj index bed227e..f5ef089 100644 --- a/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj +++ b/desktop/CodexProviderSync.App/CodexProviderSync.App.csproj @@ -24,9 +24,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 diff --git a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj index b1d1c7d..c462aea 100644 --- a/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj +++ b/desktop/CodexProviderSync.Application/CodexProviderSync.Application.csproj @@ -5,9 +5,9 @@ enable enable false - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 diff --git a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj index 6331e52..7652a1a 100644 --- a/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj +++ b/desktop/CodexProviderSync.Automation/CodexProviderSync.Automation.csproj @@ -7,9 +7,9 @@ enable CodexProviderSync.Automation CodexProviderSync.Automation - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 diff --git a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj index 57e5d15..e067c9e 100644 --- a/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj +++ b/desktop/CodexProviderSync.Core/CodexProviderSync.Core.csproj @@ -4,9 +4,9 @@ net10.0 enable enable - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 diff --git a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj index 923ce17..32b353f 100644 --- a/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj +++ b/desktop/CodexProviderSync.GuiE2E/CodexProviderSync.GuiE2E.csproj @@ -7,9 +7,9 @@ enable true CodexProviderSync.GuiE2E - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 diff --git a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj index 4c07772..62cd5d4 100644 --- a/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj +++ b/desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj @@ -17,9 +17,9 @@ CodexProviderSync Codex Provider Sync Dailin521 - 1.0.3 - 1.0.3.0 - 1.0.3.0 + 1.0.4 + 1.0.4.0 + 1.0.4.0 true diff --git a/package-lock.json b/package-lock.json index 4e33e2c..f8d3a95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dailin521/codex-provider-sync", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dailin521/codex-provider-sync", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "workspaces": [ "apps/*", @@ -35,7 +35,7 @@ }, "apps/desktop": { "name": "@codex-provider-sync/desktop", - "version": "1.0.3", + "version": "1.0.4", "dependencies": { "better-sqlite3": "13.0.3", "electron-updater": "6.8.9" diff --git a/package.json b/package.json index f53558c..a17db39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dailin521/codex-provider-sync", - "version": "1.0.3", + "version": "1.0.4", "description": "Synchronize Codex session provider metadata across rollout files and SQLite state.", "type": "module", "workspaces": [ From 1223f96ccf2e3ade2593fe8e07c4db5ae3c3d413 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Mon, 28 Sep 2026 11:14:21 +0800 Subject: [PATCH 3/4] fix(core): refresh deferred rollout ownership before SQLite --- CHANGELOG.md | 6 +- CONTRIBUTORS.md | 1 + docs/README_DESKTOP_ZH.md | 2 +- docs/WINDOWS_ELECTRON_RELEASE_ZH.md | 8 +- .../0047-paginated-provider-associations.md | 2 +- .../architecture/NODE_CORE_ARCHITECTURE_ZH.md | 2 +- .../contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md | 2 + docs/migration/BEHAVIOR_FIXTURES_ZH.md | 2 + docs/release-notes/v1.0.4-zh.md | 52 +++++++ src/operation-revision.js | 16 +- src/provider-skips.js | 7 +- test/provider-paginated-groups.test.js | 147 ++++++++++++++++++ test/provider-preparation-facts.test.js | 48 ++++++ test/provider-skip-associations.test.js | 12 ++ 14 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 docs/release-notes/v1.0.4-zh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dd8ee..d4ea651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ 本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。 -## [1.0.4] - 未发布 +## [1.0.4] - 2026-09-28 + +Windows x64 Electron 修复版本。发布状态和下载以 [GitHub Release](https://github.com/Dailin521/codex-provider-sync/releases/tag/v1.0.4) 为准;[升级说明](docs/release-notes/v1.0.4-zh.md)。 - 修复同一线程有多个分页 rollout 时,文件已同步但 SQLite 因关联冲突无法更新的问题(#107)。归属明确的分页文件组可一次同步;已有半同步状态可重试补齐 SQLite,随后无需再次创建备份。 - 修复 Windows 普通路径与 `\\?\` 路径别名的关联比较。缺少归属证据、锁定或执行期间发生变化的文件仍跳过并保留关联索引,正常数据继续处理。 -- 本地 Windows x64 便携测试包版本为 1.0.4,正式发布与安装验收尚未完成。 +- 感谢 [@Lorikein12138](https://github.com/Lorikein12138) 提供复现步骤与分页文件、Windows 长路径的根因分析。 ## [1.0.3] - 2026-09-15 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 0595ef1..d16b9b8 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -14,6 +14,7 @@ Thank you to everyone who has contributed code, documentation, testing, investig | [@Hccake](https://github.com/Hccake) | 独立 SQLite Home、恢复迁移保护和 WSL 路径安全 / Independent SQLite Home, restore relocation safeguards, and WSL path safety ([#55](https://github.com/Dailin521/codex-provider-sync/pull/55)) | | [@JoJoJoinme](https://github.com/JoJoJoinme) | Codex 状态数据库位置检测 / Codex state-database location detection ([#36](https://github.com/Dailin521/codex-provider-sync/pull/36)) | | [@Liuwei1125](https://github.com/Liuwei1125) | 嵌套 SQLite 状态数据库问题调查 / Nested SQLite state-database investigation | +| [@Lorikein12138](https://github.com/Lorikein12138) | 分页会话同步冲突与 Windows 长路径关联问题的复现和根因分析 / Reproduction and root-cause analysis of paginated session sync conflicts and Windows long-path associations ([#107](https://github.com/Dailin521/codex-provider-sync/issues/107)) | | [@panjinhe](https://github.com/panjinhe) | Windows 同步性能、CLI 进度和瞬时锁重试 / Windows sync performance, CLI progress, and transient lock retries | | [@PaulDing98](https://github.com/PaulDing98) | Provider/model 同步、watch 和桌面端单实例流程 / Provider/model synchronization, watch mode, and desktop single-instance flow ([#41](https://github.com/Dailin521/codex-provider-sync/pull/41), [#42](https://github.com/Dailin521/codex-provider-sync/pull/42)) | | [@Plintun-Max](https://github.com/Plintun-Max) | 旧版 Node.js 的 SQLite fallback / SQLite fallback for older Node.js ([#40](https://github.com/Dailin521/codex-provider-sync/pull/40)) | diff --git a/docs/README_DESKTOP_ZH.md b/docs/README_DESKTOP_ZH.md index f9d4a61..ee632a8 100644 --- a/docs/README_DESKTOP_ZH.md +++ b/docs/README_DESKTOP_ZH.md @@ -161,7 +161,7 @@ Watch 需在“设置”主动开启;当前状态按存储配置显示。指 - 便携版/本地打包版打开官方下载页。退出后解压完整新版到新目录,不要只替换一个 EXE。 - 已启用正式更新通道的安装版可按需下载,再确认重启安装;安装由用户决定,存储状态、写操作、自动同步或未完成恢复不会禁用安装按钮。 - 设置更新区和新版弹窗可选“不再提醒此版本”;重启后保留,下个新版仍提醒。仍可手动检查/更新,也可在设置中恢复该版本提醒。 -- 1.0.3 支持大首行及问题会话跳过。受影响的 1.0.1 安装版无法正常检查更新,请从官方下载页手动安装新版一次,详见[升级说明](release-notes/v1.0.3-zh.md)。线上跨版本下载安装仍待独立验收。 +- 1.0.4 修复分页会话的多文件同步冲突,可再次同步补齐此前未对齐的索引,并保留 1.0.3 的大首行和问题会话跳过能力。受影响的 1.0.1 安装版无法正常检查更新,请从官方下载页手动安装新版一次,详见[升级说明](release-notes/v1.0.4-zh.md)。线上跨版本下载安装仍待独立验收。 - **旧 Windows .NET 的单 EXE 自更新不能直接升级到 Electron。** 首次迁移需安装/解压完整新版;不把 NSIS 安装器冒充旧 EXE,不承诺已经完成线上跨版本升级验证。 - 更新程序不替换 Codex 数据。下载安装方式、签名与更新通道以实际发布说明为准。 diff --git a/docs/WINDOWS_ELECTRON_RELEASE_ZH.md b/docs/WINDOWS_ELECTRON_RELEASE_ZH.md index fb54d55..c04b3cd 100644 --- a/docs/WINDOWS_ELECTRON_RELEASE_ZH.md +++ b/docs/WINDOWS_ELECTRON_RELEASE_ZH.md @@ -76,7 +76,13 @@ main push 始终完整执行全部原有任务和 C10,最终 SHA 的发布门 正式版公告先通过 `node scripts/read-release-metadata.js --tag v1.0.x`,然后按该版本的明确授权执行 `gh release edit v1.0.x --draft=false --prerelease=false --latest=true --notes-file docs/release-notes/v1.0.x-zh.md`。公开后再次检查 `/releases/latest`、tag SHA、对应渠道的全部下载资产与哈希。RC 只公开 prerelease,不使用正式版命令,也不设 latest。 -## 6. 当前 1.0.3 发布与历史例外 +## 6. 当前 1.0.4 发布与历史例外 + +维护者已于 2026-09-28 确认本地 1.0.4 测试包成功修复 #107,并明确授权发布新版及感谢报告者。本轮为 **Windows x64 Electron 1.0.4**,沿用 `stable-updater`:安装版由用户显式下载并确认重启安装,便携版手动更新。先处理 PR #108 的全部有效审查并通过当前 head CI,采用 merge commit 合并;最终 main SHA 再次通过完整 CI 后,创建新的 `v1.0.4` 标签,准备并验收同 SHA 的 10 项附件,再公开对应 Release。授权不替代门禁,不得移动旧标签或覆盖既有 Release。 + +发布说明与贡献者名单感谢 [@Lorikein12138](https://github.com/Lorikein12138) 在 [#107](https://github.com/Dailin521/codex-provider-sync/issues/107) 的复现和根因分析。本地手测仅确认 #107 修复,不能扩展为线上升级或真实安装验收;安装/退出/卸载和 ZIP 解压验收由隔离容器完成。仍需公开说明未签名、线上跨版本下载安装尚未独立验收;不发布 npm、Legacy 或其他平台包。 + +### 1.0.3 发布授权记录 维护者已于 2026-09-15 授权提交、合并并发布新的 Windows 包。本次版本为 **1.0.3**,交付 128 MiB 首行、问题会话逐文件跳过和首行校验修复。遵循第 1~5 节:最终 main SHA 的 CI 通过后创建新的 `v1.0.3` 标签,以 `stable-updater` 准备并验收 10 项附件,再公开对应的新 Release。不得移动旧标签或覆盖既有 Release。 diff --git a/docs/adr/0047-paginated-provider-associations.md b/docs/adr/0047-paginated-provider-associations.md index d6dda86..103f473 100644 --- a/docs/adr/0047-paginated-provider-associations.md +++ b/docs/adr/0047-paginated-provider-associations.md @@ -16,7 +16,7 @@ Windows 比较路径时先统一普通 DOS 与 `\\?\` DOS、普通 UNC 与 `\\?\UNC\` 的等价表示,再做现有 Home 边界检查。数据库保存的原始 `rollout_path` 不改写;设备路径、越界路径和 WSL 限制不放宽。 -首行扫描、冻结计划与跳过事实在内部保留可信 ID 和 `historyMode`。锁定、变化、消失等后续事实保持原归属,以便准确排除 SQLite 行与 Restore 范围。 +首行扫描、冻结计划与跳过事实在内部保留可信 ID 和 `historyMode`。原计划成员在锁定、变化、消失等后续事实中保持原归属,以便准确排除 SQLite 行与 Restore 范围。计划后新增且 deferred 的文件仍无写资格,但提交前必须再次有界读取首行确认当前归属;新读取失败或首行无效时清除缓存归属,按未知新增保护分页候选,不能因首次 Apply 已见过该文件而复用旧 ID。SQLite 路径锚点可以保护其自身索引行,但不能替代新增文件当前首行的归属证据,即使该索引行已经对齐也如此。 Apply 新发现的文件只读取有界首行确认归属,始终 deferred,不能扩大本轮写集合。已知同 ID 的新增或变化成员阻止对应行更新;未知/坏新增文件保守阻止所有已知 paginated 候选(包括单文件候选)的 SQLite 更新。传统单文件已有 unknown 语义保持。SQLite 提交前复核整个相关组,包括原本已对齐、无需改写的成员;区分本轮流式替换后的新身份与外部并发修改。该复核降低竞态,但不承诺最后检查后与外部写者的原子隔离。 diff --git a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md index 49ec663..f1269fa 100644 --- a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md +++ b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md @@ -113,7 +113,7 @@ Sync 不改根 config、历史 model、cwd、user-event、workspace roots、titl → SQLite 原生事务提交 → 结果 / 受管备份清理 → 释放 Home 锁 ``` -分页组的 SQLite 行只有全体成员均已对齐或本次成功写入才可更新。Apply 新增文件仅有界读首行并 deferred,不扩大写集合;已知同 ID 新增/变化阻止对应行,未知/坏新增保守阻止全部已知分页候选行。提交前重核相关整组,包含原先已对齐成员,并区分本轮流式替换和外部变化;这是重试收敛保护,不是外部并发原子隔离。 +分页组的 SQLite 行只有全体成员均已对齐或本次成功写入才可更新。Apply 新增文件仅有界读首行并 deferred,不扩大写集合;提交前对已发现的 deferred 文件重新有界读首行,不沿用首次 Apply 的归属缓存,读取失败或无效时清除可信 ID/historyMode。已知同 ID 新增/变化阻止对应行,未知/坏新增保守阻止全部已知分页候选行。提交前重核相关整组,包含原先已对齐成员,并区分本轮流式替换和外部变化;这是重试收敛保护,不是外部并发原子隔离。 路径归属在备份前和既有 SQLite 写事务内按全部当前行重验,包含已对齐行与新行。新增或改变 owner 导致争用时只缩减冻结候选,保留受影响行,不阻止无关健康组。 diff --git a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md index 9c32b40..51fc3bf 100644 --- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md +++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md @@ -8,6 +8,8 @@ ADR-0047 增量:同 ID 多文件只有所有首行明确 `payload.history_mode 预览时已对齐的 SQLite 行也参与执行前复核:Provider/关联路径变化或行消失时保留现场并报告部分完成。已有 SQLite 写事务内再次核对完整预览快照,实际 UPDATE 始终限于原确认候选;纯观察不新增写事务或备份。 +计划后新发现的 deferred 文件在 SQLite 提交前仍须重新有界读取首行,确认当前 ID/historyMode;旧的新增归属缓存不能绕过最终复核。读取失败或无效首行作为未知新增保护分页候选,SQLite 路径锚点不能消除这类未知归属;原计划成员的冻结归属和本轮写集合保持。 + ## 2026-09-11:轻量状态相关性(ADR-0043) 普通 Status 使用内部 `status` revision:正文追加、非 Provider SQLite 列及 WAL/SHM 变化不导致失效;仍校验首行、文件身份/链接数/集合/最小大小、threads schema/ID/Provider/archived 和配置路径。Provider 状态不再整读/哈希数据库文件。真实漂移最多重试一次,实际锁与 pending Restore 继续优先阻断。超限/非法首行仍不完整、不可读数据库仍不可读,WSL 不执行 SQL。完整 Diagnostics、显式 full Status、Plan/Apply、Repair/Restore 与 PIO 不变。见 [ADR-0043](../../adr/0043-status-provider-relevant-revisions.md)。 diff --git a/docs/migration/BEHAVIOR_FIXTURES_ZH.md b/docs/migration/BEHAVIOR_FIXTURES_ZH.md index df4b0ac..f04588d 100644 --- a/docs/migration/BEHAVIOR_FIXTURES_ZH.md +++ b/docs/migration/BEHAVIOR_FIXTURES_ZH.md @@ -12,6 +12,8 @@ `test/provider-paginated-groups.test.js` 是 ADR-0047 的专用合成 Home fixture(Windows 使用 `D:\Temp`,其他平台使用系统临时目录):受锚定的同 ID `history_mode=paginated` 多文件须一次写全组及一条 SQLite 行;“文件已同步、SQLite 未同步”须只补该行、下一次 noop 且不建备份;重复 ordinal 仍可成功。传统重复 ID、缺 marker、错指/越界路径、跨 ID 和多 owner 必须保留冲突保护。锁定、删除、首行变化、写入失败、新增已知同 ID 与未知/坏新增分别验证 partial、关联行保留、重新预览后的收敛;预览后和 rollout 写期间新增/改变已对齐 owner 的路径争用保留受影响行,无关健康分页组继续。普通/归档目录覆盖 DOS/`\\?\` DOS、UNC/`\\?\UNC\` 等价路径及边界反例。配套 Provider facts/PIO fixture 覆盖等长原地、变长流式、Restore 的正文 byte、其他 metadata、文件身份和恢复范围。生产 Electron 验收要求隐藏窗口真实 Utility Core 的 Sync → noop → Restore;这些是要求的验证项目,不代表已执行。 +ADR-0047 的执行期重验补充:`provider-preparation-facts.test.js` 验证 deferred 文件每轮仅有界读取首行、重新确认归属且始终不纳入写集合;`provider-paginated-groups.test.js` 覆盖 Prepare 后、Apply 前新增 B 文件,rollout 写期间变为 A 或首行无效/不可读/消失时保留受影响分页索引,未知归属包括保护单文件分页候选,并验证重新预览后收敛。原计划成员变化后的冻结归属仍保持。 + ## ADR-0044:大首行与明确错误 `test/large-session-metadata.test.js` 覆盖 8 MiB 完整 Status→Sync/Switch→Restore、原地/变长正文不变、128 MiB LF/CRLF/EOF 读取边界与线性合并、超限/无效 Prepare 零写入及安全错误。`CPS_LARGE_HEADER_MIB=128` 可显式运行完整近上限读写。`provider-preparation-facts.test.js`、`status-coordination.test.js` 保留无效/超限拒绝及 Status 不完整检查。生产 Electron smoke 使用 8 MiB 首行验证大首行同步与恢复。 diff --git a/docs/release-notes/v1.0.4-zh.md b/docs/release-notes/v1.0.4-zh.md new file mode 100644 index 0000000..553cf63 --- /dev/null +++ b/docs/release-notes/v1.0.4-zh.md @@ -0,0 +1,52 @@ + + +# v1.0.4:修复分页会话同步冲突 + +新版 Codex 会将长会话拆成多个历史文件。此前这些文件可能一直提示“会话关联冲突”:文件已经换成新 Provider,索引却仍保留旧值,再次同步也无法完成。本版修复了这个问题;升级后再同步一次即可补齐符合归属规则的会话。 + +## 🚀 本次改进 + +- 同一会话的多个分页文件可以一起同步,文件和索引保持一致。 +- 已经发生“文件已同步、索引未同步”的情况,可以再次同步补齐;信息一致后不会重复写入或创建新备份。 +- 修复 Windows 长路径与普通路径写法不同引起的关联失败。 +- 同步期间新增、被占用或发生变化的文件继续保守跳过,并保留受影响的索引,避免误报全部完成。 + +## 📦 下载 + +| 使用场景 | 下载 | 更新方式 | +| --- | --- | --- | +| 日常使用 | `CodexProviderSync-1.0.4-windows-x64-setup.exe` | 安装版可检查、下载更新,再由你确认重启安装 | +| 便携使用 | `CodexProviderSync-1.0.4-windows-x64-portable.zip` | 完整解压后运行 `Codex Provider Sync.exe` | + +[查看发布状态、下载和校验文件](https://github.com/Dailin521/codex-provider-sync/releases/tag/v1.0.4)。本版为 Windows x64 Electron,未签名。 + +## ⬆️ 升级说明 + +1. 1.0.2、1.0.3 安装版可在应用内检查更新,下载后确认“重启并安装”;也可以退出旧程序后手动安装新版。 +2. 便携版请解压到新目录,不要只替换一个 EXE。Legacy .NET 用户也需要完整安装或解压 Electron,旧单 EXE 更新器不能直接完成迁移。 +3. 不需要删除 Codex 配置、数据库、历史会话或备份。确认当前 Provider 正确后,再执行一次同步即可。 + +## 🗂 会话同步怎么处理 + +- 只有归属明确的分页文件才会一起同步;重复文件、错误路径和多条索引争用同一文件仍会受到保护。 +- 出现“部分完成”时,查看被跳过的文件和原因。结束占用或处理文件问题后,重新预览并同步。 +- Provider 已一致但会话仍不能继续或 compact 时,需要结合 Codex 的具体报错判断;本次修复只解决 Provider 元数据关联和对齐。 + +## 🔒 数据处理方式 + +- 不读取 `auth.json`,不修改聊天正文、其他元数据、`updated_at` 或 `encrypted_content`。 +- 实际写入前创建备份,默认保留最近 2 份;需要撤回时可在备份页恢复。无需修改时不创建备份。 +- Windows 的路径写法只用于关联比较,数据库中的原始路径保持。Windows WSL UNC 数据库仍仅用于诊断,写操作需在对应 WSL 内执行。 + +## 🔍 本版验证 + +- 维护者已使用发布前的本地 1.0.4 测试包(`d491990`)实测,确认 #107 的同步冲突得到修复;正式包另行按最终源码提交验收。 +- 回归覆盖分页多文件、已有半同步、执行期间变化、长路径、正文保持和恢复范围;生产程序使用隔离数据执行 Sync → 再次同步 → Restore。 +- 正式包须在最终源码提交通过完整 CI 后构建,并完成安装版与便携版的隔离容器验收;对应源码、哈希和验收记录随发布页附件提供。 +- 签名、线上跨版本下载安装和真实 WSL 使用尚未独立验收;本轮不发布 npm、Legacy 或其他平台安装包。 + +## 🙏 感谢 + +感谢 [@Lorikein12138](https://github.com/Lorikein12138) 在 [#107](https://github.com/Dailin521/codex-provider-sync/issues/107) 提供复现步骤和根因分析,帮助定位分页多文件与 Windows 长路径的关联问题。 + +[使用说明](https://github.com/Dailin521/codex-provider-sync#readme) · [桌面版指南](https://github.com/Dailin521/codex-provider-sync/blob/main/docs/README_DESKTOP_ZH.md) · [完整变更对比](https://github.com/Dailin521/codex-provider-sync/compare/v1.0.3...v1.0.4) diff --git a/src/operation-revision.js b/src/operation-revision.js index c16a60f..673cc19 100644 --- a/src/operation-revision.js +++ b/src/operation-revision.js @@ -236,15 +236,18 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c const key = providerPathKey(filePath); seen.add(key); const prior = expected?.get(key); + const deferred = prior?.skip?.reason === "deferred"; let record; let revision; if (expected && (!prior || prior.skip)) { const skip = prior?.skip ?? rolloutSkip(filePath, "deferred", "revalidate"); revision = { skipped: skip.reason }; - record = { skip, id: prior?.id ?? null, historyMode: prior?.historyMode ?? null }; + record = { skip, id: null, historyMode: null }; // New targets stay deferred. A bounded header read establishes only // which existing index row must be protected, never write eligibility. - if (!prior && mode === "provider") { + // Refresh on every revalidation: unlike frozen Prepare members, their + // previously observed association cannot protect a later changed ID. + if ((!prior || deferred) && mode === "provider") { try { await captureProviderHeader(filePath, fsImpl, undefined, (_, value) => { record = { ...value, ...(value.record ? providerAssociationFromHeader(value.record.firstLine) : {}), skip }; @@ -273,7 +276,7 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c revision = { skipped: reason }; } } - if (record?.skip && prior) record = { ...record, id: prior.id ?? null, historyMode: prior.historyMode ?? null }; + if (record?.skip && prior && !deferred) record = { ...record, id: prior.id ?? null, historyMode: prior.historyMode ?? null }; onProviderHeader?.(filePath, record); fileBindings.push({ path: filePath, ...revision, ...(record?.skip ? { skip: record.skip } : {}) }); const { observedSize, ...binding } = revision; @@ -284,8 +287,11 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c } if (expected) for (const [key, prior] of expected) if (!seen.has(key)) { const skip = prior.skip ?? rolloutSkip(prior.path, "missing", "revalidate", prior.id); - onProviderHeader?.(prior.path, { skip, id: prior.id ?? null, historyMode: prior.historyMode ?? null }); - fileBindings.push({ ...prior, skip }); + const association = skip.reason === "deferred" + ? { id: null, historyMode: null } + : { id: prior.id ?? null, historyMode: prior.historyMode ?? null }; + onProviderHeader?.(prior.path, { skip, ...association }); + fileBindings.push({ ...prior, skip, ...association }); } manifest.sort((left, right) => left.path.localeCompare(right.path)); return { diff --git a/src/provider-skips.js b/src/provider-skips.js index 68a6dff..debe4c9 100644 --- a/src/provider-skips.js +++ b/src/provider-skips.js @@ -121,7 +121,12 @@ export function selectProviderRows(home, scan, state, targetProvider, additional const file = byPath.get(key); if (file?.id && (state.key !== "rowid" || (byId.get(file.id)?.length === 1 && !ownersByPath.has(key)))) associatedBadPaths.add(key); } - const unknown = [...skippedPaths].some(key => !associatedBadPaths.has(key)); + // A path-only SQLite owner cannot establish the current identity of a new + // deferred file. Keep its unknown membership protective for paginated rows. + // Frozen Prepare members retain the existing positive-association rules. + const unknown = [...skippedPaths].some(key => !associatedBadPaths.has(key)) + || skipped.some(item => item.kind === "rollout" && item.reason === "deferred" + && !byPath.get(providerPathKey(item.path))?.id); const selected = []; const rowSkips = []; for (const row of rows) { diff --git a/test/provider-paginated-groups.test.js b/test/provider-paginated-groups.test.js index 6d7698b..bfc85fd 100644 --- a/test/provider-paginated-groups.test.js +++ b/test/provider-paginated-groups.test.js @@ -382,6 +382,153 @@ test("an unknown rollout added during Apply conservatively preserves every known assert.equal(await value.provider("thread-single"), "prov_a"); }); +for (const mode of ["reassigned", "invalid", "unknown", "locked", "unreadable", "missing"]) { + test(`a deferred different thread becoming ${mode} during rollout writes is revalidated before SQLite`, async () => { + const group = twoFileGroup("thread-recheck"); + const single = { name: "rollout-thread-single.jsonl", id: "thread-single", provider: "openai" }; + const value = await paginatedFixture({ + files: [...group, single], + rows: [ + { id: "thread-recheck", provider: "openai", file: group[0].name }, + { id: "thread-single", provider: "openai", file: single.name } + ] + }); + const added = path.join(value.home, "sessions", "rollout-deferred-other.jsonl"); + let changed = false; + let externalBytes; + const originalOpen = fs.open; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "after_rollout_apply" || changed) return; + changed = true; + if (mode === "reassigned") await fs.writeFile(added, contents({ name: "reassigned", id: "thread-recheck", provider: "openai" })); + if (mode === "invalid") await fs.writeFile(added, "invalid header\n"); + if (mode === "unknown") await fs.writeFile(added, '{"type":"event_msg","payload":{}}\n'); + if (mode === "missing") await fs.unlink(added); + else externalBytes = await fs.readFile(added); + } + }); + // Discovery during the first Apply scan freezes this path as deferred with + // thread-other's ID. The subsequent write hook invalidates that evidence. + await fs.writeFile(added, contents({ name: "other", id: "thread-other", provider: "openai" })); + fs.open = async (target, ...args) => { + if (changed && path.resolve(String(target)) === added && ["locked", "unreadable"].includes(mode)) { + throw Object.assign(new Error("synthetic inaccessible deferred member"), { code: mode === "locked" ? "EBUSY" : "EACCES" }); + } + return originalOpen(target, ...args); + }; + let result; + try { result = await apply(plan); } + finally { fs.open = originalOpen; } + assert.equal(changed, true); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-recheck"), "openai"); + assert.equal(await value.provider("thread-single"), mode === "reassigned" ? "prov_a" : "openai"); + assert.equal(result.result.changedSessionFiles, 3, "only the three prepared files may be written"); + assert.ok(result.result.skipSummary.items.some(item => item.kind === "rollout" && item.path === added && item.reason === "deferred")); + if (mode === "missing") await assert.rejects(fs.stat(added), { code: "ENOENT" }); + else assert.deepEqual(await fs.readFile(added), externalBytes, "the deferred file must remain untouched"); + + if (["invalid", "unknown"].includes(mode)) await fs.unlink(added); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed"); + assert.equal(await value.provider("thread-recheck"), "prov_a"); + assert.equal(await value.provider("thread-single"), "prov_a"); + if (["reassigned", "locked", "unreadable"].includes(mode)) assert.equal(providerOf(await fs.readFile(added)), "prov_a"); + }); +} + +for (const mode of ["invalid", "unreadable"]) for (const indexedProvider of ["openai", "prov_a"]) { + test(`a ${mode} deferred file with a ${indexedProvider} SQLite path owner still protects all paginated candidates`, async () => { + const group = twoFileGroup("thread-path-group"); + const single = { name: "rollout-thread-path-single.jsonl", id: "thread-path-single", provider: "openai" }; + const value = await paginatedFixture({ + files: [...group, single], + rows: [ + { id: "thread-path-group", provider: "openai", file: group[0].name }, + { id: "thread-path-single", provider: "openai", file: single.name }, + { id: "thread-path-other", provider: indexedProvider } + ] + }); + const added = path.join(value.home, "sessions", "rollout-deferred-path-owner.jsonl"); + await mutateRows(value.dbPath, database => { + database.prepare("UPDATE threads SET rollout_path = ? WHERE id = ?").run(added, "thread-path-other"); + }); + let changed = false; + let externalBytes; + const originalOpen = fs.open; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "after_rollout_apply" || changed) return; + changed = true; + if (mode === "invalid") await fs.writeFile(added, "invalid header\n"); + externalBytes = await fs.readFile(added); + } + }); + await fs.writeFile(added, contents({ name: "other", id: "thread-path-other", provider: "openai" })); + fs.open = async (target, ...args) => { + if (changed && path.resolve(String(target)) === added && mode === "unreadable") { + throw Object.assign(new Error("synthetic unreadable deferred member"), { code: "EACCES" }); + } + return originalOpen(target, ...args); + }; + let result; + try { result = await apply(plan); } + finally { fs.open = originalOpen; } + assert.equal(changed, true); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-path-group"), "openai"); + assert.equal(await value.provider("thread-path-single"), "openai"); + assert.equal(await value.provider("thread-path-other"), indexedProvider); + assert.equal(result.result.sqliteRowsUpdated, 0); + assert.equal(result.result.changedSessionFiles, 3); + assert.deepEqual(await fs.readFile(added), externalBytes); + for (const id of ["thread-path-group", "thread-path-single"]) { + assert.ok(result.result.skipSummary.items.some(item => item.kind === "sqlite" && item.id === id && item.reason === "association-unknown")); + } + await fs.writeFile(added, contents({ name: "other", id: "thread-path-other", provider: "openai" })); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed"); + assert.equal(await value.provider("thread-path-group"), "prov_a"); + assert.equal(await value.provider("thread-path-single"), "prov_a"); + assert.equal(await value.provider("thread-path-other"), "prov_a"); + }); +} + +test("refreshing a deferred association cannot readmit a SQLite row excluded at the first Apply scan", async () => { + const affected = twoFileGroup("thread-excluded"); + const healthy = twoFileGroup("thread-healthy"); + const value = await paginatedFixture({ + files: [...affected, ...healthy], + rows: [ + { id: "thread-excluded", provider: "openai", file: affected[0].name }, + { id: "thread-healthy", provider: "openai", file: healthy[0].name } + ] + }); + const added = path.join(value.home, "sessions", "rollout-deferred-moving.jsonl"); + let changed = false; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "after_rollout_apply" || changed) return; + changed = true; + await fs.writeFile(added, contents({ name: "moving", id: "thread-other", provider: "openai" })); + } + }); + await fs.writeFile(added, contents({ name: "moving", id: "thread-excluded", provider: "openai" })); + const result = await apply(plan); + assert.equal(changed, true); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("thread-excluded"), "openai"); + assert.equal(await value.provider("thread-healthy"), "prov_a"); + assert.equal(providerOf(await fs.readFile(added)), "openai"); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed"); + assert.equal(await value.provider("thread-excluded"), "prov_a"); +}); + test("SQLite commit revalidates every group member, including an initially aligned member changed by another writer", async () => { const files = twoFileGroup("thread-revalidate", ["custom", "prov_a"]); const value = await paginatedFixture({ diff --git a/test/provider-preparation-facts.test.js b/test/provider-preparation-facts.test.js index a9d74db..f6c4437 100644 --- a/test/provider-preparation-facts.test.js +++ b/test/provider-preparation-facts.test.js @@ -65,6 +65,9 @@ test("paginated ID and history mode remain frozen when a member changes, disappe const changed = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles }); assert.deepEqual(changed.scan.files.map(file => [file.id, file.historyMode]), [["fixture", "paginated"]]); assert.equal(changed.scan.skippedItems[0].reason, "changed"); + const frozen = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles: changed.rollout.fileBindings }); + assert.deepEqual(frozen.scan.files.map(file => [file.id, file.historyMode]), [["fixture", "paginated"]]); + assert.equal(frozen.scan.changes.length, 0); const locked = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles, fsImpl: { ...fs, async open(target, ...args) { if (String(target) === value.file) throw Object.assign(new Error("synthetic locked member"), { code: "EBUSY" }); @@ -96,8 +99,53 @@ test("new files lend bounded association facts but never enter the frozen write assert.ok(facts.scan.skippedItems.some(item => item.path === added && item.reason === "deferred")); assert.ok(facts.scan.changes.every(change => change.path !== added)); assert.equal(facts.rollout.fileBindings.find(file => file.path === added).historyMode, "paginated"); + + await fs.writeFile(added, Buffer.concat([Buffer.from(JSON.stringify({ type: "session_meta", payload: { + id: "different", model_provider: "custom" + } }) + "\n"), value.body])); + const recheckProbe = headerReadProbe(added); + try { + for (let iteration = 1; iteration <= 2; iteration += 1) { + facts = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles: facts.rollout.fileBindings }); + assert.deepEqual(recheckProbe.counts(), { opens: iteration, bytes: iteration * 64 * 1024 }); + assert.deepEqual(facts.scan.files.find(file => file.path === added), { path: added, id: "different", historyMode: null }); + assert.ok(facts.scan.skippedItems.some(item => item.path === added && item.reason === "deferred")); + assert.ok(facts.scan.changes.every(change => change.path !== added)); + } + } finally { recheckProbe.restore(); } }); +for (const mode of ["invalid", "unknown", "locked", "unreadable", "missing"]) { + test(`deferred association loses stale trust when its next header is ${mode}`, async t => { + const value = await fixture(t); + const original = await collectProviderPreparationFacts(value.home, "openai"); + const added = path.join(value.home, "sessions", "rollout-added.jsonl"); + await fs.writeFile(added, JSON.stringify({ type: "session_meta", payload: { + id: "different", history_mode: "paginated", model_provider: "custom" + } }) + "\n"); + const discovered = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles: original.rollout.fileBindings }); + if (mode === "invalid") await fs.writeFile(added, "invalid header\n"); + if (mode === "unknown") await fs.writeFile(added, '{"type":"event_msg","payload":{}}\n'); + if (mode === "missing") await fs.unlink(added); + const fsImpl = { ...fs, async open(target, ...args) { + if (String(target) === added && ["locked", "unreadable"].includes(mode)) { + throw Object.assign(new Error("synthetic inaccessible new member"), { code: mode === "locked" ? "EBUSY" : "EACCES" }); + } + return fs.open(target, ...args); + } }; + let facts = discovered; + for (let iteration = 0; iteration < 2; iteration += 1) { + facts = await collectProviderPreparationFacts(value.home, "openai", { expectedFiles: facts.rollout.fileBindings, fsImpl }); + assert.deepEqual(facts.scan.files.find(file => file.path === added), { path: added, id: null, historyMode: null }); + const binding = facts.rollout.fileBindings.find(file => file.path === added); + assert.equal(binding.id, null); + assert.equal(binding.historyMode, null); + assert.equal(binding.skip.reason, "deferred"); + assert.ok(facts.scan.changes.every(change => change.path !== added)); + } + }); +} + test("Provider facts reuse the exact revision and change algorithm with one bounded header read instead of three", async t => { const value = await fixture(t); const probe = headerReadProbe(value.file); diff --git a/test/provider-skip-associations.test.js b/test/provider-skip-associations.test.js index 1f05c2a..8a0d133 100644 --- a/test/provider-skip-associations.test.js +++ b/test/provider-skip-associations.test.js @@ -64,6 +64,18 @@ test("a deferred known ID that makes its own group ambiguous does not block anot assert.deepEqual(result.skippedItems.map(r => [r.id, r.reason]), [["same", "association-conflict"]]); }); +test("only an unidentified deferred file overrides its SQLite path owner's positive association", () => { + const legacy = { path: path.join(home, "sessions", "rollout-legacy.jsonl"), id: "legacy", historyMode: null }; + const files = [{ path: good, id: "paged", historyMode: "paginated" }, { path: bad, id: null, historyMode: null }, legacy]; + for (const ownerProvider of ["old", "openai"]) for (const reason of ["deferred", "locked", "metadata-invalid"]) { + const scan = { files, skippedItems: [rolloutSkip(bad, reason)] }; + const state = { key: "id", rows: [row("paged", good), { ...row("owner", bad), model_provider: ownerProvider }, row("legacy", legacy.path)] }; + const result = selectProviderRows(home, scan, state, "openai"); + assert.deepEqual(result.rows.map(r => r.id), reason === "deferred" ? ["legacy"] : ["paged", "legacy"], `${ownerProvider}: ${reason}`); + if (reason === "deferred") assert.ok(result.skippedItems.some(item => item.id === "paged" && item.reason === "association-unknown")); + } +}); + test("Windows DOS and UNC namespace aliases associate without changing stored paths", { skip: process.platform !== "win32" }, () => { for (const testHome of [home, "\\\\server\\share\\fixture"]) { const file = path.join(testHome, "sessions", "rollout-fixture.jsonl"); From c1c8588b6662d5ea4ca94098e50e2efc43e30ef8 Mon Sep 17 00:00:00 2001 From: "DAL\\Administrator" <3452720699@qq.com> Date: Mon, 28 Sep 2026 11:46:58 +0800 Subject: [PATCH 4/4] fix(core): complete final association checks before SQLite --- .../0047-paginated-provider-associations.md | 4 +- .../architecture/NODE_CORE_ARCHITECTURE_ZH.md | 2 + .../contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md | 2 + docs/migration/BEHAVIOR_FIXTURES_ZH.md | 4 + .../core/src/application/provider-sync.js | 13 +- src/provider-skips.js | 3 +- test/provider-paginated-groups.test.js | 127 ++++++++++++++++++ test/provider-skip-associations.test.js | 19 +++ 8 files changed, 164 insertions(+), 10 deletions(-) diff --git a/docs/adr/0047-paginated-provider-associations.md b/docs/adr/0047-paginated-provider-associations.md index 103f473..fbbb126 100644 --- a/docs/adr/0047-paginated-provider-associations.md +++ b/docs/adr/0047-paginated-provider-associations.md @@ -14,12 +14,14 @@ 缺少 marker、legacy/非 paginated、多文件但没有路径锚点、跨 ID,或多个 owner 的情形仍是 `association-conflict`/`association-unknown`,不推测归属。单文件候选沿用 ADR-0045 的旧选择规则。`ordinal` 不参与上述判断。 -Windows 比较路径时先统一普通 DOS 与 `\\?\` DOS、普通 UNC 与 `\\?\UNC\` 的等价表示,再做现有 Home 边界检查。数据库保存的原始 `rollout_path` 不改写;设备路径、越界路径和 WSL 限制不放宽。 +Windows 比较路径时先统一普通 DOS 与 `\\?\` DOS、普通 UNC 与 `\\?\UNC\` 的等价表示,再做现有 Home 边界检查;范围目录名与关联键均按 Windows 大小写语义比较,POSIX 保持大小写敏感。数据库保存的原始 `rollout_path` 不改写;设备路径、越界路径和 WSL 限制不放宽。 首行扫描、冻结计划与跳过事实在内部保留可信 ID 和 `historyMode`。原计划成员在锁定、变化、消失等后续事实中保持原归属,以便准确排除 SQLite 行与 Restore 范围。计划后新增且 deferred 的文件仍无写资格,但提交前必须再次有界读取首行确认当前归属;新读取失败或首行无效时清除缓存归属,按未知新增保护分页候选,不能因首次 Apply 已见过该文件而复用旧 ID。SQLite 路径锚点可以保护其自身索引行,但不能替代新增文件当前首行的归属证据,即使该索引行已经对齐也如此。 Apply 新发现的文件只读取有界首行确认归属,始终 deferred,不能扩大本轮写集合。已知同 ID 的新增或变化成员阻止对应行更新;未知/坏新增文件保守阻止所有已知 paginated 候选(包括单文件候选)的 SQLite 更新。传统单文件已有 unknown 语义保持。SQLite 提交前复核整个相关组,包括原本已对齐、无需改写的成员;区分本轮流式替换后的新身份与外部并发修改。该复核降低竞态,但不承诺最后检查后与外部写者的原子隔离。 +只要冻结 SQLite 写候选非空,提交前就再次有界复核首行,不以存在分页候选或首次 Apply 已发现 deferred 为前提;首轮 Apply 扫描后才新增的可信同 ID 文件也保护传统单文件的对应行。本轮成功写入的全部成员记录新的首行与身份 binding,涵盖原地和流式写;无 SQLite 写候选时不额外进行这些确认,不新增事务或备份。该有限终检不改变传统 unknown 的正关联规则,也不代表整个 Sync 只读取一次首行。 + SQLite 路径归属在备份前按当前快照重新选择,并在既有原生写事务中按全部当前索引行再次核验,包含已对齐行和执行期间新增行。新增或变更 owner 争用分页成员时保留受影响行;写候选只能从冻结集合缩减,健康组仍可继续。 PIO-1~PIO-6、原地与流式写策略、UndoBackup、partial、原生 SQLite 事务及既有写前/写后校验保持。SQLite-only 的半同步状态在组内全部文件已对齐时可于重试补齐行;随后 noop 不创建备份。 diff --git a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md index f1269fa..8d1fba6 100644 --- a/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md +++ b/docs/architecture/NODE_CORE_ARCHITECTURE_ZH.md @@ -115,6 +115,8 @@ Sync 不改根 config、历史 model、cwd、user-event、workspace roots、titl 分页组的 SQLite 行只有全体成员均已对齐或本次成功写入才可更新。Apply 新增文件仅有界读首行并 deferred,不扩大写集合;提交前对已发现的 deferred 文件重新有界读首行,不沿用首次 Apply 的归属缓存,读取失败或无效时清除可信 ID/historyMode。已知同 ID 新增/变化阻止对应行,未知/坏新增保守阻止全部已知分页候选行。提交前重核相关整组,包含原先已对齐成员,并区分本轮流式替换和外部变化;这是重试收敛保护,不是外部并发原子隔离。 +只要有冻结 SQLite 写候选,SQLite 步骤提交前就执行有界首行终检,不依赖分页或已知 deferred 的存在。本轮所有成功写入成员先确认新的 binding,涵盖传统文件的原地/流式写;首轮 Apply 后才新增的已知同 ID 文件仍能保护对应传统行。无 SQLite 写候选时不额外确认 binding 或终检,不新增事务、备份或写目标;传统 unknown 正关联规则与候选只收缩的限制保持。 + 路径归属在备份前和既有 SQLite 写事务内按全部当前行重验,包含已对齐行与新行。新增或改变 owner 导致争用时只缩减冻结候选,保留受影响行,不阻止无关健康组。 只使用 `/tmp/provider-sync.lock`;跨 Home 的 SQLite 竞争交给原生事务。预检不预留未来数据库写锁:之后仍可能 busy。首次 mutation 前失败零业务写入;mutation 后失败为带 backupId/failedStage/failureCode/retryRecommended 的 `partial`,重新 Prepare/执行收敛,或用户手动 Restore。 diff --git a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md index 51fc3bf..33cd0a5 100644 --- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md +++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md @@ -10,6 +10,8 @@ ADR-0047 增量:同 ID 多文件只有所有首行明确 `payload.history_mode 计划后新发现的 deferred 文件在 SQLite 提交前仍须重新有界读取首行,确认当前 ID/historyMode;旧的新增归属缓存不能绕过最终复核。读取失败或无效首行作为未知新增保护分页候选,SQLite 路径锚点不能消除这类未知归属;原计划成员的冻结归属和本轮写集合保持。 +有冻结 SQLite 写候选时始终执行提交前的有界首行终检,包含没有分页候选的操作,并记录本轮全部成功写入成员的新 binding,避免将自身原地/流式修改误判为外部变化。首轮 Apply 后才新增的可信同 ID 文件同样保护对应传统索引行;传统 unknown 正关联规则保持。没有 SQLite 写候选时不额外确认写后 binding,不新增终检、事务或备份;实际 UPDATE 仍只缩减原候选集合。 + ## 2026-09-11:轻量状态相关性(ADR-0043) 普通 Status 使用内部 `status` revision:正文追加、非 Provider SQLite 列及 WAL/SHM 变化不导致失效;仍校验首行、文件身份/链接数/集合/最小大小、threads schema/ID/Provider/archived 和配置路径。Provider 状态不再整读/哈希数据库文件。真实漂移最多重试一次,实际锁与 pending Restore 继续优先阻断。超限/非法首行仍不完整、不可读数据库仍不可读,WSL 不执行 SQL。完整 Diagnostics、显式 full Status、Plan/Apply、Repair/Restore 与 PIO 不变。见 [ADR-0043](../../adr/0043-status-provider-relevant-revisions.md)。 diff --git a/docs/migration/BEHAVIOR_FIXTURES_ZH.md b/docs/migration/BEHAVIOR_FIXTURES_ZH.md index f04588d..9cb16ad 100644 --- a/docs/migration/BEHAVIOR_FIXTURES_ZH.md +++ b/docs/migration/BEHAVIOR_FIXTURES_ZH.md @@ -14,6 +14,10 @@ ADR-0047 的执行期重验补充:`provider-preparation-facts.test.js` 验证 deferred 文件每轮仅有界读取首行、重新确认归属且始终不纳入写集合;`provider-paginated-groups.test.js` 覆盖 Prepare 后、Apply 前新增 B 文件,rollout 写期间变为 A 或首行无效/不可读/消失时保留受影响分页索引,未知归属包括保护单文件分页候选,并验证重新预览后收敛。原计划成员变化后的冻结归属仍保持。 +同一 fixture 还覆盖没有分页候选的操作:传统文件等长/变长写入后的 binding 确认、已有 deferred 归属变更、首轮 Apply 后才新增的同 ID 文件、无关可信 ID 和未知首行。已知同 ID 保护对应行,新文件不写;传统 unknown 正关联、SQLite-only 收敛和无 SQLite 写候选的读取/事务/备份边界保持。PIO 的 Prepare 单轮读取次数、正文、mtime 与文件身份断言不放宽。 + +`provider-skip-associations.test.js` 另覆盖 Windows DOS/UNC、普通/namespace 路径及 sessions/archived_sessions 大小写别名:锚点仍可关联,已经对齐的跨 ID owner 仍触发冲突,数据库原始路径保持。 + ## ADR-0044:大首行与明确错误 `test/large-session-metadata.test.js` 覆盖 8 MiB 完整 Status→Sync/Switch→Restore、原地/变长正文不变、128 MiB LF/CRLF/EOF 读取边界与线性合并、超限/无效 Prepare 零写入及安全错误。`CPS_LARGE_HEADER_MIB=128` 可显式运行完整近上限读写。`provider-preparation-facts.test.js`、`status-coordination.test.js` 保留无效/超限拒绝及 Status 不完整检查。生产 Electron smoke 使用 8 MiB 首行验证大首行同步与恢复。 diff --git a/packages/core/src/application/provider-sync.js b/packages/core/src/application/provider-sync.js index a9267ae..e29b951 100644 --- a/packages/core/src/application/provider-sync.js +++ b/packages/core/src/application/provider-sync.js @@ -187,10 +187,6 @@ export async function buildProviderWriteProgram(context, settings = {}) { } } const sqliteRowsToWrite = selection.rows.length; - const paginatedPaths = new Set(scan.files.filter(file => file.historyMode === "paginated") - .map(file => providerPathKey(file.path))); - const protectedPaths = new Set(selection.rows.flatMap(row => row.paths) - .filter(filePath => paginatedPaths.has(providerPathKey(filePath))).map(providerPathKey)); const finalBindings = new Map(facts.rollout.fileBindings.map(file => [providerPathKey(file.path), file])); selection.skippedItems.push(...(context.expectedPlanState?.providerScope?.excludedRows ?? [])); const initialSkips = uniqueSkips([...(scan.skippedItems ?? []), @@ -296,7 +292,7 @@ export async function buildProviderWriteProgram(context, settings = {}) { if (mutation.result === "APPLIED_IN_PLACE") partialRollout.inPlaceChanges += 1; writeContext.markMutation(); const key = providerPathKey(change.path); - if (protectedPaths.size > 0) { + if (sqliteRowsToWrite > 0) { const binding = await captureAppliedProviderBinding(change, finalBindings.get(key), mutation.result); finalBindings.set(key, binding); if (binding.skip) { @@ -340,9 +336,10 @@ export async function buildProviderWriteProgram(context, settings = {}) { run: async ({ context: writeContext, state }) => { // Reuse the bounded reader/manifest. Only the originally // admitted rows may survive; new files are association-only. - const finalScan = protectedPaths.size > 0 - ? (await collectProviderPreparationFacts(context.codexHome, targetProvider, { expectedFiles: [...finalBindings.values()] })).scan - : scan; + // New IDs can also join legacy rows or invalidate SQLite-only + // associations, even when no paginated row was selected. + const finalScan = (await collectProviderPreparationFacts(context.codexHome, targetProvider, + { expectedFiles: [...finalBindings.values()] })).scan; const finalSelection = selectProviderRows(context.codexHome, finalScan, sqliteState, targetProvider, [...initiallySkipped.map(filePath => rolloutSkip(filePath, "locked", "revalidate")), ...writeSkips]); const eligible = new Set(selection.rows.map(row => String(row.id))); diff --git a/src/provider-skips.js b/src/provider-skips.js index debe4c9..5c6f066 100644 --- a/src/provider-skips.js +++ b/src/provider-skips.js @@ -68,7 +68,8 @@ function rowRolloutPath(home, value) { if (comparableHome === null || comparableValue === null) return null; const absolute = path.resolve(comparableHome, comparableValue); const relative = path.relative(comparableHome, absolute).split(path.sep); - if (!["sessions", "archived_sessions"].includes(relative[0]) || relative.includes("..")) return null; + const scopeDirectory = process.platform === "win32" ? relative[0].toLowerCase() : relative[0]; + if (!["sessions", "archived_sessions"].includes(scopeDirectory) || relative.includes("..")) return null; return providerPathKey(absolute); } diff --git a/test/provider-paginated-groups.test.js b/test/provider-paginated-groups.test.js index bfc85fd..0fa3d75 100644 --- a/test/provider-paginated-groups.test.js +++ b/test/provider-paginated-groups.test.js @@ -497,6 +497,133 @@ for (const mode of ["invalid", "unreadable"]) for (const indexedProvider of ["op }); } +for (const configProvider of ["prov_a", "provider_long"]) for (const mode of ["reassigned", "invalid", "unreadable", "unchanged"]) { + test(`legacy SQLite candidates revalidate a ${mode} deferred file after ${configProvider} rollout writes`, async () => { + const files = ["legacy-a", "legacy-healthy"].map(id => ({ name: `rollout-${id}.jsonl`, id, provider: "custom", historyMode: null })); + const value = await paginatedFixture({ + configProvider, + files, + rows: [ + ...files.map(file => ({ id: file.id, provider: "custom", file: file.name })), + { id: "index-only", provider: "custom" } + ] + }); + const added = path.join(value.home, "sessions", "rollout-deferred-legacy.jsonl"); + let changed = false; + let externalBytes; + const originalOpen = fs.open; + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point !== "after_rollout_apply" || changed) return; + changed = true; + if (mode === "reassigned") await fs.writeFile(added, contents({ name: "reassigned", id: "legacy-a", provider: "custom", historyMode: null })); + if (mode === "invalid") await fs.writeFile(added, "invalid header\n"); + externalBytes = await fs.readFile(added); + } + }); + await fs.writeFile(added, contents({ name: "other", id: "legacy-other", provider: "custom", historyMode: null })); + fs.open = async (target, ...args) => { + if (changed && path.resolve(String(target)) === added && mode === "unreadable") { + throw Object.assign(new Error("synthetic unreadable legacy member"), { code: "EACCES" }); + } + return originalOpen(target, ...args); + }; + let result; + try { result = await apply(plan); } + finally { fs.open = originalOpen; } + assert.equal(changed, true); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider("legacy-a"), mode === "reassigned" ? "custom" : configProvider); + assert.equal(await value.provider("legacy-healthy"), configProvider, "our own writes must not become changed-member skips"); + assert.equal(await value.provider("index-only"), ["invalid", "unreadable"].includes(mode) ? "custom" : configProvider); + assert.equal(result.result.changedSessionFiles, 2); + assert.equal(result.result.inPlaceSessionFiles, configProvider === "prov_a" ? 2 : 0); + assert.equal(result.result.rewrittenSessionFiles, configProvider === "provider_long" ? 2 : 0); + assert.deepEqual(await fs.readFile(added), externalBytes, "the deferred file stays outside this write set"); + for (const file of files) assert.equal(providerOf(await value.read(file.name)), configProvider); + if (["reassigned", "invalid"].includes(mode)) await fs.unlink(added); + const retry = await apply(await prepareSync({ codexHome: value.home })); + assert.equal(retry.outcome, "completed"); + for (const id of ["legacy-a", "legacy-healthy", "index-only"]) assert.equal(await value.provider(id), configProvider); + }); +} + +for (const configProvider of ["prov_a", "provider_long"]) for (const mode of ["same-id", "other-id", "unknown"]) { + test(`an ${mode} file first appearing after ${configProvider} rollout writes revalidates an aligned legacy member's SQLite-only candidate`, async () => { + const aligned = { name: "rollout-legacy-aligned.jsonl", id: "legacy-aligned", provider: configProvider, historyMode: null }; + const writer = { name: "rollout-legacy-writer.jsonl", id: "legacy-writer", provider: "custom", historyMode: null }; + const value = await paginatedFixture({ + configProvider, + files: [aligned, writer], + rows: [ + { id: aligned.id, provider: "custom", file: aligned.name }, + { id: writer.id, provider: configProvider, file: writer.name } + ] + }); + const added = path.join(value.home, "sessions", "rollout-new-legacy.jsonl"); + const addedBytes = mode === "unknown" ? "invalid header\n" + : contents({ name: "later", id: mode === "same-id" ? aligned.id : "legacy-other", provider: "custom", historyMode: null }); + const plan = await prepareSync({ + codexHome: value.home, + faultInjector: async ({ point }) => { + if (point === "after_rollout_apply") await fs.writeFile(added, addedBytes); + } + }); + assert.equal(plan.impact.sqliteRowsToChange, 1); + assert.equal(plan.impact.rolloutFilesToChange, 1); + const result = await apply(plan); + assert.equal(result.outcome, "partial"); + assert.equal(await value.provider(aligned.id), mode === "same-id" ? "custom" : configProvider); + assert.equal(await value.provider(writer.id), configProvider); + assert.equal(result.result.changedSessionFiles, 1); + assert.equal(result.result.inPlaceSessionFiles, configProvider === "prov_a" ? 1 : 0); + assert.equal(result.result.rewrittenSessionFiles, configProvider === "provider_long" ? 1 : 0); + assert.equal(await fs.readFile(added, "utf8"), addedBytes); + assert.ok(result.result.skipSummary.items.some(item => item.path === added && item.reason === "deferred")); + assert.ok(!result.result.skipSummary.items.some(item => item.path === value.file(writer.name)), "successful writes keep their new binding"); + }); +} + +test("no SQLite candidates add no acknowledgement header reads or SQLite step, and the next noop adds no backup", async () => { + const file = { name: "rollout-legacy-no-sqlite-write.jsonl", id: "legacy", provider: "custom", historyMode: null }; + const value = await paginatedFixture({ files: [file], rows: [{ id: file.id, provider: "prov_a", file: file.name }] }); + let writing = false; + let readOnlyOpens = 0; + let backups = 0; + const stages = []; + const options = { + codexHome: value.home, + onProgress: event => stages.push(event.stage), + faultInjector: ({ point }) => { + if (point === "before_rollout_apply") writing = true; + if (point === "after_rollout_mutation_before_applied") writing = false; + if (point === "before_backup") backups += 1; + } + }; + const plan = await prepareSync(options); + assert.equal(plan.impact.sqliteRowsToChange, 0); + const originalOpen = fs.open; + fs.open = async (target, ...args) => { + if (writing && path.resolve(String(target)) === value.file(file.name) && args[0] === "r") readOnlyOpens += 1; + return originalOpen(target, ...args); + }; + let result; + try { result = await apply(plan); } + finally { fs.open = originalOpen; } + assert.equal(result.outcome, "completed"); + assert.equal(result.result.inPlaceSessionFiles, 1); + assert.equal(result.result.sqliteRowsUpdated, 0); + assert.equal(readOnlyOpens, 0, "SQLite association acknowledgements are unnecessary without SQLite candidates"); + assert.ok(!stages.includes("update_sqlite")); + assert.equal(backups, 1, "only the rollout mutation needs a backup"); + const noop = await apply(await prepareSync(options)); + assert.equal(noop.result.noop, true); + assert.equal(noop.backup, null); + assert.equal(backups, 1); + assert.ok(!stages.includes("update_sqlite")); +}); + test("refreshing a deferred association cannot readmit a SQLite row excluded at the first Apply scan", async () => { const affected = twoFileGroup("thread-excluded"); const healthy = twoFileGroup("thread-healthy"); diff --git a/test/provider-skip-associations.test.js b/test/provider-skip-associations.test.js index 8a0d133..64b9d2c 100644 --- a/test/provider-skip-associations.test.js +++ b/test/provider-skip-associations.test.js @@ -102,6 +102,25 @@ test("unknown bad metadata restricts updates to positive healthy associations", assert.deepEqual(result.rows.map(r => r.id), ["good"]); } }); +for (const [layout, testHome] of [["DOS", home], ["UNC", "\\\\server\\share\\fixture"]]) { + test(`Windows ${layout} directory casing preserves anchors and foreign owner protection`, { skip: process.platform !== "win32" }, () => { + const files = ["sessions", "archived_sessions"].map(directory => ({ + path: path.join(testHome, directory, "rollout-fixture.jsonl"), id: "same", historyMode: "paginated" + })); + for (const file of files) for (const alias of [file.path.toUpperCase(), path.toNamespacedPath(file.path).toUpperCase()]) { + assert.equal(providerPathKey(file.path), providerPathKey(alias)); + const scan = { files, skippedItems: [] }; + const anchored = selectProviderRows(testHome, scan, { key: "id", rows: [row("same", alias)] }, "openai"); + assert.deepEqual(anchored.rows.map(item => item.id), ["same"]); + assert.equal(anchored.rows[0].rollout_path, alias); + const contested = selectProviderRows(testHome, scan, { key: "id", rows: [ + row("same", files[0].path), { ...row("other", alias), model_provider: "openai" }, row("healthy", null) + ] }, "openai"); + assert.deepEqual(contested.rows.map(item => item.id), ["healthy"]); + assert.deepEqual(contested.skippedItems.map(item => [item.id, item.reason]), [["same", "association-conflict"]]); + } + }); +} test("skip summaries deduplicate and bound detail bytes without exposing internal identifiers", () => { const items = Array.from({ length: 205 }, (_, n) => rolloutSkip(path.join(home, "sessions", `rollout-${n}.jsonl`), "changed", "revalidate", "unsafe/private-id")); const summary = summarizeSkips([...items, ...items]);