fix(mobile): 补齐历史窗口空洞,跨空洞不再折成一条工作组 - #1210
Conversation
|
| Filename | Overview |
|---|---|
| apps/mobile/app/sessions/[sessionId].tsx | 接入窗口补齐流程,并以单调序号、按会话互斥和同步代门槛管理补齐生命周期;此前报告的跨会话阻断与取消撤销路径已关闭。 |
| apps/mobile/src/session/historyWindowGap.ts | 新增空洞检测、服务端邻接探测、分页补齐、取消检查和请求预算控制。 |
| apps/mobile/src/session/remoteSessionStore.ts | 使用显式覆盖区间及实时尾部可信状态维护最新窗口连续性。 |
| packages/maker-shared/src/messageRender.ts | 为共享消息分组加入历史空洞切分,并按已见动作的最大结束时间确定边界。 |
| packages/maker-shared/src/messageNormalize.ts | 通过精确 toolUseId 配对提供工具结果落库时间,避免长任务或乱序完成被误判为空洞。 |
| apps/mobile/src/device-link/DeviceLinkContext.tsx | 将实时订阅确认和断流事件传递给消息窗口连续性状态。 |
Sequence Diagram
sequenceDiagram
participant Screen as 手机会话页
participant Store as remoteSessionStore
participant Desktop as 桌面消息服务
Screen->>Desktop: 同步最新消息窗口
Desktop-->>Screen: 最新页 + moreBeyondWindow
Screen->>Store: 对账连续覆盖区间
Screen->>Screen: 检测最靠尾部的时间跳变
Screen->>Desktop: "limit=1 邻接探测"
alt 服务端本来相邻
Desktop-->>Screen: 较旧侧相邻行
Screen->>Screen: 记录 contiguous
else 确认存在空洞
loop 请求与行数预算内
Screen->>Desktop: 按 before 游标加载更早页
Desktop-->>Screen: 历史消息页
Screen->>Store: 合并补齐结果
end
end
Reviews (12): Last reviewed commit: "fix(mobile): 尾部可信改由订阅 ACK 决定,页比订阅先到不再算可信" | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1fbf6a6a4f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
本 PR 修复手机端长会话在“历史窗口不连续(有空洞)”时中间对话整段不可见、且跨空洞被折成一条「已工作 Xs」导致时长谎报的问题:将桌面既有的空洞阈值与切分逻辑对齐到共享层,并在手机端增加窗口空洞探测与按需补齐。
Changes:
- 新增
@cindy/maker-shared级别的HISTORY_GAP_SPLIT_MS单一来源,并在桌面侧通过 re-export 保持既有引用路径不变。 - 共享渲染层(
messageRender.ts)补齐“跨空洞切分”兜底:工作组与tool_group段内按阈值切开,并新增settledAt(工具结束锚点)以避免长工具/长 thinking 被误判为空洞。 - 手机端新增
historyWindowGap:对窗口尾部跳变先limit=1探测确认是否真空洞,再沿before游标回填补齐;同时补充覆盖该行为的回归测试。
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/maker-shared/src/messageRender.ts | 增加空洞阈值切分与 settledAt 结束锚点,避免跨空洞合并导致时长谎报 |
| packages/maker-shared/src/messageNormalize.ts | tool_use/tool_result 配对新增“结果 createdAt(结束时刻)”查询能力,供渲染层锚点使用 |
| packages/maker-shared/src/historyGap.ts | 新增两端共用的 HISTORY_GAP_SPLIT_MS 常量单一来源 |
| packages/maker-shared/src/tests/messageRenderHistoryGap.test.ts | 新增共享渲染层历史空洞切分的回归测试 |
| packages/maker-shared/package.json | 暴露 ./history-gap 子路径导出 |
| apps/mobile/src/session/messageNormalize.ts | mobile normalize 填充 settledAt(来自 tool_result createdAt) |
| apps/mobile/src/session/historyWindowGap.ts | 新增手机端窗口空洞探测与补齐实现 |
| apps/mobile/src/tests/messageRenderModel.test.ts | 增加手机端渲染链路在“孤岛窗口”形状下不跨空洞折叠的回归用例 |
| apps/mobile/src/tests/historyWindowGap.test.ts | 覆盖空洞检测与补齐的核心分支与预算/取消/失败结局 |
| apps/mobile/src/tests/historyWindowBackfillWiring.test.ts | 锁住 SessionScreen 接线层的关键前置条件(互斥、去重、取消判定等) |
| apps/mobile/app/sessions/[sessionId].tsx | 在会话屏幕中接入后台空洞补齐流程(探测 + 回填 + 取消条件) |
| apps/desktop/src/renderer/lib/historyGap.ts | 桌面侧常量改为从 shared re-export,保持桌面引用路径稳定 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20a4387fb0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe34cc5a06
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/mobile/app/sessions/[sessionId].tsx:3812
- 这里注释把
cancelled归到failed一类,但下面实际实现刻意对cancelled不落地(outcome !== 'cancelled'才写 backfilled,且cancelled不写 failed)。建议把这段注释口径改成与实现一致,避免后续维护者误以为 cancelled 会进入 failed 集合。
* - `failed`:请求异常(断线等)。跳过是为了防抖(messages 每变一次就重试会打成请求风暴),
* 但**绑在连接代上**:`connectionEpoch` 变化即清空,重连后同一处可以再试。不占额度。
* `cancelled` 也归这里 —— 会话切走 / 锚点行被 /clear、rewind 拿掉,都属于"这次没做成"。
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/maker-shared/src/messageRender.ts:1326
itemEndTimestamp对work_group目前是“从后往前找到第一个有时间戳的 child 就返回”。但 work_group 的 children 是按发起/出现顺序组织的,且你在workRunFallbackEnd的注释里也明确提到并行动作会乱序完成;因此这里应该取 所有 child 结束时刻的最大值,否则会把锚点回退(例如 A 先发起跑很久、B 后发起很快结束,最后一个 child 是 B),进而把后续本来连续的 item 误判成空洞并错误切段。
if (item.type === 'work_group') {
for (let index = item.children.length - 1; index >= 0; index--) {
const childEnd = itemEndTimestamp(item.children[index]);
if (childEnd !== null) return childEnd;
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6f4cf3687f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/maker-shared/src/messageRender.ts:1326
itemEndTimestamp对work_group的结束时刻目前是“从后往前找到第一个有时间戳的子项就返回”,这等价于取“最后一个子项的结束”。但work_group.children的排序是按动作发起时刻,不保证结束时刻单调(并行 Agent/Task、长 thinking + 短 tool 等场景会出现较早发起的子项结束更晚)。在这种情况下锚点会被低估,导致后续 item 被误判为跨空洞而被切段/切组。建议对work_group也按所有子项结束时刻取 max(与workRunFallbackEnd的口径保持一致)。
if (item.type === 'work_group') {
for (let index = item.children.length - 1; index >= 0; index--) {
const childEnd = itemEndTimestamp(item.children[index]);
if (childEnd !== null) return childEnd;
}
return null;
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 702f3d9536
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@dashhuang 👋 这个 PR 还有 1 条 review conversation 没 resolve(apps/mobile/src/session/historyWindowGap.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
上一个 commit 的收紧太粗:它只看"最新页是否满页",于是把用户一路「加载更早」翻出来的历史也当成 不可信段丢掉 —— 一次普通断线重连(最新快照恰好满 80 行)就会让正在看的历史与滚动锚点消失,而且 自动补齐不会拉回(裁完窗口里已经没有内部跳变可发现),只能手动重翻。#1210 review 指出。 窗口连续性因此分成两档,由 store 的 `sessionContiguousSince`(每会话「已验证连续」的下界)标出: - 建立下界的只有"服务端一次给出的连续段":整窗替换 `setMessages`、最新窗口 `setLatestMessageWindow`、 以及沿 before 从窗口最旧端连续翻页的「加载更早」—— 后者走新入口 `mergeEarlierMessages`。 - 冷开缓存 hydrate 刻意不建立(它与最新页的关系无从确认,正是 #1222 要丢的那类)。 - /clear、rewind、error-persisted 整窗失效、会话回收一律重置为未知,下次最新窗口同步重建。 - 判据本身以 hasOverlap 为前提:下界断言的是"到**窗口最新端**连续",本页与窗口完全不重叠时 (陈旧窗口)那个断言对并集不再成立,不能让旧结论赖着不走 —— 既有的 "replaces a stale cached window" / "keeps live-pushed tail" 两条测试正钉这一点。 - 空洞补齐的 `mergeMessages` 刻意不下移下界:补完虽然更连续,但要算准新下界得先确认窗口里没有 别的洞,而那正是补齐在解决的问题。保守不动的代价是补回的段可能在下次满页同步时被丢弃 (下次打开会重新补),方向上是安全的那一侧。 于是三种段各归其位:已验证连续的保留,来源不明的按 moreBeyondWindow 丢弃,比本页更新的 live push 行与本地系统卡照旧保留。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
apps/mobile/src/session/remoteSessionStore.ts:1332
setLatestMessageWindow在“更早段被丢弃时收敛下界”的判断里用的是next.some(item.createdAt < latestOldestCreatedAt)。但next会包含mobile-system-*本地系统卡,它们的createdAt可能早于本页最旧行,从而让这里误以为“仍有更早历史被保留”,导致sessionContiguousSince不被重置。这样旧的连续性下界可能继续背书后续窗口,进而在moreBeyondWindow=true时把来源不明的更早缓存段错误当成“已验证连续”而保留。建议这条判断排除mobile-system-*。
// 本页自身是连续段;若更早的段被丢弃,下界就收敛到本页最旧行。用前移语义合并:既有下界更早
// 且那些行仍在窗口里(已验证连续)时保持不动。
if (!next.some((item) => item.createdAt.localeCompare(latestOldestCreatedAt) < 0)) {
sessionContiguousSince.delete(sessionId);
}
lowerContiguousSince(sessionId, latestOldestCreatedAt);
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acee484bae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
第七轮 review 的两条,并把这两个不变量的消费点穷举补齐,不等下一轮再被指出。 1. 手动翻页与自动补齐仍会短暂并发(codex P2)。上一轮把 loadingEarlier 加进作废 effect 的依赖, 但 effect 是**被动**的:loadEarlierMessages 在 setLoadingEarlier(true) 之后同步就发请求,若自动 补齐在 effect 执行前返回,它照样通过 isCancelled、merge 并继续下一页。作废因此抽成 abandonInFlightBackfill(),手动入口在发请求**之前**同步调用;被动 effect 只留给切会话与 连接代变化(重连 —— 在飞请求走的是旧连接,早收手比等超时干净,这条是穷举时补的第三个入口)。 2. 嵌套工作组的结束时刻取的是"最后一个 child",不是全体最大值(copilot)。children 按发起时刻 排列,并行动作乱序完成时真正的结束可能落在更靠前的 child 上。shared 的 itemEndTimestamp 与 桌面同款函数 renderItemEndMs 都改成全量取 max,与两处已有的 Math.max 锚点(groupWorkRuns 的 prevEndMs、workRunFallbackEnd)口径一致。 如实说明:这条在**当前调用图下不可达** —— 进入 end 计算的 run/items 都来自 buildLinearItems 的 线性输出,不含 work_group(内层组只出现在外组的 children 字段,不参与外组时长)。所以改的是口径 一致性与防御性,写不出能区分新旧实现的测试,也就没有为它硬造用例(试写过一条,新旧实现都通过, 已删掉——留着会假装钉住了什么)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/mobile/src/session/remoteSessionStore.ts:1331
- 这里用
next.some(item.createdAt < latestOldestCreatedAt)来判断“更早的段是否仍在窗口里”并据此决定是否delete sessionContiguousSince,会被本地系统卡(mobile-system-*)干扰:系统卡会被刻意保留且 createdAt 可能远早于 latest page,导致条件恒为 true,从而无法在丢弃更早服务端缓存段时把 contiguousSince 收敛到本页最旧行。这样会让sessionContiguousSince继续背书一个实际上已不在窗口里的“已验证连续”下界,后续最新页同步可能错误地把更早的缓存段当成 verified contiguous 而保留。建议此处只检查非本地系统卡/真实服务端行。
// 本页自身是连续段;若更早的段被丢弃,下界就收敛到本页最旧行。用前移语义合并:既有下界更早
// 且那些行仍在窗口里(已验证连续)时保持不动。
if (!next.some((item) => item.createdAt.localeCompare(latestOldestCreatedAt) < 0)) {
sessionContiguousSince.delete(sessionId);
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7854016706
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`sessionContiguousSince` 只记「已验证连续」的下界,而下界断言的是「从它到**窗口 最新端**连续」——窗口最新端会被断流期间漏收的行悄悄作废:旧窗 1–80,断线漏收 81–200,重连后先到一条 push 201,再收到最新页 122–201,此时「有交集」仅靠那条 push 成立,旧下界却还在给「1–80 + 122–201」这个孤岛背书,而这些行若在 30 分钟内 产生,自动探测也发现不了。 改成显式的覆盖区间 `[since, until]` + `liveTailTrusted` 位: - 采纳旧段要求「旧行落在区间内」**且**「本页最旧行不晚于 `until`」(两段首尾相接); - `liveTailTrusted` 表示自 `until` 建立以来推送链路没断过,只有这时 push 才能后推 上界。断流的三个入口都通知 store:socket 掉线(全部会话)、退后台释放 `session:<id>` 订阅、离开会话取消订阅(对应会话)。区间本身保留——断流不会让 断流前验证过的那段变假; - 事实自检:最新页若在区间内带来窗口没有的行,说明断言已被服务端事实推翻,当次 按「未知」处置; - 记账移到相等早退**之前**(`setMessages` 与 `setLatestMessageWindow` 口径一致): 权威页与冷开缓存逐行相同是常态,被早退跳过则这次响应白来,之后会话涨过一页、 再遇一次满页重连刷新,本已确认过的行会被当成来源不明全丢,用户当前历史与滚动 位置一起消失。 顺带把「这一行在不在那一批里」收敛成一份身份索引,交集探测与覆盖区间的事实自检 共用它,不再各写一遍迁移档判据。 新增 7 个 store 用例 + 3 个断流接线断言,全部经变异验证能区分新旧实现;`coverLiveRow` 里跳过本地系统卡那一档写不出能区分的用例(当前调用图下要叠两个改坏点才暴露), 按防御性保留、不硬造用例。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/mobile/src/session/remoteSessionStore.ts:1571
remoteSessionStore里既有一个模块级 helpernoteLiveStreamInterrupted(...),又新增了同名的 store 方法noteLiveStreamInterrupted(...),方法体里再调用noteLiveStreamInterrupted(sessionId)依赖“method 名在函数体内不成 binding”这一语义才能命中外层 helper。这里对读者非常不友好,也很容易在后续重构(例如把 helper 移到别处、或把方法改成函数表达式)时不小心变成递归/死循环。建议把外层 helper 或 store 方法改名(例如 helper 改成markLiveStreamInterrupted/setLiveTailUntrusted),并同步更新此处调用点,避免同名遮蔽带来的维护风险。
noteLiveStreamInterrupted(sessionId?: string): void {
noteLiveStreamInterrupted(sessionId);
},
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e1e219c30
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@dashhuang 👋 这个 PR 还有 1 条 review conversation 没 resolve(apps/mobile/src/session/remoteSessionStore.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
屏幕侧的 `openAndSubscribe` 与 `startFocusedTopicSubscription` 都是 `void subscribe(...)`(订阅只管之后的推送,不该挡数据读),所以「权威页比订阅 ACK 先到」是常态。上一版把每个权威页都标成 `liveTailTrusted: true`,于是这个空窗 里被控端写下的行——既不在这一页、也不会被推过来——会被之后一条 push 抬过去: `until` 越过漏收的行,等尾部涨过一页后最新页已不含它们,`joinableWindowCoverage` 的事实自检也发现不了,孤岛就此固化,而它们若在 30 分钟内产生,时间空洞补齐同样 不触发。 改成: - `liveTailTrusted` 由**权威页落库那一刻订阅是否已 ACK** 决定。device-link 在订阅 ACK 记账处通知 store,且只按 `markHeldRemoteTopicsSubscribed` 真正记进 ACK 表的 topic 生效(中途被释放的不算)。重连补齐路径(`rehydrate`)是 `await subscribe` 之后才拉页,因此那条路径拿到的仍是可信尾部。 - ACK 本身不点亮既有区间:ACK 之前的空窗里可能已经漏了行,只有此后落库的权威页 才能重新确定尾部。 - 断流两种形态都把 ACK 记录一并作废(socket 掉线 → 全部会话;退后台释放 `session:<id>` 订阅、离开会话取消订阅 → 对应会话)。生效与失效不成对时,断线后 才落库的在途页会靠旧 ACK 重新把尾部标成可信,绕一圈回到同一个孤岛。 新增 4 个用例(ACK 之前落库的页不可信、两种断流形态各一例 ACK 失效、ACK 接线断言), 并给原有 4 个依赖 push 续推的用例补上「订阅已 ACK」前置;全部经变异验证能区分新旧 实现。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f94d36e36f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@dashhuang 👋 这个 PR 还有 1 条 review conversation 没 resolve(apps/mobile/src/session/remoteSessionStore.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
MagicLizi
left a comment
There was a problem hiding this comment.
Auto-review: standard-tier 审查通过。18 文件 / 2274 行。
- 修复 Mobile 历史窗口空洞导致不相关消息段被折成一个工作组(显示错误时长)
- 共享 30min 阈值拆分 + coverage 追踪 + 自动回填(预算限制 6 probe / 3 fill / 400 rows)
- Desktop 同步修复:renderItemEndMs 取所有子项最大值(并行任务可能乱序完成)
- 无冷更触发、无安全/凭证变更,测试 700+ 行
零 P0/P1。
MagicLizi
left a comment
There was a problem hiding this comment.
Code review passed — all invariants verified, gap detection logic is correct, tests comprehensive. No P0/P1 findings.
|
历史窗口空洞的修复做得很扎实——用单调 seq 代替易摆动的 session ID 做取消判据是个巧妙的设计,六条不变量的接线守卫让后续重构也很难无意打破这些约束。 |
这次改了什么
摘要
手机端打开长会话时,中间的对话会整段消失,只剩首条消息、一条「已工作 142m 32s」和最后一条回复。
原因是两层:
listMessages的最新页和local-db:messages:createdpush 的尾部拼成,几者之间没有连续性保证 —— 断连期间漏收的 push 不会补回,而setLatestMessageWindow只要求缓存旧页与最新页有交集就整段保留。于是窗口成了「首段 + 尾段」的孤岛,中间几百行从未加载。HISTORY_GAP_SPLIT_MS(30 分钟)双层守卫(groupWorkRuns+tool_segment都按它切),手机端走的那份共享分组messageRender.ts一直没有。user 行是唯一的 turn 边界,窗口里缺了它,跨空洞的动作就被折进同一个「已工作 Xs」,时长也从某段真实工作谎报成整场跨度。实测现场:一个 445 行的会话(8 条 user 行、跨 2 小时 22 分),手机上只渲染出 3 个 item,中间 6 轮对话("给我总结下我们解决了什么问题?"、"怎么改的跟我说一下"、"好的,提交pr" 等)连同各自的回答全部不可见。
本 PR 三层一起修:
packages/maker-shared/src/historyGap.ts作两端共用正本,desktop 侧lib/historyGap.ts改为 re-export(桌面既有引用与文档指向不变)。messageRender.ts的工作组分组与tool_group段内双层按阈值切开,对齐桌面MessageStream。锚点取结束时刻并取已见最大值 —— 为此新增settledAt(配对tool_result的落库时刻,由 shared pairing 新增的resultCreatedAtFor提供、mobile normalize 填充),所以「一次跑 40 分钟的工具」「想了 40 分钟的 thinking」「并行工具乱序完成」都不会被误判成空洞、把连续工作切碎。setLatestMessageWindow原本只要求缓存旧页与最新页有交集就整段保留 —— 交集不等于连续,两段之间仍可能隔着服务端有、本地从未加载的行。现在调用方把结构信号moreBeyondWindow(本页满页或被 device-link 裁行 = 上沿之外还有服务端历史)传进 store,为真时早于本页的缓存段一律丢弃,窗口于是始终是「某点 → 最新」的连续区间。这条不依赖时间阈值,所以覆盖了「断连期间漏收几十上百条、但它们在 30 分钟内快速产生」那类检测不到的孤岛(一个长 turn 里的连续工具调用就是)。判据与「加载更早」入口同源(shouldKeepOlderMessagesAffordance),本就该一致。唯一的例外是已验证连续的那种旧段(整窗替换的那页、用户一路「加载更早」翻出来的、订阅未断时收到的 push):它由一段显式的覆盖区间[since, until]标出,与本页首尾相接时保留,否则用户正在看的历史和滚动锚点会在每次满页重连时凭空消失(详见下方不变量 8)。apps/mobile/src/session/historyWindowGap.ts。找窗口里最靠尾部的跳变后,先花一次limit=1探测确认服务端两行是否本来就相邻 —— 时间跳变不等于空洞,"用户隔夜回来继续聊"同样会留下几小时的间隔,拿时间阈值直接触发翻页会让这类会话每次打开都白翻几页。确认有洞才沿既有before游标向上翻页补齐,判定只看本页真正取回的行(拿合并后的窗口判定,随便一页都会让判定成立,空洞永远补不回来)。变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
settledAt结束时刻锚点)+ store 层的窗口连续性保证(从源头消除孤岛,Closes bug: 手机端 30 分钟内产生的历史窗口空洞不会被检测到 #1222) + 手机端窗口空洞探测与补齐 + 回归测试local-db:messages:list的分页语义(连续性完全由客户端的结构信号判断)/clear/rewind 删掉的行,重连后若早于最新页最旧行会继续留在窗口里。实测origin/main同样复现,是既有缺口而非本 PR 回归;修它要引入方向相反的新信号(_count总数对账),误判方向是清空用户正在看的历史,需要单独设计与验证after方向游标状态模型与不变量(补齐流程)
三轮 review 全部集中在补齐的状态形状上,所以把不变量写在这里当锚点。每条在代码里只允许有一个判据,所有对称路径复用它;
historyWindowBackfillWiring.test.ts逐条对着断言。状态:
backfillGapStateRef({ sid, epoch, contiguous, backfilled, failed })、backfillInFlightRun({ sid, seq } | null,可观察 state)、backfillRunSeqRef/backfillLatestRunSeqRef(单调序号)。事件:窗口对账完成、
messages变化、切会话、换连接代、补齐 settle(6 态)。不变量:
一轮补齐的身份是单调的。每次启动分配只增不减的
runSeq;「是否已被取代」「飞行标记的清除」「结论的写入」全部对着 seq 比。凡是「当前状态是否仍等于启动时状态」的判据都不可靠——会话 id 会摆回来(A 在飞 → 切到 B → 快速切回 A),那种判据会把取消撤销掉,于是同一会话并发翻页、旧轮收尾还误清新轮的标记。切会话时占掉一个序号(不启动新轮)即作废在飞的那一轮,作废是终态。同一会话同一时刻最多一轮在飞。互斥按
inFlightRun.sid === sessionId;别的会话残留的那一轮不连坐当前会话,它自己会在下一次isCancelled上收手。同步门槛按 session + 连接代判定。屏实例会被原地复用,屏幕级
lastSyncedAt在切会话后仍是上一个会话的非空值;复用仓内既有的readAckSyncedKey。每个 settle 结局有独立的遗忘条件与预算归属:
contiguous(服务端本来相邻,是事实)covered/budget/exhausted(真翻过页)failed(请求异常)connectionEpoch变化即清空cancelled两道预算闸:考察总次数
HISTORY_GAP_MAX_CONSIDERED_PER_VISIT(防跨数百天的会话里几十上百处正常停顿打出上百次limit=1探测)、翻页段数HISTORY_BACKFILL_MAX_GAPS_PER_VISIT(防一路翻整场历史)。前者统计三类之和,后者只统计真花了翻页请求的那些。窗口连续性是一段显式的「已验证覆盖区间」,不是一个下界。
[since, until]断言「这个闭区间内的所有服务端行都在窗口里」,另带一个liveTailTrusted位表示「自until建立以来实时推送链路没断过」。before从最旧端连续翻页 → 下界前移)、订阅内到达的实时 push(上界后推)。冷开缓存 hydrate 与空洞补齐mergeMessages刻意不登记liveTailTrusted由权威页落库那一刻订阅是否已 ACK 决定。屏幕侧的openAndSubscribe与startFocusedTopicSubscription都是void subscribe(...)(订阅只管之后的推送,不该挡数据读),所以「页比订阅先到」是常态;这个空窗里被控端写下的行既不在这一页、也不会被推来,之后一条 push 就会把until抬过它们,等尾部涨过一页后最新页已不含那几行、自检也发现不了。反过来重连补齐(rehydrate)是await subscribe之后才拉页,尾部可信。ACK 本身不点亮既有区间——ACK 之前的空窗里可能已经漏了行,只有此后落库的权威页能重新确定尾部/clear、rewind、会话回收、服务端行被清空 → 删除整个区间;断流清liveTailTrusted与 ACK 记录(socket 掉线 → 全部会话;退后台释放session:<id>订阅、离开会话取消订阅 → 对应会话),区间本身保留——断流不会让断流前验证过的那段变假。生效与失效必须成对:ACK 记录若不随断流作废,断线后才落库的在途页会重新把尾部标成可信,绕一圈回到同一个孤岛until」(两段首尾相接)。只记下界不够:下界断言的是「到窗口最新端连续」,而窗口最新端会被断流期间漏收的行悄悄作废——旧窗 1–80、漏收 81–200、重连后先到一条 push 201、最新页 122–201,此时「有交集」仅靠那条 push 成立,旧下界却还在给孤岛背书setMessages与setLatestMessageWindow两条早退路径口径一致保留判据与区间记账共用同一个「是否相接」的判断结果:两处分开算,早晚会出现「保留了旧段却不声明覆盖它」(下次同步照丢)或「声明了覆盖却已经丢掉」(凭空背书出一个孤岛)。
身份不确定的信号不进判据。同毫秒组内的行 id、按邻接位置猜出来的
tool_result归属,都不足以确定身份:空洞的身份用两侧时刻对(historyWindowGapKey),连上/相邻判定按时刻比而不是 id 精确匹配,工具的结束时刻(settledAt)只认toolUseId精确配对。内容可以猜(猜错最坏是显示串了,live 中按 id 到达即自愈),进入判据的时刻不能猜——猜错会让空洞守卫在最需要它的场景失效。补齐永不写用户可见的加载态或错误。它是静默自愈,失败由渲染层的
HISTORY_GAP_SPLIT_MS守卫兜底(不谎报时长),用户仍可用「加载更早」自己往上翻。UI 变化
不涉及:没有新增或修改任何组件、布局、样式、动效或文案。唯一的视觉差异是消息流里的「已工作 Xs」折叠条在窗口不连续时会切成多条(既有组件、既有样式,条目数与时长由数据决定),与桌面现有行为一致。
apps/mobile/app/、apps/mobile/src/session/、apps/desktop/src/renderer/)但确无视觉/交互/文案变化 —— 全部是消息流分组逻辑、窗口分页与共享常量,没有新增或修改任何组件、布局、样式、动效与 UI 文案,也没有新增颜色或 token。唯一的观感差异是既有的「已工作 Xs」折叠条在窗口不连续时会切成多条(既有组件、既有样式,条数与时长由数据决定),与桌面MessageStream的现行行为一致。怎么验证的
自动验证
新增回归测试 48 个:
packages/maker-shared/src/__tests__/messageRenderHistoryGap.test.ts(8):跨空洞切成两组且时长不横跨、空洞落在两次工具调用之间时tool_group也切开、40 分钟工具/40 分钟 thinking 不算空洞、空洞前那段按动作结束时刻结算(不低报)、无 nextItem 时取全体子项结束时刻的最大值(并行乱序完成)、缺settledAt时退回发起时刻、窗口连续时分组不变。apps/mobile/src/__tests__/historyWindowGap.test.ts(20):跳变检测(取最靠尾部一处、严格大于阈值、跳过mobile-system-*合成卡、时间不可解析的行不参与)与补齐结局(contiguous/covered/exhausted/ 游标不前进 / 请求数预算 / 行数预算 /cancelled/failed)。apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts(11):屏幕侧前置条件不许被删(不变量 1–6 逐条断言:单调 seq 与三个作废入口、同会话互斥、按 session + 连接代的同步门槛、六条 settle 结局各自的遗忘条件与预算归属、两道预算闸、补齐不写error/loadingEarlier),外加实时流生效/中断通知的四个入口(订阅被远端 ACK 时按真正记进 ACK 表的 topic 生效;socket 掉线 → 全部会话;退后台释放session:<id>订阅、离开会话取消订阅 → 对应会话)。apps/mobile/src/__tests__/remoteSessionStore.test.ts(+14):窗口连续性覆盖区间——moreBeyondWindow为真时丢弃来源不明的缓存段、「加载更早」翻出来的历史在满页重连时保留、rewind 后结论失效、丢弃旧段时仍保留更新的 live push 与本地系统卡、断流后先到的 push 不再让旧段被背书、断流后到达的 push 不推进上界(本页被裁到只剩它时信任位是唯一守卫)、断流按会话隔离、订阅未断时 push 推进上界后满页刷新仍保留更早历史、两条相等早退路径都登记连续性、最新页在区间内带来窗口没有的行时旧结论作废、订阅 ACK 之前落库的权威页尾部不算可信、断流时 ACK 记录一并作废(socket 掉线与按会话释放两种形态各一例)。packages/maker-shared/src/__tests__/messageNormalize.test.ts(+2):settledAt只认toolUseId精确配对,邻接猜出来的时刻不进判据。apps/mobile/src/__tests__/messageRenderModel.test.ts(+1):手机端整链路上的孤岛形状回归。用真实现场数据(本机 445 行会话)跑过修复前后对比:
work_group durationMs=8552s(= 142m 32s)手工验证
在 iPhone 17 Pro 模拟器(iOS 26.5、cn 版 dev client
com.xd.cindycnv0.1.0 build 2026072301)上跑本分支:pnpm mobile:sim:whoami -- --region=cn为✓ PASS,Metro:8081归属本 worktree,源码指纹dash/mobile-history-gap@cd31ce92a+3775274060。启动到登录页与主流程正常,无红屏、无回归。未执行的验证
风险
风险分类
影响与回滚
local-db:messages:list的before游标(与「加载更早」同一通道,已在 device-link allowlist 内),没有新增 IPC channel、push 事件或 wire protocol 字段。app.json、app.config.js、apps/mobile/package.json、eas.jsonproduction 段、plugins/、modules/、fingerprint.config.cjs,不触发冷更。limit=1探测(真安静的会话到此为止),确认有洞才翻页,上限 400 行 / 12 请求 / 每次打开 3 处。补齐失败静默,由渲染守卫兜底并保留「加载更早」入口。/clear、rewind 或整窗替换之后不会把刚被移除的历史(含clearedAt之前的消息)merge 回窗口。远程 / 手机适配(
docs/dev-rules/remote-and-mobile-adaptation.md)maker.listMessages(origin-aware 路由,远程会话自动经listMessagesFor),不碰 workdir 文件与 agent 进程,无本机fs读取。local-db:messages:list。backfillHistoryUntil+HISTORY_GAP_SPLIT_MS)。提交前检查
git commit -s,见 DCO)historyGap.ts/historyWindowGap.ts的文件头注释,与既有规则文档指向一致)🤖 Generated with Claude Code