Files
rond-with-you/.workbuddy/memory/2026-09-30.md
T
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

14 KiB
Raw Blame History

2026-09-30

生产时间线「部分重复部分没有」的根因与修复

用户报 https://life.shiroko.one/timeline 上仍有重复。抓页面逐条比对:一屏 180 条里有 53 对 到达时间、离开时间、停留时长完全相同的条目——与 09-04 那种「离开时间不同」的已并入行不同, ZMERGEDTO/ZPARENT 判据一条都收不到(43 对里隐藏侧 0)。

真正成因:站点补录的行没有留档 ⇒ 没有 UUID,导出时被补上 v5("ZVISIT/<src_pk>");这份包 再合并回站点时站点侧没有 UUID 可对齐,被当成「备份新增」又插一条(41/43 的副本 UUID 正好等于 配对行的派生 UUID,实测确认)。

修:rond 里把派生 UUID 抽出来当对账键(DerivedUUID/VisitMatchUUID),差异与合并都用它; 卡片新增第二类判定(成因「你补录的那条被合并回来的一份」),清理时把副本名下的位移/天气改挂 到保留项。提交 8dec973(+ 格式 aaf65f6),已推送;全量验收随后跑。

「本地测试要十几分钟」的根因:我昨天那个收口把页面拖成了 44~70 秒

  • 症状:最慢的 4 个用例占全套 55%(309s/274s/226s/115s),全是「渲染若干页面」的布局用例。
  • 定位:临时逐页计时探针(/ 43.6s、/places 70.1s)→ 变异对照(关掉判据 0.28s)→ CPU profile (135 秒只用 3 秒 CPU ⇒ 在等,不是算)。孤立的 EXPLAIN 只有 12~38ms,会骗人。
  • 根因:判据打在 entity_raw.raw(TOAST 过的整行 JSON)上,而 /places 是「每个地点 × 5 个子查询」。
  • 修:派生列 visits.is_merged + 刷新(ingest / recountPlaces)+ 部分索引;/places 70084ms → 206ms, 首页 43633ms → 698ms。新增 page_render_budget_test.go(7 个页面卡 10 秒的灾难探测器)。
  • 教训:别拿单条查询的耗时判断整页性能;「功能对、就是慢」的退化必须有守卫。

生产导出 data/20260930.rondbackup 被真机拒收 —— 逐项排查(未定位到形状违规)

按技能里「备份恢复失败」的排查顺序全跑了一遍,每一层都与手机包/曾被接受的包同形:

  1. 打包层(头号真凶):本地头 bit3=0、长度非 0、条目名与顺序与 09-20 被接受的那份一致 ✓
  2. 模型骨架:表 31 / 索引 47 / CREATE TABLE 逐字符 / Z_PLIST sha256 / Z_MODELCACHE 16073B / Z_UUID / Z_VERSION —— 与 09-29v2 手机包完全一致 ✓
  3. 逐列空值画像:唯一差异 ZLOCATION.ZISOCOUNTRYCODE 20 个 NULL。这不是问题——09-20 被接受过的包也是 23 个 NULL, 且 09-29 做过真机对照实验(同一份包唯一差异这列):'' 被拒、NULL 能导入(见 commit d5e96ed)⇒ NULL 是对的, 别再去「补空串美化」(我这次差点又去改它)。
  4. 关系完整性 0 悬空(含 ZRAWVISIT→ZVISIT、ZVISIT.ZLOCATION/ZACTIVITY_、自引用 ZMERGEDTO/ZPARENT)✓
  5. Z_OPT 全 ≥1、Z_ENT 与参照一致、ZIDENTIFIER_ 16B 且唯一、值类型分布无新增类别、无非法 UTF-8、 行级时间不变量 0 违例、SQLite 文件头(页大小/WAL 版本/编码)一致 ✓
  6. 计数画像同形(1001 到访 / 64 隐藏 / 436 地点 / 392 有到访 / 交通方式 6 条无重名)✓
  7. 包里仍有 43 组站内副本重复对(就是 dedup 卡片要清的),但不影响导入 ✓

下一步(等用户):要报错原文 + 两个对照导入把范围切两半—— storage/.verify/acceptance-clean.rondbackup(纯净往返)与 data/20260920-fixed-A-数据修复.rondbackup(曾成功)。 若指向「站点数据」,就用这份失败包做行级二分(按 UUID 拆「手机原有的行」/「站内新建的行」)。

拒收排查续:静态判据全过 ⇒ 转真机二分(工具已提交)

用户确认:报错就是「备份恢复失败,你的当前数据未被修改」(QQ 传文件,同一路径传的另一份能导入)⇒ 导出链路与 App 都没问题,问题在这份数据集的数据里。

静态排查全过(打包位标志 / Core Data 骨架逐字节 / 空值画像 / 关系完整性含自引用 / 类型分布 / Z_OPT·UUID·BLOB / 行级时间不变量 / 位移窗口不变量 / 取值域与数值范围 / SQLite 文件头)。 顺手排掉四个「看着像」的:

  • ZISOCOUNTRYCODE 20 个 NULL:09-20 被接受过的包也是 23 个 NULL,且 09-29 真机对照实验证明「空串被拒、NULL 能进」⇒ 不是它;
  • 站内新建行的 v5 UUID:手机包自己就有 172 个 v5;
  • 43 组精确重复到访:手机包自己就有 39 组 ⇒ 真机容忍;
  • 位移窗口越界:这份包 0 条(手机包自己倒有 2 条)。

新增 tools/bisect_export.py:以被拒包为底生成 V1(去掉站内新建的 22 条到访)、 V2(+3 个站内新建地点)、V3(手机里也有的行按手机原值重置),都已过审计(悬空 0 / Z_MAX 够 / 打包正常)。 判读:哪一版开始能进,问题就在上一版删/改掉的那一类里;三版都进不去 ⇒ 问题在「行的集合」(删过/多过的行)。

二分结果:V1 ✓ / V2 ✓ / V3 ✗ ⇒ 罪在「站内新建的那 22 条到访」(地点无辜)

  • V1(去掉 22 条)能导入、V2(再多去 3 个地点)也能导入、V3(全保留、但把手机里也有的行按手机原值重置)报错 ⇒ 问题在这 22 条到访本身或其附属记录里,与地点、与「共享行被改过的值」无关。
  • 这 22 条的构成:Z_PK=[46,200]+998..1017。其中 46/200 其实是基线(09-14 包)自己的行 (同一 UUID、同一数据),只是手机在 09-29 前把它们删掉了(09-29v2 里没有这两个 UUID)—— 所以「按 UUID 是否在手机包里」分类会把它们误判成站内新建。
  • 下一轮对照(都已过审计、位于 data/bisect/): V4 = 保留这 22 条但删掉它们的附属记录(15 位移 / 16 天气 / 2 采样); V5 = 删 [46,200,998..1007];V6 = 删 [1008..1017]。
  • 判读:V4 ✓ ⇒ 罪在附属记录;V5/V6 哪个 ✓ ⇒ 罪在另一半的那 10~12 条里,继续对半拆。

二分第二轮:V4 ✗ / V5 ✓ / V6 ✗ ⇒ 罪在 [46,200,9981007],再收紧到 [9981007]

  • V4(保留 22 条、只删它们的附属记录)报错 ⇒ 附属记录(15 位移 / 16 天气 / 2 采样)无罪;
  • V5(删 [46,200,9981007])能进、V6(删 [10081017])报错 ⇒ 罪在 V5 删掉的那 12 条里;
  • 46/200 被排除:它们就是基线自己的行(同 UUID 同数据),而纯净往返包里也有这两行且能导入 ✓ ⇒ 罪在 [998~1007] 这 10 条站内新建行本身(其余一切都已被 V5 证明是兼容的)。
  • 静态差分:这 10 条与无辜的 [1008~1017] 逐列完全同形(同为 v5 UUID、同标志位、ZRAW 都有值、TZ 都是 NULL) —— 看不出任何结构差异,只能继续对半。
  • 第三轮:V10 = 删 [9981002]、V11 = 删 [10031007](都在 data/bisect/)。

二分定案:罪魁是 ZVISIT #1000(09-22 06:16→10:48,挂在重复地点 416 上)

  • 五个「只留一条」对照:只有 P1000 报错,P998/P999/P1001/P1002 都能导入 ⇒ 单条罪魁 = #1000。
  • 顺带排除的:重复地点本身(手机包自己就有 8 组同名同坐标且都带访,坐标连低位都相同); UUID(#1000 的 UUID 就是标准的 v5("ZVISIT/1000"),与兄弟们同形);ZRAW 量级(2305 与手机的 2209 同量级); 重复组的 ZLASTARRIVALDATE_ 规律(手机各组也是各成员各值)。
  • #1000 与四个无辜兄弟的唯一差异 = 它的地点是 416,而 416 是「广西壮族自治区邮政公司」的 重复地点行(与 417 同名同坐标全字段相同,只有 ZLASTARRIVALDATE_ 不同),且只有 #1000 用它; V1/V2/V10 能导入,正是因为那时 #1000 已删、416 成了没有到访的空壳。
  • 判别包(都在 data/bisect/): W1 = 只留 #1000 但把它的地点换成 417;W2 = 只留 #1000 但把 416 的「最近到访」改成与 417 一致; W3 = 只留 #1000 但把 ZRAW 改成 1421。 W1 能进 ⇒ 罪在「416 这一行 / 这个对」;W1 报错而 W2/W3 能进 ⇒ 罪在 416 的 ZLASTARRIVALDATE_ 或采样数。

定案:真机拒收的根因 = ZVISIT.ZRAW 超过包内采样行数(已修,commit d99c63f)

  • 判别包结果:只有 W3 能进(W1 改地点、W2 改「最近到访」都报错)⇒ 罪魁是 ZVISIT #1000 的 ZRAW = 2305(「这次到访由多少个原始采样点归并而来」的计数)。
  • 不变量:ZRAW ≤ 包内 ZRAWVISIT 行数。接受过的三份包(09-14 基线 / 09-20 A 包 / 验收纯净往返) 最大值都 ≤ 采样行数;被拒的那份只有 #1000 越界(2305 > 2246)。夹到 2246 以内即可导入 (W3 用 1421 验证过;被接受的包里 ZRAW 为 NULL/0 的行本来就有 39~139 条 ⇒ 往下夹安全)。
  • 成因链:合并进来的到访带着源包的 ZRAW,而源包的 ZRAWVISIT 采样行站点不托管(只从基线带) ⇒ 计数与包内采样行数脱节。导出侧修法:写完行、清完悬空采样点之后,一条 UPDATE 把 ZRAW 夹到 count(ZRAWVISIT) 以内。用例 TestExportClampsRawSampleCount(变异验证过)。
  • 顺手:readDupBaseline 先 os.Stat(sql.Open 默认 READWRITE|CREATE,路径不存在会静默建空库, 报「no such table」而不是「没有基线」)。
  • 给用户的即时包:data/20260930-fixed-zraw.rondbackup(就是那份被拒的包,只把 #1000 的 ZRAW 2305→2246,其余一字未动,审计干净)。
  • 更彻底的做法(记着):把数据集的基线换成一份较新的手机包——采样行来自基线, 基线越新,新到访的采样行越齐,ZRAW 也就天然自洽。

基线替换:用户按链路操作,差异页显示「完全一致」(= 预期结果,不是失败)

用户把 App 导出的 20260930_180637.rondbackup 直接导入成新数据集,再用旧的站点导出 20260930.rondbackup 做差异合并 → 差异页说「与当前数据集完全一致:没有新增、没有被删、也没有冲突」。

原因:喂给 App 的那份包就是站点自己的导出(20260930-fixed-zraw.rondbackup = 站点导出只夹了 ZRAW), 所以 App 再导出来内容当然一样 ⇒ 换基线这一步已经达成(新数据集的基线 = 一份新鲜手机包 ✓), 清单里的「把旧站点导出合并进去」在他这条路径上是多余的(那一步是为「上传一份全新手机包、 里面没有站点数据」的路径准备的)。

顺带记:新数据集的收益主要是编号/存储对齐(App 导入时会重排 Z_PK,新数据集的 src_pk 与它的基线同一套); 采样行(ZRAWVISIT)不会因此变多——那些采样点从来不在我们的包里(真实缺失), 以后靠导出侧的 ZRAW 夹取兜住即可。

闭环完成:换基线成功并验证(2026-09-30 18:20)

用户流程:App 导出的 20260930_180637.rondbackup 直接导入成新数据集 → 网页端显示 937 条到访 (= 包内 1001 − 64 条隐藏行 ✓ 说明 App 那份把数据放在 -wal(3.16MB)里也被我们完整读入 ✓)→ 以它为基线在网页端导出 20260930_180637-export-20260930.rondbackup → 导入 App 正常 ✓✓。

我这边独立验证(新导出 vs 新基线):跨表计数 全部 ±0(1001 到访 / 436 地点 / 815 位移 / 589 天气 / 2246 采样 / 6 方式 / 32 活动 / 8 标签);表 31/31、索引 47/47、CREATE TABLE 逐字符、 Z_PLIST/Z_MODELCACHE sha256 全部一致;ZRAW 最大 2246 ≤ 采样行 2246 ✓;打包层 bit3=0 ✓。 (App 那份的 zip flags 是 0x0800——只有 UTF-8 位,数据在 -wal 里,与我们 0x0000 的写法不同但都被接受。)

技术结论已写进 rond-backup-parse 技能:ZRAW 不变量、逐行二分手法(tools/bisect_export.py)、 换基线六步 + 自检、以及排查顺序里新增的「先验纯净往返包」与「值域不变量」两步。

项目记忆纳入版本控制 + 注入上限取证(2026-09-30 18:30)

用户要求「记忆跟着项目走」,把 .workbuddy/memory/ 从 gitignore 里放出来。

  • .gitignore 挡路的是 .workbuddy/ 那行。改成「反忽略」六行:.workbuddy/ → !.workbuddy/ → .workbuddy/* → !.workbuddy/memory/ → .workbuddy/memory/* → !.workbuddy/memory/*.md。 ⚠️ 别用 !.workbuddy/memory/**——它把 memory 下的任何文件都放行。实测 ./web.test.exe 混进 暂存过,所以收紧成只认 .md。复核用 git check-ignore -q --no-index <path> 逐条打靶: memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED, 三个 .md 全 included,git add -An .workbuddy/ 恰好 16 个文件。
  • 记忆里没有真凭据(唯一命中是 2026-09-14.md 里的 password=rond-admin,那是项目默认口令)。
  • 注入上限取证(以前一直靠猜):app.asar 里 getWorkingMemoryContent() 用 var MAX_MEMORY_CHARS = 1e4,比较的是 JS content.length——字符数(UTF-16 单元),不是字节; 中文 1 字算 1。超了就 substring(0,1e4) 且尾部静默丢失,只在当轮挂一句「ACTION REQUIRED」。 ⇒ 以前按「15.6KB 字节」判断是否超限是错的(中文文本 1 万字符≈1.5 万字节)。 本 details 与每日日志都不参与注入,只有 MEMORY.md 一个文件受约束。
  • 索引现状:MEMORY.md 6604 字符 / 10526 字节,距上限留 34% 余量;仍有 12 节,34 节正文在 details。