diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9e1d6c..d4ea651 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
本文件记录面向用户和集成方的重要变化。完整的发布叙事、升级说明和下载入口见对应版本的中文发布说明;实现证据和测试门禁见技术发布说明。
+## [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 普通路径与 `\\?\` 路径别名的关联比较。缺少归属证据、锁定或执行期间发生变化的文件仍跳过并保留关联索引,正常数据继续处理。
+- 感谢 [@Lorikein12138](https://github.com/Lorikein12138) 提供复现步骤与分页文件、Windows 长路径的根因分析。
+
## [1.0.3] - 2026-09-15
- 已下载更新由用户决定安装:存储配置、写操作、Watch 和恢复状态不再阻止“重启并安装”。
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/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/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/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/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..fbbb126
--- /dev/null
+++ b/docs/adr/0047-paginated-provider-associations.md
@@ -0,0 +1,33 @@
+# 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 边界检查;范围目录名与关联键均按 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 不创建备份。
+
+## 验证与文档边界
+
+新增 `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..8d1fba6 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,12 @@ Sync 不改根 config、历史 model、cwd、user-event、workspace roots、titl
→ SQLite 原生事务提交 → 结果 / 受管备份清理 → 释放 Home 锁
```
+分页组的 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。
不创建普通跨文件 journal,不自动全量回滚。SQLite 自身的 ROLLBACK 与单文件字节写入失败的局部复原不是全操作回滚。锁定/变化 rollout 分别报告,不能吞掉或误报 completed。全被跳过也可 partial 且无备份,不能因“没写”就报健康。
@@ -163,6 +171,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..33cd0a5 100644
--- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md
+++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md
@@ -4,8 +4,14 @@
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 始终限于原确认候选;纯观察不新增写事务或备份。
+计划后新发现的 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/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..9cb16ad 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,14 @@
`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-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/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/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": [
diff --git a/packages/core/src/application/provider-sync.js b/packages/core/src/application/provider-sync.js
index 8446a2b..e29b951 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,7 @@ export async function buildProviderWriteProgram(context, settings = {}) {
}
}
const sqliteRowsToWrite = selection.rows.length;
+ 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 +291,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 (sqliteRowsToWrite > 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 +310,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 +334,17 @@ 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.
+ // 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)));
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 +352,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..673cc19 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"];
@@ -236,12 +236,26 @@ 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 };
+ 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.
+ // 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 };
+ });
+ } catch (error) {
+ if (!fileReadSkipReason(error)) throw error;
+ }
+ }
} else {
try {
revision = mode === "provider" || mode === "status"
@@ -250,7 +264,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 +276,7 @@ export async function captureRolloutRevision(codexHome, { fsImpl = fs, mode = "c
revision = { skipped: reason };
}
}
+ 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;
@@ -272,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 });
- 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 {
@@ -299,6 +317,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 +331,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..5c6f066 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,9 +63,13 @@ 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);
- if (!["sessions", "archived_sessions"].includes(relative[0]) || relative.includes("..")) return null;
+ 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);
+ const scopeDirectory = process.platform === "win32" ? relative[0].toLowerCase() : relative[0];
+ if (!["sessions", "archived_sessions"].includes(scopeDirectory) || relative.includes("..")) return null;
return providerPathKey(absolute);
}
@@ -68,7 +84,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 +97,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,9 +120,14 @@ 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));
+ // 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) {
@@ -113,7 +137,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..0fa3d75
--- /dev/null
+++ b/test/provider-paginated-groups.test.js
@@ -0,0 +1,739 @@
+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");
+});
+
+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");
+ });
+}
+
+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");
+ 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({
+ 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..f6c4437 100644
--- a/test/provider-preparation-facts.test.js
+++ b/test/provider-preparation-facts.test.js
@@ -55,6 +55,97 @@ 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 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" });
+ 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");
+
+ 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 e8e9eb6..64b9d2c 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,81 @@ 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("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");
+ 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"]) {
@@ -27,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]);