Skip to content

mmc-trajectory: 退役记录索引、落下来鲜度层、总览条按像素列聚合,并从段格式里去掉两个只写不读的字段 - #21

Merged
MyPrototypeWhat merged 36 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory-d47
Oct 16, 2026
Merged

MyPrototypeWhat merged 36 commits into
MiniMax-AI:mainfrom
avatasia:add-mmc-trajectory-d47

Conversation

@avatasia

@avatasia avatasia commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

把记录索引退役、新鲜度层落下来、总览条按像素列聚合,从段格式里去掉两个只写不读的字段,并修掉评审指出的四处。

tests/ 随包分发:.minimax-plugin/plugin.json 没有 files/exclude,整个目录会装到每个读者的机器上。所以下面每个守卫名都是这个包的一部分。

一、退役记录索引(1ef5f71)

磁盘上的索引目录是死的产物,而每次发布都要重算一整份记录投影来喂它。

  • miniapp/node/trajectory-index.mjs 整个删除
  • 投影搬进 search-segments.mjs 并收窄到只产段行真正需要的字段;段行从此只有一个来源
  • 「无定位得到 -1,且不得误归文件 0」这条语义原样保留

二、新鲜度层(4096b5e)

横幅写着「正在重试」,而它能被收掉的唯一条件是「服务端回了一个带 body」——304 不算。cacheKey 只由 dir|sizeBytes|lastActiveAt 决定,会话一安静,之后每次轮询都是 304。于是一次瞬时抖动 + 一个安静的会话 = 横幅永久停留,并且一直宣称自己在重试。

  • 两个散落的布尔换成单一 freshness 模型,唯一写者 noteFresh()
  • 304 也算新鲜——轨迹与会话列表两条路都改了
  • 认不出的失败拆成「与本地服务的连接中断」与「服务端返回了无法读取的内容」两类
  • 连续失败从第二次起显示次数与来源

三、总览条按像素列聚合(55e96f2)

改前最坏会话(36,525 条)下这条带子建 36,525 个 .ov-span,每个至少 2px 宽——约 36,000 个互相压在一起,872px 的带子上每格读者看到的是「最后画上去的那条」。改后节点数在 20,000 条就饱和(872px × 3 车道 = 2,616),与记录数无关。

颜色、透明度、车道、data-kind 全部仍来自同一条 CSS 规则——这次只决定有多少个节点和它站在哪,调色板没有第二个来源。条带没被排版过时退回「每记录一列」,也就是改动前的行为。

四、段格式退役 byteStart / byteEnd(155227c)

这两个字段写进每一行段记录的 JSON 前缀,shipped 搜索路径里没有任何一处把它们读回来。SEGMENT_SCHEMA_VERSION 2 → 3,已落盘的段全部失效一次,每个会话首次搜索重建一次。

两条「它没了」的断言都写成能被真的打红的形状:输入里带着这两个字段,输出里没有。只写「输出里没有」的话,退役之前它也会绿。退役有两扇门(投影、buildSegment),各配一条变异。

五、悬停和点击各自要的东西不一样(90d3fb3、ff6e1fb)

聚合之后只有占到过某一列的记录在 DOM 里有节点,而命中测试当时读 DOM 上的 data-record-id——输掉所有列的记录既点不到也悬停不到。改为:车道从 DOM 读,记录从完整模型取,答案是离指针最近的那条记录。

这条不变量原本由一条读源码文本的断言守着。改为在沙箱里跑 spanAt 并断言返回的就是模型里那一个对象。

评审随后指出两个距离的减法写反了:span.end < time 时算的是 span.end - time,span.start > time 时算的是 time - span.start,两个距离都是负数。bestDistance 从 Infinity 起,于是第一个候选照旧入选,之后越负越赢——选中的是离指针最远的那条记录。悬停提示的时间随位置反向变化,点击也落到错误的记录上。

改为两个距离都是「大时间减小时间」,于是 if (d < bestDistance) 留下的是最近的那条。

原来这段算术一处断言都没有:client-check.mjs 只用正则读它的源码,而两处减法都是 span.start/span.end 对 time,读源码看不出方向。现在补 7 条行为断言,期望值由 span 坐标手算、不从函数回读:指针落在空隙里取两侧较近的一条、较近的一侧换到另一边、同一侧有两条候选时仍取较近、无行号时跨行比较、越过整个会话取结束最晚的那条、早于整个会话取第一条、指针所在行是过滤而不是偏好。

六条断言都能失败:把任一减法单独翻回去,对应断言分别红 4 条与 6 条。两条变异各瞄一行,因为两行各自承重。

六、其余改动

e5d4e23 总览条的模型不再每次重建:flat 用数组身份而非行数入键,第二个轴 state.actualDuration 也入键。守卫数的是重建次数,不是「字段存不存在」
fce491d 时间范围守卫不再向跑着的服务要会话,自建 400 轮跨午夜的合成会话交给服务端自己的 buildTrajectoryPayload
9604ba1 会话列表认得出「这是子会话」:store 里一直有 parent_session_id,投影、映射、stripInternalFields 四段各一条断言。只标子会话、不写父会话标题——子会话一定比父会话新,列表按最近取前 30,父通常已掉出窗口
fb69e95 两条空转的比较:/api/sessions 的 ETag 材料不含 parent 而 body 读了它;compactions 在 fixture 里是零,于是把页面上的 += 1 删掉仍然 0 === 0 全绿
8096e07 一个字段没人变异,就是一个页面可以从不数它:fixture 改成 lineage 之上的 generation view,先断言 messagesAll 与 messages 不相等,再断言别的;20 个被比较字段逐个一条页面侧变异
b3a75f3 守卫自己的文件进入哈希清单:成员规则是「被改写的文件」
e2c514c /api/sessions 的 ETag 材料原先用 NUL 拼接,而 SQLite 的 TEXT 装得下 NUL,于是两个可分辨的行共用一个 ETag。改成构造上单射的 JSON.stringify([...])。守卫里那条形状检查(「分隔符是标题装不下的字符」)换成两条身份断言:把歧义字符放进值里面,两个可分辨的元组必须给出不同材料
155755e、ff6e1fb 残渣检查:自测不再读它并不拥有的基线文件;守卫改为把包复制到临时目录在副本上跑,--print-startup-leftover 报告启动比较真正得出的结果,诊断同时报出它走了哪个分支

七、本轮:评审指出的四处

一、总览条的距离减法写反(见上)。

二、全新克隆首跑会红。 残渣守卫把包复制到临时目录再跑,复制时用过滤器排掉基线文件。那条守过滤器的变异原先瞄在守卫里那一个调用点上,而被复制的源就是守卫所在的那个包——tests/.shipped-snapshot.json 被 gitignore 排除,全新检出里没有它,于是过滤器排不排,副本都是一样的。实测:同一条变异在干净检出上 46/47,在跑过一次套件、文件被留下的开发机上 47/47。

过滤器因此改成接收一个 root 的工厂,守卫自己 mkdtemp 一份带基线的源再复制它,于是「副本里没有基线」成了过滤器的事实,而不是磁盘的巧合。变异随之改瞄工厂本体。改动后无基线环境下 47/47。

三、包根目录多了一个 plugin.json。 它与 package.json 内容完全相同,而 package.json 承载的就是 mcode.miniApp 入口指针。仓库里另外七个插件都是 .minimax-plugin/plugin.json 加 package.json 两个清单文件,只有这个包多一个。删除。

四、版本号没跟着升。 CONTRIBUTING 要求更新已有应用时同步 version。.minimax-plugin/plugin.json 与两份 README 一起 1.1.0 → 1.2.0。守卫断言的是与 plugin.json 的相等而不是「不是 1.0.0」,所以三处必须一起改;四条相关的变异跟着重锚——留在原地的话三条会失锚、第四条会变成一次什么都没改的编辑。

八、一次只问「这个 commit 改了什么」的验收,十五轮都没有撞到产品行为

前面十五轮验收每一轮都发现真缺陷,但全部是同一类:一条检查不能失败。没有一条是「软件跑起来算错了」。这不是那十五轮的巧合,是题目给的形状决定的。

跑套件发现不了没被执行到的代码里的缺陷 —— 它只能确认「洞不在走过的路上」。所以这一轮换了题目:

  1. 承诺 → 守卫的映射表,覆盖整个包,而不是这个 commit。输出「读者能观察到的行为 / 谁守住它 / 没有」,每一行「没有」都是一条发现;只有形状、没有行为的同样算
  2. 对产品本身做一次行为实验,不是对测试
  3. 自选攻击线(保留)

结论是 not acceptable,一次撞出两条,都不在旧题目射程里。

一、搜索失败后自动重试成功,状态文案还停在失败上

一次搜索失败会把 state.searchError 设上,而清它的只有两处:键盘输入,和清除按钮 —— 两处都是读者发起的。轮询在转录变化后自动重跑当前查询,那不是读者,于是它没有任何路径能清掉那个字段。

于是:搜索失败 → 之后某次轮询自动重试并成功 → 命中集与条数都更新了,而文案仍然是「搜索失败,以下为上次结果」。因为 searchStatusLabel 先读 error,所以那个过期的 error 压过了它正在描述的那份新命中集。页面在正确的行上面写着错误的句子。

改法是成功分支里清一次。之所以十五轮没人看见,是因为现有检查对这个形状是同一个盲区:searchStatusLabel 的每个用例都是为那一个用例现造一个 state,而 state 不会过期;runSearch 那几条是 fnSource 的源码正则。

现在这个转换是被跑出来的:守卫从 index.html 里切出 runSearch、searchHitSet、searchStatusLabel、formatInt、sessionParam 的真源码求值,先要一次失败的 fetch 再要一次成功的,然后读页面自己的 state。期望值不是为测试写出来的,只有服务端返回的内容是。撤销修法时它按名失败,另外 mutate-search-client.py 加一条变异,13/13 按名捕获。

二、/api/runtime 把栈原文发了出去

映射表里唯一返回「没有」的一行。问下去才发现这不是覆盖缺口,是一条已经破了的承诺。

这条路由全部的契约就是「不发送什么」:注释里写着「Names and structure only」,以及「this route must never hand the browser a filesystem location」。而响应里有三个字段不是 basename、不是计数、不是布尔 —— 它们是异常文本的子串:

sqlite.error       导入 node:sqlite 失败时的 message
sqlite.queryError  打不开数据库时的 message
diagError          handler 自己抛错时的 STACK

diagError 是每次都漏的那一个:Node 的栈帧逐帧写出所在文件的绝对路径,所以 600 个字符里大半是目录名;在 Windows 上这些路径带着用户 profile 目录,也就是这个包处处当成发布阻断项的身份泄露。而且它恰好在这条路由存在的理由 —— 报告失败 —— 的路径上触发。

改成两遍再截断:redact 知道三个 store 位置,但只知道它们被交给它的那种拼写;scrubAbsolutePaths 与拼写无关,把任何绝对路径抹掉。第二遍不是冗余的,守卫用一个真实栈量出了这道缺口:一个栈帧是 URL 形式(file:///C:/Users/...,正斜杠、带 scheme),所以反斜杠前缀一条都匹配不上,.minimax 兜底只救回 store 自己的目录,而 node 安装目录、临时目录、依赖目录全都留下。

它故意没有折进 createRedactor:那个也跑在会话正文上,在那里抹掉无关字符串会改变读者看到的东西;栈里没有读者要的内容,这正是这里允许粗暴而那里不允许的原因。

守卫分两类并各自标明:行为的是真的 scrubAbsolutePaths 处理一个真的栈(在临时家目录里写一个会抛的模块再捕获它的真实栈,不是打字打出来的);形状的是三条逐行核对「都过了两遍、且截断在最后」。形状那部分存在的原因是 handler 是 start() 里的闭包,而本套件的约定是不启动 runtime —— 为让测试能调用它而把路由搬出闭包,对 shipped 代码的改动比这条性质本身还大。

mutate-runtime-diag.py 六条变异,六条按名捕获,文件逐字节还原。顺带量出一件事并写进注释:这个 runtime 上 SQLite 的报错不带路径(全文就是 unable to open database file),所以那两个字段是作为通道守着的,不是作为泄漏 —— 守卫把这点说明确,而不是把它讲成比实际更大的一个故事。

九、建议采纳的一条

readSessionTitles 的 SELECT 新增了 parent_session_id。某个 runtime 库里没有这一列时,db.prepare 会在读到第一行之前抛错,而这个抛错落进那个为别的理由存在的空 catch——于是页面上所有标题与 kind 一起消失,全部退回首句文本,没有任何东西说明为什么。

改为:这一列是可选的,有则读、无则退回不带它的查询,而只丢 parent。字段映射一个字没改——降级路径上 row.parent_session_id 是 undefined,原有三元本来就给出 null。

这一条原本由四条读 server.mjs 源码的正则守着,四条都能被一条跑得通的查询满足,而没有一条能被一条跑不通的查询满足。改为在守卫里建两个真的 SQLite 库(一个带列、一个不带)调用这个函数:还原修法时缺列那条按名失败,而另外 24 条形状断言全绿。

守卫与变异

整套件 92/92(44 个 verify-*.mjs + 46 个 mutate-*.py + 2 项检查),无 shipped 文件还原漂移。

守卫 之前 现在 变异 之前 现在
verify-overview.mjs 162 169 mutate-overview.py 12 14
verify-review16.mjs 47 49 mutate-review16.py 47 47
verify-list-cache.mjs 23 25 mutate-list-cache-ttl.py 17 18
client-check.mjs 212 217 mutate-search-client.py 12 13
verify-runtime-diag.mjs 新增,25 条 mutate-runtime-diag.py 新增,6 条

每条新不变量都配变异,且变异必须按名被抓住——崩溃不命名规则,这个项目把它算成比红断言更弱的结果,变异控制会报 UNNAMED。verify-review16.mjs 的断言数涨了而变异数没涨,是因为新增的两条断言由已有的两条变异拥有:一条瞄「过滤器排掉基线」,一条瞄「过滤器按路径而不是按文件名」。

未做

  • 遗留索引目录清理:停写与迁移是两件事,迁移的先决条件未到
  • overviewModel() 的结果不缓存:条带与账本共用同一份模型,缓存它要先答「模型何时失效」
  • 总览条不上 canvas:要把调色板搬进 JS(第二个来源),且它打不到剩下的大头
  • 残渣守卫的「指针不在任何 span 内」这类边界已由上面第七条覆盖;基线 JSON 畸形、多条残渣同时出现、只读临时目录这些形态仍未被断言覆盖

…ches

Three of the remaining poll-cost items, in the order they compound.

countRolesInPrefix counted the list's eight counters with five matchAll passes over the bounded
prefix — a match object per hit, thousands of them per session per rebuild. It now runs four
test-driven scans that read each value out of the text by position. The scans carry /g and test
mutates lastIndex, so each resets it first: the loops break when a value has no closing quote,
which is the normal case for a truncated prefix and leaves the cursor mid-string on module state
shared across calls. verify-count-roles.mjs holds the capturing implementation it replaced as a
reference and requires the two to agree over hand-written edges and over every session prefix in
the store it is pointed at, so the rewrite is held to being the same function and not merely a
faster one.

/api/sessions had no validator while /api/trajectory got one first. sessionsValidator is built
from the same values the payload is built from, in the same request: the visible slice's
dir|sizeBytes|lastActiveAt — exactly what describeSession keys its descriptor cache on — plus the
titles, which no file stat can see, plus the limit, because ?limit=10 and ?limit=20 are different
payloads and must not satisfy one another. The 304 is decided before any descriptor is built, and
an unchanged list answers from the bytes the previous request already built.

The soundness bound is the descriptor cache's and not a new one: a manifest.json appearing after
the walk listed a session would change startedAt without moving the tag, and that is already
invisible for up to SESSION_CACHE_TTL_MS to the cache this reuses. verify-list-cache.mjs pins the
TTL relationship that says so.

One POLL_INTERVAL drove both fetches and it was wrong for both. The list is a cached walk and a
cached title read and now usually a 304; the trajectory reparses a lineage, rebuilds a payload
that measured 18,924 rows, and reopens the runtime database on every request. They are two ticks
now, with the expensive one slower, and the list wakes the trajectory when it has evidence: a 200
means something in the visible slice moved, a 304 means nothing on screen can be stale. So the
slow tick is a floor on latency rather than a delay applied to every change.

The list keeps the fast tick for a reader-visible reason too: the picker renders 「刚刚」 against a
render-time clock, so a slower tick makes that label sit still and then jump. That ordering is
asserted, not just argued in a comment. The ledger build guard moves with the trajectory, where it
belongs — it exists because a table mid-build must not be replaced mid-build, and the list never
renders the ledger.

Read points that moved with it: verify-list-cache.mjs measures the cache against the list's own
cadence rather than the page's, client-check's poll assertion no longer passes just because
SESSION_POLL_INTERVAL contains POLL_INTERVAL as a substring, and the list body cache joins the
others in dispose.

63/63 guards, 33 mutations across the three new controls.
`collectSessionEntries` did two jobs with one return value: it walked the
store, and it returned `entries.slice(0, MAX_SESSION_LIST)`. Because the
slice was the last statement, the only handle the service had on a session
directory was "one of the 30 most recently touched" — and resolving an
explicit `?session=<id>` scanned exactly those 30.

Measured against a real store: 146 candidate directories, 30 exposed, and
116 of them answering 404 to their own id. Inside the window 200/200; a
sample outside 404/24. No leakage at the boundary, which is what a single
truncation point looks like. A session the reader could name but not open is
a correctness defect, and the runtime database cannot paper over it: the six
compacted sessions on that machine have no `local_runtime_sessions` row at
all, so the `latest` branch's `relativeDir` fallback has nothing to find.

The traversal now yields two products and the two callers take different
ones. `recentEntries` is sorted and capped, and it is still the only thing
the picker is built from. `byDecodedId` is every candidate keyed by the id
decoded from its own name; the walk already stat'ed `messages.jsonl` for
each candidate, so the index costs no I/O the walk was not doing anyway.

Three things the index deliberately does not get to do on its own:

  * A decoded name is a claim. `readSessionIdentity` prefers
    `manifest.sessionId`, so an index hit still reads the identity of the one
    directory it picked and compares before serving.
  * Two directories can decode to one id. `Map.set` would keep whichever
    came last and make the answer depend on `readdir` order, so duplicates
    are kept as a list and the manifests resolve them.
  * A name that does not decode is still a real session. It gets no fast
    path; it does not become "does not exist".

`MAX_SESSION_CANDIDATES` stays a resource guard, but it stops being a
correctness boundary by saying so. When the walk stops early the route
answers `session_scan_budget_exhausted`, which is a different answer from
`session_not_found` and only one of the two is true.

The client seeds `state.sessionId` from the host id. `sessionParam()`
already preferred `hostSessionId`, so a host-pinned page rendered
「跟随最新(自动切换)」 above content pinned to a different conversation
that it would never leave. That contradiction predates this change, but the
reachability fix turns it from a visible error into a silent one.

The picker is untouched: still 30 candidates, still `?limit=20`, still a
server cap of 50. Widening it is a UI decision and is not taken here.

Guards: `verify-session-reachability.mjs` holds 31 assertions over a
synthetic 40-session store and a 405-session store that runs the walk out of
budget, and `mutate-session-reachability.py` breaks each of them in the
shipped source and requires the guard to report it by name — 15 mutations,
all caught. Suite is 65/65.
trajectoryCacheKey carries the active file's size and mtime, so a session that is still
being written to mints a new key every time it grows. Every miss then stores another
complete body -- ?since= included, because the cache entry and /raw both need the whole
thing -- and the eviction was a plain FIFO over keys. Four versions of the SAME
(session, slice) therefore sat there at once, three of them unreachable: /raw resolves
the current key, and the page's next poll produces a newer one.

Measured on a 9.1 MB session polled every 8s: four bodies, 49.6 MB, after four polls. On
this machine's 68 MB body that is three dead copies.

An entry now carries the family it answers for -- the session directory plus the requested
slice -- and storing a new one drops its predecessor. Everything that decides whether the
answer went stale stays in the key; everything that decides what the answer IS stays in the
family. trajectoryCacheFamily is exported and used by both the key and the cache, so the two
copies of that expression cannot drift.

Different ?generation= slices stay separate families and keep their own entries, still
bounded by the same limit. Reading still refreshes recency.

verify-trajectory-cache.mjs: 30 assertions, including that the key still carries size and
lineage -- a stale entry must never become servable just because the family matched.
mutate-trajectory-cache.py: 10 mutations, each breaking a different part of the mechanism,
because a bug collapsing two of them would still be caught by the wrong assertion.

Two anchors had to move, and the mutations stayed: the raw-lazy set call grew its family
argument, and createTrajectoryCache now has a clear() of its own, which made the parsed-row
cache's clear() anchor ambiguous. That harness reports an ambiguous anchor as unresolved
rather than guessing, so the anchor grew the bytes that make it specific.

Suite 67/67.
verify-parsed-rows.mjs built its stand-in session as
`{ session: {...}, sizeBytes: 0, truncated: false }`, then spread it into the
per-file result with `...other` AFTER the loop had computed both numbers. The
guard replaced its own measurements with two constants one line before comparing
them: every assertion about total size and the truncated flag was asserting the
literal back at itself.

Those are exactly the two quantities the route derives by walking the store --
how many bytes the generation actually holds, and whether the cap cut it. The
guard was therefore free to disagree with the route about either without any
assertion noticing.

The stand-in now carries `session` and nothing else. Nothing else needed to
change: the computed values survive to be compared, which is the point.

53 assertions and all 12 mutations behave the same as before, so what the guard
was actually pinning is unchanged -- it just stops pinning a constant.

Found while measuring what parsed rows are worth to hold in memory: the row
cache's value was being judged partly by a guard that could not disagree with
itself.
The body was already serialized by the time /raw was asked for, and the entry kept the whole
parsed object graph behind it so that one record could be handed back. Measured on this
machine's own store: 377 MB of heap held while one 71.8 MB session was open, against 304 MB
once the rows were not retained -- and on a session with a 20.5 MB source, 31 MB against 11.
Polling does not change either way: 304 of 304 answered 304, at 1.6 ms each.

The parsed-rows cache could not have been the fix, because it was never a cache for the file
that mattered. Its key is path+size+mtime, and handleTrajectory only reaches the parse after
the trajectory key -- carrying that same size and mtime -- has already missed. Every store was
followed by a miss. Sealed generations are the opposite case and stay cached, because for
those the stat really is constant.

So the active generation is parsed every time and retained never, and what the entry keeps
instead is, per record: the file, the byte range of its line, and a SHA-256 of that line.
36,527 locators cost 5 MB against the object graph's 73. `parseMessagesJsonl` reads the bytes
`readHead` already has and reports where each line sits, which also stops a 68 MB JSONL being
decoded into a 135 MB JS string before it is parsed at all.

/raw seeks to the range, refuses anything whose length or digest does not match, and answers
409 raw_stale rather than a plausible message from the wrong line. That case is narrow -- the
entry is keyed on size and mtime, so only a rewrite preserving both reaches it, and no sealed
generation on this store has ever been rewritten -- but nothing above the file level can see
it, and a wrong message under the right record's name is worse than a hint. The page puts the
validator in its own raw cache key for the same reason: a record index is a position within
one version of one body, and a body that has moved on would otherwise keep its old messages.

The cost is on re-parses only, and it scales with record count rather than file size: 574
records cost 25 ms, 36,527 cost 342 ms. 304s are unaffected.

Reads now answer one line at a time out of a file rather than out of memory, and a record the
page has already opened is kept decoded for the entry, 32 at a time.

verify-raw-locators.mjs: 57 assertions, over the three things the seam can get wrong -- the
positions being byte offsets rather than character indices (the fixture carries a CJK line,
where the two part company at the line's end and agree at its start), the digest meaning
something under a same-length rewrite, and the route actually keeping locators instead of
rows. mutate-raw-locators.py: 20 mutations.

Four existing mutation controls had anchors this change invalidated, and the anchors moved
rather than the mutations: two named the old cache-entry line, one named a comment that was
re-wrapped, two named `empty` and the inline-parse call site. mutate-review16's own anchor
had to move with them, which is the third time a re-wrap has cost a detector its anchor.

Also verified, and it is the part worth keeping: no sealed generation on this store has ever
been rewritten (52 of them, checked against their own catalog's byteLength and mtime), and
the lineage at a session root is the active file alone -- rotated generations live under
snapshots/ rather than beside messages.jsonl, so the retention this removes was the active
file's and nothing else.

Suite 69/69.
… the lineage

A message can be stored more than once across a lineage: the active file may
re-record one a sealed snapshot already holds, and sealing a generation can
rewrite old tool results in place. Neither is a row that can simply be
dropped — one is a second copy of one version, the other a second version of
one message — so the route now merges them and keeps both.

  buildMessageIdentity() folds every occurrence into one identity and records
  the content versions it was written in; selectIdentityView() then picks the
  rows one request projects. The whole lineage shows each message once, at its
  first version; a generation shows that generation's own stored context. A
  row with no usable id is never keyed together with another.

Two readings of "the same message" disagreed about one field.
contentFingerprint() dropped history_artifact, because the newer generation's
opener carries it while nothing about the message changed; equalOutsideContent()
counted it, so whether a rewrite read as a declared trim depended on which row
happened to carry the generation's annotation. It now ignores it too. Checked
against the session store on this machine, the two comparisons disagree about
none of the pairs compared — the change is behaviour-preserving on the data
that exists, and it stops depending on annotation placement in future data.

A turn's opener is decided over the whole lineage rather than the projected
slice. Deciding it from the slice is what let a real steer become the first
visible prompt of its own turn whenever the opener's generation was filtered
away: it counted as dialogue instead of a redirect, and its turn was filed as
neither prompted nor closed. Turns now report openerInView / openerOutOfView
so those two facts stay apart — a view can hold a turn's steer without holding
the row that opened it.

stats.messagesAll counts logical messages over the reachable lineage instead of
summing the per-file counts the catalog recorded, so a message stored twice is
one message and a generation slice no longer shrinks the session total. The
physical count moves to stats.messageRowsAll, and each generation reports the
messages it stores as logicalMessageCount. The picker reads that, and the
generation banner now says which generation is still growing — the picker can
pin the live one, which the previous wording promised would never change.
verify-message-identity.mjs covers the merge, the two views, the four declared-
trim predicates, the turn opener, and the three numbers the payload derives
from them; mutate-message-identity.py breaks each one at a time.

The declared-trim section hangs the generation's declaration on that
generation's own opener row, which is where the store puts it. With it on the
changed row instead, every negative case failed at the "equal outside content"
predicate for a reason unrelated to the case under test — so two of the four
predicates could be deleted and the section still passed. A positive control in
the same shape keeps that from recurring.

One mutation anchor did not do what its label says, which is worth recording:
it replaced a precondition inside a comparison function with `if (false) return
false;`. A never-taken branch does not disable the work below it, so the
mutation was never observed and read as a live miss. Anchors belong on the line
that decides the answer.

mutate-steer.py gains the two ways a steer can come back: deciding the opener
from the projected slice, and treating an opener the view does not hold as no
opener at all.

The generation picker's count and the banner sentence are new behaviour, so
mutate-generation-counts.py breaks each: reading the catalog's per-file row
count again, printing a count the option carries but the label omits, taking
the whole-context number from the slice, and describing the live generation as
a sealed snapshot.

Three existing anchors are re-pointed rather than repaired, because the shipped
lines they matched moved: the steer rule, the orphan filter that now attaches a
per-generation count, and the orphan-matching line it sits next to.
…record

Two record-addressing defects, both visible on stored sessions that keep more
than one generation of the same conversation.

An unmarked activity file was skipped

A session's cross-generation information lives in exactly one place: its
history-catalog.json, whose activeGeneration names the generation still taking
writes. The lineage walk instead decided the active generation from the file
alone, so an active file carrying no generation marker was read as generation 0
and the whole rest of the chain was dropped. One session in 139 catalogued
sessions is affected: its chain resolved to a single file, and every earlier
turn of that conversation was invisible. With the fallback in place the same
chain resolves to generations 0 and 1 — 752 rows become 383 unique messages,
228 identical copies and 141 copies whose tool results were trimmed by the
runtime, and 39 turn openers.

The fallback only applies where it has to. A file that declares its generation
is still taken at its word, so a catalog entry that disagrees with a file is
still resolved in the file's favour, and a file that declares a generation other
than the one sought is still refused. The catalog names which files to look at;
it does not become the authority on what they contain.

Rows and time spans were keyed by message, not by record

The first column renders a record's index, and index is what a row means when it
is scrolled to, pinned to, or brushed on the overview. Those three all went
through the message-level id instead. A message split across records by a
tool-result trim therefore resolved to whichever record happened to be first,
so the scroll anchor, the overview selection and the time brush disagreed with
each other and with the row the reader was looking at. Measured on that session,
228 messages have more than one record, so this is not a corner case.

The overview now keys each span by the record it was built from, and its hit
test returns the span rather than an id. That is what lets a click keep its
message-level meaning — the same message's records highlight together, which is
what selection is for — while hover and time brushing stay on the single record
under the pointer. Selection is unchanged on purpose.
… and mutations

A fallback that is never exercised resolves nothing, so both fixes ship with the
evidence that they are load-bearing.

verify-generations.mjs grows by 5 assertion groups covering the cases the
fallback has to distinguish: an unmarked active file resolves to the
catalogued generation, an unmarked sealed snapshot resolves to the generation it
was sought at, a file that declares its generation still overrides the catalog,
a fork still pointing at a generation other than the one sought is still
refused, and a catalog with no active entry still emits the chain. 129
assertions, all passing.

verify-message-identity.mjs already carries 16 mutations and client-check.mjs
grows by 9: the compaction band and the row both key by record, the overview
model pushes a record-keyed id, selection reads spans by record, and the hit test
hands back a span so the click and the hover read different fields off it. The
old assertion on the compaction band pinned an expression rather than the
behaviour and is updated to pin the key. 186 assertions, all passing.

mutate-generation-counts.py grows from 5 to 11 mutations and mutate-steer.py
from 5 to 8, so both fixes are broken and caught by name: dropping the
catalogued-generation fallback, letting a declaration override the catalog in the
wrong direction, accepting a fork, and reverting each of the record-level keys
back to the message-level id.

All 72 guards pass.
…le text

Two derived artifacts under the runtime's data dir, each with its own guard, neither wired to the
route yet. They are here because the next change is going to need them, and because building them
first is how their shapes get argued against real numbers instead of against an intention.

The index: what a session's ledger looks like, laid out so reading a window or counting a kind
costs a lookup rather than a re-parse of every generation the session has stored.

The primary key is the record's own index, which is already dense and sorted on disk (measured
gapless 1..36527 on a 44-generation session), so the file IS the sorted array a binary search wants.
Four structures are built on load: message id to its records, generation to its records, kind to its
count, and — the one that costs a sort — a side array of timestamps in time order. That last one is
not optional: timestamps along the ledger are nearly but not quite monotonic (32 adjacent inversions
in 25,428 rows on a real session, the largest a 141.8 s step backwards against a 98.9 h span), so
bisecting the primary order would silently drop whatever fell in a gap. The guard holds a fixture with
constructed inversions and shows, as a control, that bisecting the primary order on that same data
loses records and the side array loses none.

What a record does not carry is the bulk: the projected text fields are left out and read back from
the source line on demand, which is the same move the existing lazy raw read already makes. The
denylist is a denylist rather than an allowlist on purpose — a hand-copied allowlist lost eleven
fields the page actually reads before it was measured, and a field this file forgot is a row that
renders blank with nothing to say why. Two of them survive as booleans: whether there was error
text, and the byte range of the line a record came from, which is what a search hit in a sealed
generation is mapped back through.

Nothing in either artifact is a machine path. A record names its file by an index into the
manifest's own file table, and the resolved path is stripped before the manifest is written.

Both are published with the manifest written last, so an interrupted write leaves a directory that
reads as "no artifact" rather than as half of one. Neither is consumed unless its bytes match the
digest the manifest was written with, its schema and projection versions are the ones this build
speaks, and the source it was built from is still the source on disk.

The search segment: the five fields a reader can search, stored once per sealed generation.

Those five fields are measured at 36.7 MB for the worst session, against 8.6 MB for the index, so
they are kept apart: the ordinary read never opens the search surface, and a search never pays for
the index's own metadata twice. A sealed generation cannot change, which is what makes its segment
something written once; the active generation is being appended to and is therefore never stored.

The surface is the page's own search text under one stated normalisation — runs of whitespace
become a single space, ends trimmed — applied to the surface and to the needle alike, so what the
reader typed cannot decide the answer. One record is one line, which is not a formatting choice:
the page joins the five fields with a newline, and a surface that kept that separator produced five
lines per record, so a scan's line number stopped being the record ordinal its locators were numbered
by and a hit resolved to whichever record happened to sit at that line. The guard pins that with an
assertion on the ordinal rather than on the string, because string equality cannot see it — both
sides shift together.

And a scan may not cross a record boundary. One needle tested against each line alone; the guard
constructs a needle that belongs to neither of two records and shows the joined blob would have
matched it.

All 76 guards pass: 84 assertions and 17 mutations on the index, 92 assertions and 17 mutations on
the segments.
The page decided 「does this record contain the needle」 by scanning five fields of
the payload it happened to be holding. So 「只搜已加载部分」 was the contract rather
than a choice, and it missed everything the window did not hold. Search now asks
the server, which scans the same projection the payload was built from, and the
page asks a set.

Server

  - /api/trajectory/search?session=&q=&generation= walks the lineage through the
    one walk handleTrajectory uses, so both endpoints see the same records
  - a hit is the record being displayed: hits resolve through the identity layer
    and the view's version choice, never through stored lines, so a hit can never
    name a row the ledger is not drawing
  - the cap bounds what is sent, not what is counted: a thousand hits is a
    thousand hits
  - a needle shorter than two characters is a 400, not an empty answer
  - indexes and one search segment per sealed generation are published after the
    response, best-effort; the active generation is never published, because it is
    still being written

Client

  - recordSearchText is gone; matchesQuery asks the server's hit set
  - an unanswered needle filters nothing and the count line says 正在搜索. Four
    states are now distinguishable: 命中 N 条 / 未找到 / 搜索中 / 搜索失败.
    「没搜到」 and 「没搜成」 used to be the same empty table
  - one AbortController plus a sequence gate, so only the newest request writes
  - a changed payload re-asks, because the answer names indexes of the payload
    that was there before
  - clearing the filters drops the answer with the needle

The overview strip now follows the answer instead of holding a predicate of its
own, and can reach a per-turn rule. Both are pinned, each by a mutation: the
strip previously kept its own copy of the whole decision, and it drifted the moment
the search moved to the server.

verify-raw-locators' release assertion was accepting a substring of the new
handler's `view.rows.length = 0;`. It now looks inside handleTrajectory and pins
that the `rows` being released is `view.rows`.

Tests: `node tests/run.mjs` on this tree -> 80/80 guards passed. New: verify-search-
endpoint 62 assertions, mutate-overview 2 mutations.
…pened

`text`, `thinking`, `arguments` and `result` were 59.1% of every response body, and the page held
all of it to draw a ledger row and — once in a while, for one record — four inspector tabs.

Measured over this machine's whole session store, projecting every record the way the route does:

    text       8.9%      thinking  13.2%
    arguments  9.3%      result    26.6%      error  1.0%

Measured again over the twelve largest sessions, 6568 records, before and after the strip:

    13.66 MB -> 5.57 MB, 59.2% removed

Server

  - push() measures four presence flags, once, and the values stay on the records. The search
    reads them in this process, so stripping at the projection would have broken search to save the
    page memory; slimPayloadForWire() runs at the serialisation boundary instead, and the ?since=
    delta is built from the stripped payload, so a poll carries no more bulk than the first load.
  - /api/trajectory/record?session=&index=&generation= answers for one record. It re-projects the
    single stored line that record came from and picks by rowPos — a line can expand into several
    records (its text, each thinking block, each tool call), and a wrong position shows the reader
    another block's reasoning as if it were this one.
  - It does not answer with `kind`. Re-projecting one line reproduces every value but disagrees
    about kind for an opener (measured: 1 of 6568; a steer row alone has no turn to have been
    interrupted by). The page already has the record and its kind, and kind is the field a reader
    would act on.
  - `error` is gone. It was the same expression as `result`, set only when isError was already
    true, so `isError || error` could never have disagreed with `isError`, and every failed tool
    result was serialised twice.
  - The row summary is one server-derived field. The page used to assemble it from
    `summary || text || toolName || title`; over this store the `text` step decided nothing and the
    other two decided it for 5 of 1287 records, all system rows.
  - The line read and its digest check moved into readRecordRow(). Two endpoints need them, and two
    copies is two answers to 「are these still the bytes that record was built from」.

Client

  - the four tabs and the error block read the detail; availability comes from the flag
  - 空答复 is decided by hasText === false, strictly. A record from a payload without the flag has
    neither flag nor body, and calling that broken would mark it for no reason.
  - the handoff fetches the detail alongside the raw message, and says so when the error text did
    not arrive rather than printing 「失败」 with nothing after it

Also fixed, found while doing it

  - make-payload.mjs picked the fixture session by mtime. It was not a rule, it was a timestamp,
    and it had changed the answer without anyone deciding it should: the committed payload still
    carried `raw` and `rawOmitted`, fields the projection stopped producing long ago, and every
    guard reading it was asserting against a payload the server cannot produce. It now picks
    deterministically and refuses to record without a tool error, a checkpoint, an open turn and a
    body-less reply — the four the guards read it for.
  - client-check asserted 「exactly two .innerHTML in the page」 under a label about jsonColorize.
    A count fails the moment a third legitimate call is added and passes just as happily when one
    of the two is deleted and the other becomes an injection. It is a property now.

Tests: `node tests/run.mjs` on this tree. New: verify-record-detail, 139 assertions, and
mutate-record-detail, 17 mutations each caught by name.
…he wrong turn

The cursor is `record.index` and a delta is cut at `index > since`. That rests on numbering only
ever growing — and the identity layer took that away. A message arriving that is a newer version
of an earlier one suppresses that earlier copy, and every record after it shifts down by one. A
tail cut at the old number then lands in the middle of the wrong turn: no error, no empty table,
just records in the wrong place.

The `since > upTo` fallback covers a page that has run PAST the server. It does not cover a
numbering that moved underneath it.

  - Putting the etag in the cursor key does not work: the etag changes on every ordinary growth
    (size, mtime, lineage), so every incremental poll would fall back to the whole payload and the
    incremental path would stop existing.
  - Forcing a whole payload on every generation change covers only one of the two ways numbering
    moves. The other happens without any generation changing at all.

So the tail says what it assumed. `delta.basedOn` is the id of the record the cursor pointed at,
read while the tail is being cut — which walks those records anyway. The page compares it against
the record it holds at that index: same id, nothing before the cursor moved, the tail belongs to
this payload; different, the numbering moved, and the only honest answer is the whole payload.

Two things about the shape:

  - The check runs BEFORE the merge and returns early. Checking afterwards would leave records in
    the wrong turn with nothing to undo them.
  - A delta with no `basedOn` is refused rather than trusted. This page cannot tell such a tail
    from a correct one, and 「probably fine」 is the guess the rule exists to prevent.

`since === 0` passes: there is nothing before the cursor that could have moved.

Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-incremental grew 5
assertions and mutate-incremental 5 mutations (24 total), each caught by name.
`tests/verify-time-range.mjs` is not in the default run: it needs a booted service and a
multi-generation session. That is also why nothing had run it since the session it was calibrated
against stopped existing. Booting the runtime against the live store and running it found two
failures, and neither was a fault in the range filter itself.

One was the wall-clock window, which was a literal: 23:50 to 00:20 on fixed October dates. Once the
session that belonged to was compacted away, both sides of the comparison the section exists to
make were 0, so it passed while comparing nothing, and only the assertion written to catch exactly
that noticed. A literal window is the one input in that file that can silently stop being evidence,
and nothing about the code can tell you when it has. The window is now derived from the payload:
the last row before a local midnight and the first row after it, five minutes of padding, floored
and ceiled to whole minutes. It is still required to cross a local midnight, since that is the part
that makes each bound a subtraction the machine's distance from Greenwich gets to corrupt.

  - Reached from the rows rather than from a fixed width, because the reader steps away. On the
    session served here the last row before the one usable midnight was 41 minutes early, so a
    +/-10 minute window found nothing and reported the reader's habits as a fault in the
    arithmetic. The first midnight inside the span turned out to have no rows within an hour on
    either side, being in the middle of a 17 hour gap.
  - If no midnight qualifies, the file fails by name and stops, rather than carrying on with
    nothing chosen. A guard that limps past its own precondition ends up reporting the store's
    habits as faults in the arithmetic, which is the failure this section just had.

The other was 清除筛选 no longer dropping the range in the function that clears it. The reset moved
into `resetBaseFilters()` and the assertion kept reading `clearFilters()`, so it went red on a
change that broke nothing — the same mistake the comment above it warns about, one indirection
later. It now follows the hop, and pins the hop itself: checking only the order would pass on a
清除筛选 that had stopped calling the reset at all, which is a filter the reader can neither see
nor turn off.

Neither edit is a relaxation. Both assertions hold the same thing they held before, and the second
gained a check it did not have. 151 assertions against the live service, green.

The teeth were then checked by hand, because this file cannot have a mutation control inside the
suite: `run.mjs` promises that nothing in it needs a running Mini App, and this one cannot be
checked at all without one — it derives its own fixture from `GET /api/trajectory`. Six mutations
against a throwaway copy of the package taken outside the tree, so no shipped byte was touched,
each required to fail with the assertion that owns the rule rather than a neighbour: the bound read
as UTC, the session-start subtraction dropped, one comparison in `recordInRange()` flipped,
`resetBaseFilters()` unhooked from `clearFilters()`, `clearTimeRange()` unhooked from
`resetBaseFilters()`, and the two clears in the wrong order. Six caught by name, the last three
producing exactly one named failure each. The client was byte-identical before and after.

`tests/README.md` claimed a control for this file, `mutate-time-range.py`, that has never existed
here — not in HEAD, not anywhere in the history. A file that names a control nobody can run is a
pointer with nothing behind it, so the claim is replaced by what is actually true, including that
the hand-check is a one-off and nothing re-runs it.

Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. Separately, against a booted
runtime on this machine's store: `node tests/verify-time-range.mjs <port>` -> 151 assertions, 0
failed, plus the six mutations above.
… on the way

A search was the one route that still rebuilt the whole lineage: `collectLineage`, then the identity
view, then `buildTrajectoryPayload` over every row of every generation, then a scan. The index and
the per-generation segments that already hold the searchable text were written on every request and
read by nothing. On the largest session measured on this machine that was 3.7 seconds to type a word.

    before   cold 3309.8 ms   warm median 3667.3 ms   warm max 3793.7 ms
    after    cold 1730.3 ms   warm median 1689.0 ms   warm max 1703.5 ms

Same session, same probe, artifacts directory emptied first. The median and the maximum landing on
top of each other is the signature of the artifact path answering every time; before, the spread was
the rebuild's. A live 20.4 MB session went 616 ms -> 208 ms. The remaining 1.69 s is the index
itself: 34.2 MB of records.jsonl, read and parsed per search. The projection is gone; the index
read is what is left, and sharding it per generation is the next thing and is not in here.

  - `handleSearch` tries the artifacts before anything expensive. `indexStore.read` checks every
    generation's source identity and the projection version, and the identity decisions baked into
    the index are a pure function of exactly those — so no second intersection against the current
    view is done. That extra step is the kind that silently disagrees with the thing it checks.
  - The ACTIVE generation gets a segment too, which reverses §8.8.1 rule 5. It was right that such a
    segment would be stale before it was written, and it still is; what changed is that nothing has
    to trust it. `sameSource` reads the generation's own size, so an appended file invalidates its
    own segment and only its own, and an untrusted artifact makes the search fall back. Without one
    there would be nothing to scan on a session still being written — the case a reader is most
    likely to search.
  - A generation with no records gets an empty segment rather than none. "No segment" and "no
    records" would otherwise report the same thing, and a session holding one empty generation
    would fall back forever, for a generation that could never match anything.
  - Missing anything falls back WHOLE. `total` is how many records in the session matched, so
    answering from 44 of 45 generations reports a smaller number and says nothing about being wrong.
  - A generation read short by our own cap now carries `truncated` in its source, and both
    `sameSource` and `sameSources` compare it. `size` is the whole file whether or not the read
    covered it, so before this a truncated generation's artifact was indistinguishable from a
    complete one.

Two things this got wrong first, both worth more than the feature:

**The segments were being built from the index's records.** The index drops
`text`/`thinking`/`arguments`/`result` to stay small, and three of the five searchable fields are
in that set — so a segment built that way searches two of five, and a reader finds less with
nothing reporting it. Measured: needle "needle", 14 matches through the artifacts, 45 through the
rebuild. Both plausible. The surface is now built from the payload's records with the index
supplying only fileIdx and the byte range.

**And the guard was green throughout.** It compares the two paths needle for needle on a fixture,
which passed — because in that fixture every match happened to sit in `summary`. The guard now
asserts directly that a record's segment line contains its own `result` while the index does not,
and finds that record on purpose rather than hoping one is there.

Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-endpoint 63 -> 91
assertions and mutate-search-endpoint 15 -> 18, mutate-search-segments 17 -> 18,
mutate-trajectory-index 17 -> 18, each caught by name. The five invariants are in the design note:
same answer as the rebuild, fall back whole, segments only for generations in the lineage, a
truncated generation is not trusted, and `total` is the count rather than the cap.
…ord index off that path

A hit has to answer with something the reader can be sent to: the record's ledger index, its
turn, its kind and its summary. Those lived in a second file read in step with the segment, and
resolving a hit meant going back to the session's whole record index — 34.2 MB of JSONL for the
largest session measured on this machine, parsed on every search.

Each segment line now carries that answer, so the search path reads the segments and nothing
else. The locators file goes with it: it said where each record came from, which nothing that
answers a search needs, and those fields are on the line now. A hit the scan named but the
reader cannot resolve falls back rather than being skipped, since `total` is how many records in
the session matched and a smaller number says nothing about being wrong.

The reader takes the whole scan in ONE call. Resolving one ordinal at a time walked the segment
from byte zero per hit: correct answers, and about eighteen gigabytes of walking for a common
needle, which measured SLOWER than the index read it replaced. Nothing about values can see that,
so the guard counts the walk instead — a boxed string with counted per-line operations, an upper
bound so a correct rewrite that walks some other way passes. Two more assertions cover the same
rule: no per-ordinal reader is exported for a caller to reach for by mistake, and the answers
come back in the order asked rather than the order they sit.

  - search-segments.mjs: `readSegmentHit` -> `readSegmentHits`; `buildSegment` writes
    `<compact json>\t<searchable text>` so a needle can never match into the answer; the scan
    tests only what is after the TAB. `SEGMENT_SCHEMA_VERSION` 1 -> 2.
  - server.mjs: `searchFromArtifacts` reads the segments and sorts by `index`.
    `collectSearchHitsFromArtifacts` is gone — it existed to let the artifact path use the index.
  - trajectory-index.mjs is untouched. The index is still written; what to do about a subsystem
    nothing reads is a separate decision and is not taken here.

Also adds the root `plugin.json`, which `sync-sop.py classify` has been listing as SHIP — the
host's `mcode.miniApp` entry pointer — and which was in the working tree and shipping to readers
while never having been added to the repository. It was never deleted either: `git log --all` on
that path returns zero commits, so "the whole directory installs" was true of a file git did not
track. That is also why every projection refused until now.

Measured on the largest session on this machine (189.1 MB, 45 generations, 25,427 messages,
36,525 records), same request, artifact directory emptied first:

    baseline   cold 3309.8 ms   warm median 3667.3 ms   warm max 3793.7 ms
    this       cold 1439.7 ms   warm median 1416.6 ms   warm max 1432.6 ms

Six needles compared the artifact path against the rebuild on real data: identical bodies and
identical totals (45 / 14,092 / 9,103 / 12,770 / 3,604 / 6,227). Of the 1,416.6 ms that remains,
reading the 44.4 MB of surfaces costs 37.9 ms, hashing them 82.7 ms, and scanning them 20.7 ms;
the rest is the lineage walk parsing every generation's rows, which this path does not use and
which is the next thing to look at.

Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-segments 116 -> 122
assertions and mutate-search-segments 19 -> 22, verify-search-endpoint 93 -> 96 and
mutate-search-endpoint 17 -> 19, all caught by name.
Set a time range and 「追加 N」 disappears from the message card and the turn card. It is still
on the kind chip below them, and it is still correct for the whole session — the whole-session
cards read the server's stats, and these two read the same numbers re-scoped to the range. Both
readings were right and nothing said so.

The cause is a shape that lives in two processes. `scopedStats()` builds its own literal rather
than reading the server's, the server declares `steers: 0`, and this copy did not — while the
loop below still did `stats.steers += 1`. `undefined + 1` is NaN, NaN is sticky, and `isCount()`
refuses a non-finite number, so both readers of that field dropped the term silently.

It stayed invisible because the guard comparing scopedStats against the server numbers field by
field carries a hand-written list, and `steers` was not in it. A hand-written list can only catch
the fields someone remembered to write down. So the field is added to that list — the direct check
for this field — and the new assertion reads both halves off the function itself, which is what
stops the next one whatever it is called. It also covers the nested shape: `stats.tokens.input`
needs `tokens` declared, and the parent name is what gets compared.

Read points, all of them: `stats.steers` is read in exactly two places, both inside `renderKpi()`.
`kpiStats()` has three call sites; the other two read only `messages` and `turnsWithPrompt`, so
their numbers do not move.

Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. mutate-turn-breakdown gained one
mutation — delete the field from the literal — caught by name. client-check gains one static
assertion, which is where it lives rather than in the range guard: that one needs a booted
runtime, and run.mjs promises nothing in the suite needs one.

Found by a retirement audit rather than by a failing guard, which is the first time that has been
the discovery route here.
总览条是下面那张表的画,但它在 spanPassesFilter() 前面挡了一个局部标志,
只列了五个轴里的两个(类型、搜索)。于是设了时间区间、或者只勾了一条异常规则时,
那个标志说「没在筛选」,条带继续画着下面那张表已经丢掉的记录 ——
读者看到的是「筛选了但什么都没变」。

标志是删掉,不是补全:一份列举轴的清单就是一份会漂移的清单。笔选的例外不由
「这个轴没列进去」表达,而是由 spanPassesFilter 传 null 表达。

可见行为因此改变,且是往对的方向:设区间或勾异常时,总览条会跟着变窄。
本地实测(189.1 MB / 25,427 条 / 45 代的那一场,872×900):

- 点「今天」,区间内 0 条:指标读到 0 条消息 / 0 轮,#overview-lanes 的 outerHTML
  变成 73 字节、零个子节点。改之前这里仍是三条满带。
- 点「工具失败」756 条:#overview-lanes 是 70,177 字符 ≈ 756 个 span,
  只有工具道是红的。改之前这里仍是三条满带。

守卫补的是渲染,不是谓词。verify-overview.mjs 原来有 103 条断言、19 处引用
spanPassesFilter,但 renderOverview() 从没被执行过 —— 沙箱里没有 DOM。
这就是这个缺陷活下来的原因:谓词一直是对的,错的是调用方不再问。
所以给沙箱补上 createElement 和一个最小节点桩,把渲染跑起来,
断言写成读者会说的那句话:总览条是下面那张表的画,同样的记录、同样的顺序,
在每一种筛选下都是。103 → 127 条,每种都把行号写死再和 visibleEntries() 对比,
否则两边同时为空也会「通过」。

mutate-overview.py 加第三条变异,把那个局部标志原样写回去(isFiltering()
砍掉三轴的那种写法),按名抓住,报的是 strip under a time range。

套件 82/82。
样式表里有 159 条 class 选择器,十一个在 <style> 之外零命中:btn-primary、btn-text、
bar-success、chipbar、chips、chip 家族(含 chip-mark / is-on)、turn-meta .num、mono,
以及 legend —— 最后这个原始扫描漏了,手扫也把它读成活的,因为它夹在一堆活规则中间。
删完 148 条,少 94 行。

删规则之前先查了变量:btn-primary 是 --mcode-primary-action-bg 和 -text 的唯一消费者,
而这两个变量在三个主题块里各声明一次。只删规则不动变量就是新造六行死声明 ——
下一次扫死 CSS 的人会看见它们,然后不信这次扫描。删死代码做一半比不做更糟,
因为它把一个可核对的结论换成一个不可核对的。

守卫写的是性质不是名单(tests/verify-css.mjs,42 条):

  1. <style> 里每条 class 选择器,在 <style> 之外至少有一个消费者。
  2. 每个自定义属性都至少被 var() 读一次。

按名字写死的清单只能挡住同一个名字被加回来,而没人检查的死规则活不过两次重构。
第二条不是设想的,它就是第一条删 btn-primary 时撞上的那六行。

这个守卫自己出过一次错,错法值得记。第一版把「拼接前缀」当成无条件豁免:只要脚本里有
'turn-' + 这类写法,前缀下所有 class 永远算有消费者。后果是删掉 .turn-meta 的唯一
消费者之后,规则看着还是活的,守卫还是绿的 —— mutate-css.py 的第二条变异直接存活。
修法不是删变异,是把豁免收紧:前缀只能覆盖它能点出名字的那个类。生成器取值来自固定
字面量表(' pill-' + tone,kindTone() 返回九个之一),所以 pill-user 之所以活是因为
'user' 作为字面量存在,这不需要跟数据流就能查。前缀点不出后缀,就什么都不覆盖。

mutate-css.py 三条变异,三个方向都按名捕获:给没人建的标记加规则;轮次头去掉副行而
规则留下;主题块里声明一个没人读的属性。

套件 84/84。浏览器实测:页面结构与删除前逐项一致,无塌陷、无位移。
删掉 formatCompact、turnContext、turnContextSuffix、buildBlock。它们在产品路径上都没有
调用者;turnContext 只被 turnContextSuffix 调用,而 turnContextSuffix 只被测试调用。
turnContextParts 和 turnSubNode 是活的,没动 —— 它们是同一条链的上游。

collectSessionEntries 改判,不删:它有八个读者,全是测试,而 tests/ 随包分发,
所以读者和它一起装到读者机器上,它不是悬空的。「产品路径没有读者」和「没有读者」
是两件事。删它要先迁移八个测试,那是测试基础设施重构,不是退役。

两样东西必须跟着搬,不能跟着删:

一是注释。turnContext 头上那段讲的是产品理由 —— 两个不相交的「5」并排会被读成同一个数
数了两遍,另有 是分界。turnSubNode 还在实现同一个分界,所以注释搬了过去。

二是断言。context group is marked as a separate population 和 numbers before 另有 sum to
the headline 原来跑在 turnContextSuffix 上 —— 一个没有任何人渲染的呈现器。这种断言会在
被渲染的那一行坏掉时依然通过,而那正是它存在的理由。改指到 turnSubNode,并且真的跑它:
沙箱只有 { console },所以给 el 和 document.createTextNode 补了最小桩,把渲染出来的文本
从树上读回来。顺带修好一处 —— 旧断言传的是 compactionTurns,而 turnContextParts 读的是
compactions,所以 压缩 那一项从来没进过断言;它过了,但它没在测自己注释里说的那件事。

mutate-record-detail.py 里那条变异的替换文本原本调用 buildBlock。删掉它会把这条控制变成
崩溃(渲染时 ReferenceError)而不是按名失败,而报崩溃的控制等于没报。替换文本改用活代码
重写,缺陷不变:结果页直接渲染 record.result,瘦身之后它永远是空的。17 条变异仍全部按名捕获。

mutate-turn-breakdown.py 的锚点跟着分隔符搬到 turnSubNode,回归本身没变。

守卫写的是性质:内联脚本里声明的每个函数,都在自己的声明之外出现过(tests/verify-dead-code.mjs)。
不是名单 —— 名单只能挡住那三个被加回来。这条规则在这份文件上是准的:批次之前 230 个声明、
4 个无读者,之后 226 个、0 个。

规则定下来之前错了三次,三次全是误报。只认调用会把十三个 addEventListener 的事件处理器
全报成死的,所以是「出现过」不是「被调用过」;没剔注释会把散文里的名字当读者;
为不让页面挂载而切掉的启动块里正好有 init() 的唯一一次调用。三处都写进了守卫的头注释。

另外断言了规则自己的前提:顶层函数缩进恰好 6 空格。一次重新格式化就能让规则悄悄不再
覆盖大部分文件,而那是最像成功的失败。

三条变异按名捕获,其中第三条「把唯一的引用只留在注释里」专治「规则没剔注释」那个错误方向。
范围只到 index.html;server.mjs 有同形状的东西,但它的导出被八个随包分发的测试读着。

套件 86/86。
磁盘上的索引目录是死的,投影那一步是活的 —— 所以这次退役的不是「一个索引」,而是
一份没人读的产物,加上一次每次发布都要重算的完整记录投影。

server.mjs 从索引模块导入九个名字,六个零读者。剩下三个里有一个例外:索引行的返回值
在段构建时被当场读走,每段行取它的 fileIdx / byteStart / byteEnd。把它连同模块一起删掉,
搜索就再也拿不到记录落在文件里的位置 —— 那正是「按记录取详情」端点的输入。

因此投影保留,但收窄成只产那三个字段,搬进 search-segments.mjs(这三个字段就是段行
JSON 前缀的一部分,搬过去之后段行只有一个来源)。trajectory-index.mjs 整个删除。

「无定位得到 -1/-1/-1,且不得误归文件 0」这条语义原样保留。它此前分散在两层,各守一半:
段守卫只管「完全不带定位字段的输入」的归一化,根本不调用投影;投影层那条只有一处守着,
而它马上要跟着模块一起没了 —— 所以搬到了新守卫里,并且真的跑投影函数,不是抄一遍它的
匹配表达式。

守卫与变异:
  - tests/verify-locator-rows.mjs(18 条):顺序、形状、fileIdx、字节范围、同名文件不得按
    basename 匹配、无定位不得变成文件 0、邻居保住自己的、空输入与空 locator map
  - tests/mutate-locator-rows.py(4 条):四个方向各一条,全按名捕获
  - verify-search-endpoint.mjs 96 → 97 条

重写了一条断言。原来它说的是「这条搜索路径不读索引」,而写索引的调用在同一个函数里,
所以它要靠作用域才成立。现在索引两边都没有了,规则就写成它真正是的东西:整个服务里
没有任何记录索引。断言之前先剔注释 —— 服务端要解释索引去哪了,而一句提到它的解释恰好会
让「没有索引」这条规则失败,那么唯一的写法就是什么都不写。

这条断言原本是三个名字的名单,而名单只能挡住名单上的东西。补了一条按性质的:产物根目录下
只允许存在一个子目录(段所在的那个)。用一个 token 名单抓不到的名字注入验证过,它按名失败
—— 一个用新名字重建的索引会通过名单,被这条抓住。

mutate-search-endpoint.py 补到 20 条,新增的那条用活代码重建被退役的形状:段旁边再写一份
产物,没有任何人读它。这不是把模块加回来,是有人真会写出来的回归形状。

顺带修好一段已经过期的注释:它说索引还在用、并且「是它标出每条记录的 index、turn 和 kind」。
索引没有了,turn 来自 payload 的 turn 表,投影只出文件与字节范围。

存量目录留着不动:停写让新增写入归零,不改变已经落盘的文件(实测每个会话每个视图 8.6 MB),
回收是迁移,不是停止写入的同一步。已确认全仓库没有任何删除或修剪逻辑碰它,所以留着是安全的。

套件 86/86。
横幅写着「正在重试」,而它能被收掉的唯一条件是「服务端回了一个带 body」—— 304 不算。
而 304 正是页面静止时每一次轮询的答案:cacheKey 只由 dir|sizeBytes|lastActiveAt 决定,
会话没动就是同一个 tag,服务端必然回 304。于是**一次瞬时的连接抖动 + 一个安静的会话 =
横幅永久停留**,并且一直宣称自己在重试。点「刷新」能收掉它,但那只是因为手动刷新丢掉了
validator,强制拿了一次完整 body。

顺带查出同形的第二条:会话列表有完全一样的 304 早退,以及一个**写了三处、读了零处**的标志。
列表读取失败对读者完全不可见。

改动:

- 两个散落的布尔(state.stale、state.sessionsError)换成这一层自己的 freshness 模型,
  唯一写者 noteFresh()。原来 state.stale 有三个写点,现在每个字段只有一个。
- **304 也算新鲜** —— 轨迹和会话列表两条路都改了。
- 认不出的失败拆成两类:「与本地服务的连接中断」和「服务端返回了无法读取的内容」。
  原来四种错误码加上这两类全部塌缩成同一句「更新失败」,页面上不留计数也不打日志,
  一次抖动和一次持续故障事后无法区分。
- 连续失败从第二次起显示次数与来源(轨迹 / 会话列表)。第一次不给这句解释,因为一次抖动
  会在一个轮询内自己消失。
- fetchSessions 的 HTTP 失败原本把 status 扔掉了,所以任何非 2xx 都会被归成「断线」。

**修复不许变成另一种代价**:render() 重建整个账本,安静会话每 8 秒一个 304。
noteFresh() 返回「状态是否真的动了」,只有动了才渲染。这条有断言,不是靠自觉。

四问在 .research 的退役审计 §11,含逐个读点表。

守卫 tests/verify-freshness.mjs(27 条):把交付的内联脚本原样载入沙箱**真跑**,
断言写成场景而不是符号清单 —— 失败后接 304 / 接 200 / 连抖三次 / 断线 vs 坏响应 /
只有列表失败 / 五个 304 之后渲染次数为 0。
这一层此前没有任何守卫(state.stale、sessionsError、stale-bar 在 tests/ 里的匹配数是 0),
这正是四个缺陷能活下来的原因。

变异 tests/mutate-freshness.py(5 条)全按名捕获:把 304 当成什么都没发生;失败列表只记不看;
恢复不清计数;断线被报成服务端的问题;每个 304 都重渲染。
另加一条反方向的用例 —— 只加一行注释必须仍然全绿,否则守卫读的是散文不是行为。

套件 88/88。
最坏会话(36,525 条)下这条带子建 36,525 个 `.ov-span`,每个至少 2px 宽,于是 ~36,000 个是
互相压在一起的 —— 872px 的带子上只有约 436 格,每格读者看到的是「最后画上去的那条」。
每次轮询带 body 的重建,浏览器实测 3,937 ms,轮询间隔 2.5 秒。

改动:按**像素列**建节点,一列一条车道取最后一个覆盖它的记录 —— 也就是原来叠放时每个像素上
读者真正看到的那个值。所以**每列的逻辑答案和以前是同一个**。但画面不是逐像素相同的:
旧的盒子最小 2px 且带最多 1px 的间隙,新的是 1px、间隙 0。所以是**更锐**,不是**一样**。

颜色、透明度、车道、`data-kind` 全部仍然来自同一批 CSS 规则:这次只决定**有多少个节点**
和它们**站在哪**,调色板没有第二个来源。这也是没有选 canvas 的原因。

交互一行都没动,因为它们本来就不走 DOM:
  笔刷区间 / 滚轮缩放   overviewFractionAt → overviewTimeAt → nearestSpanAt
  区间是否含某记录       recordInSelection,读 overviewSpansById 这个 Map
`overviewSpansById` 仍然为**每一条**建条目 —— 区间判定要用。只有画被聚合了。

悬停和点击改了:以前 `spanAt()` 用 `document.elementFromPoint` 拿指针下的节点,
在每像素 42 条时它已经是随意的(返回最后画上去的那条);聚合之后它变成**只有列的赢家
有节点**,其余记录根本够不着。现在车道仍然从 DOM 读(车道在 CSS 里,14px 的间距不该在 JS
里抄一份),而记录从**完整模型**里取 —— 指针附近在时间上最近的那条。悬停因此比以前精确,
不是变差。

选中记录与悬停记录各多一个节点,压在最上层:列是一个范围,而「这一条」不是;
选中的那条还可能落在当前视口之外,那更是列表达不了的。

条带没被排版过时退回「每记录一列」,也就是改动前的行为 —— 这不是防御性写法,而是这个函数
不该把画面变得更糟的保证。

实测(同一份探针、同一台机器、同一个桩):

| 记录数 | renderOverview 改前 | 改后 | 节点改前 | 节点改后 |
| ---: | ---: | ---: | ---: | ---: |
| 5,000 | 36.3 ms | 34.9 ms | 5,000 | 2,559 |
| 36,525 | 546.5 ms | 192.5 ms | 36,525 | 2,616 |

节点数在 20,000 条就饱和(872px × 3 车道 = 2,616),与记录数无关。outerHTML 从约 2.7 MB
降到约 190 KB。

**收益只算一遍**:去掉的是 DOM,不是那次遍历。改后 192.5 ms 里 overviewModel() 自己占
78.6 ms(41%),其余约 114 ms 是 36,525 次循环本身。「每记录一个节点」的成本归零了,
「每记录走一遍」的成本原样还在。1,000 条那一档变慢 2 ms(聚合的常数项),那一档本来也不痛。

四问在退役审计 §12,逐个读点列了出来。其中一条是**新断言当场抓到了聚合自己造出来的缺陷**:
3 列放 5 条记录时中间那条一列都没占到,从条带上整个消失 —— 改之前它是有节点的,丢不掉。
所以承诺按两种情形分开写:条带宽于记录数时每条独占一列、条带等于列表(断言原样保留);
条带窄于记录数时承诺「画的每条都是列表留下的」且「覆盖记录走到的每一列」。

守卫 verify-overview.mjs 127 → 146 条。stripIds() 改为去重 —— 一条记录可以占住多列,
而「每条记录一个节点」在每像素 42 条时本来就不是读者能验证的话。
mutate-overview.py 3 → 8 条,全按名捕获。原有三条变异锚在 spanPassesFilter 那行,聚合发生在
它之后,锚点原样继续解析。

其中一条是被验收评审推翻之后才写的:第一版守卫直接调 markOverviewHover('g3'),注入 id,
所以它绕过了必须「发现」这个 id 的那条路 —— 而那条路当时是坏的。它是绿的,缺陷也是绿的。
现在这一段驱动真实的 pointermove,让 DOM 按浏览器的方式答一个**列赢家**,再要求模型答出
**那条输掉所有列的记录**。为此桩补了 classList:没有它,命中测试在测试里根本无法成立,
这也正是这个缺陷能活下来的原因。

套件 88/88。
聚合把「每条记录一个节点」变成「每像素列一个节点」之后,只有**占到过某一列**的记录在 DOM 里有节点。
而命中测试当时是 `document.elementFromPoint` 往上走到 `.ov-span`,读它的 `data-record-id`,再查 Map ——
于是**输掉所有列的记录既点不到也悬停不到**,而上一批为它们写的悬停标记因此永远不会亮。那条标记是死代码,
它写的场景它自己触发不了。

- **车道从 DOM 读**:14px 的车道间距住在样式表里,指针的 y 是唯一诚实的办法。在 JS 里抄第二份,
  意味着以后改车道要记得改两个地方,而记不得的那一次会安静地指错行。
- **记录从完整模型取**:`nearestSpanAt(time, lane)` 在模型里按时间最近找,可选地只看某一条车道。
  于是答案是**离指针最近的那条记录**,而不是最后画上去的那条 —��� 两个问题读者问的都是这个。

指针不在任何节点上时仍然返回 `null`,不做吸附。

顺带查出一条真缺陷并改掉:`mutate-generation-counts.py` 锚的那一行就是被这次改动删掉的那行,
锚点失效 → 控制 `raise SystemExit` → 整套件掉到 87/88。锚点只能改锚点,于是改锚到新代码形状上:
`return nearestSpanAt(overviewTimeAt(overviewFractionAt(clientX)), lane);`(全文件唯一,另一处
`nearestSpanAt` 在滚轮手势里,不带 lane)。**没有**改锚到「读 DOM 那个 id」——那是 `mutate-overview.py`
里已经有的那条变异,重复一条会让两个控制各说一次同一件事,而这条变异的名字声称的是返回值的形状,
不是解析路径。

改锚时发现守卫有个缺口,而且缺口比想象的大:

- `client-check.mjs` 那条断言是**读源码文本**的,它原来的两个拒绝项是「不许出现
  `getAttribute('data-record-id')`」和「不许出现 `return <词>.id`」。第一条挡住从 DOM 解析,
  第二条挡住直接返回 `.record`。但**按语法枚举返回值不会结束** —— `recordKey(hit.record)` 和
  `hit.id` 都返回一个键、都保留模型解析、都不含那两样东西。
- 所以这条性质搬到了 `verify-overview.mjs`:那里的 sandbox 是 `vm.runInContext` **跑交付的脚本**,
  `sandbox.spanAt()` 可以直接调。断言改成**问它返回什么**,而不是读它怎么写的。
- 再往前一步:只问**形状**也是不够的。`{ id: hit.id, record: hit.record }` 带齐了形状检查看的每个
  字段、也报对了记录号,而页面后面的一切在它上面都能正常工作 —— 它是**新对象**,不是命中测试找到的那个段。
  点击和悬停拿到的就是这个值,所以要成立的是 `directHit === crowded`(`crowded` 就是
  `overviewCache.model.spans` 里那一个,`nearestSpanAt` 按引用返回它)。

两条位置上的讲究都是被变异控制教出来的:

- 断言必须排在 handler 用例**之前**。返回键的改动同样会弄坏后面那几个悬停断言;排在后面,
  这条变异会归因到先失败的那条,再也拿不到自己的名字。
- 两处读取都要过同一个 guard。第一版直接写 `directHit.record.id`,命中测试答 null 时抛 TypeError,
  两条变异立刻变成 `UNNAMED` —— 崩溃不命名规则,这个项目把它算成比红断言更弱的结果。

守卫与变异:

- `tests/verify-overview.mjs` 146 → **149**;桩补了 `classList`、`style.getPropertyValue`、
  track 与 lanes 分开的宽度、场景前清 `overviewDrag`。最后一项原来缺得静悄悄:
  前一个场景留下的 brush 会让 pointermove 直接 return,悬停断言全部测不到东西
- `tests/mutate-overview.py` 8 → **10**,10/10 按名捕获。新的两条是「返回行键」和「返回假壳」,
  后者**只**被身份断言抓住
- `tests/client-check.mjs` 206 条不变,那条断言的注释改成明说「这不是完整性质」并指向真正拥有它的那条
- `tests/mutate-generation-counts.py` 12 条全按名捕获

套件 88/88。
这两个字段写进每一行段记录的 JSON 前缀,**shipped 搜索路径里没有任何一处把它们读回来**:
`readSegmentHits` 只取 5 个字段(index / turnIndex / kind / summary / generation)后丢掉其余,
而真正需要定位符的两个端点 `/api/trajectory/record` 与 `/raw` 读的是内存里的 lineage 缓存
`cached.raws`,压根不碰段文件。实测代价:真实段面 **4.36%**,每行 49 字节
(1,177 行、1.3 MB 的样本;49 × 1,177 = 57,673 字节)。

它们当初是按一条**对但没走完**的规则进来的:段行携带「一条命中回答所需的一切」,于是命中应当自足。
打开命中走的是内存缓存那条路,缓存冷时直接 `trajectory_not_cached`,而**轨迹路由**才会
`loadParsedRows`(按 size + mtime 建键)重新解析并回填 —— 承诺从没兑现,字段一直在全额付钱。

补一条来路事实,免得被当成抄来的:**定位符是这个 app 自己算的**。`parseMessagesJsonl` 解析时
记下每行的字节区间,拼成 `{ filePath, start, end, digest }`;代码里没有任何地方从会话文件读一个
叫 locator 的字段。同一个 locator 里的 `digest` **是被读的**(`buildMessageIdentity` 用它做身份
指纹),只是没跟着进段文件。所以退役的是「位置」那部分,不是整个 locator。

改动:

- `SEGMENT_SCHEMA_VERSION` 2 → 3。已落盘的段**全部失效一次**,每个会话首次搜索重建一次。
  这是本次唯一对外可见的行为变化,且是单部署下的本机重算
- `locatorRowsFromPayload` → `fileIdxRowsFromPayload`,只返回 `fileIdx`。**函数连名一起改** ——
  它不再产出 locator,名字声称的东西变了而名字不变,正是这个文件一直在还的那种债
- `fileIdx` 留着:它是唯一的分代分组键(`located.fileIdx !== fileIdx`)。「无定位得到 -1,且
  不得误归文件 0」这条语义原样保留,且它本来就只住在投影层 —— 段守卫管的是「完全不带字段的输入」
  的归一化,根本不调用投影,所以如果投影把无定位的记录修成文件 0,段那边 122 条断言会全绿
- 删掉一个零引用的 typedef

两条「它没了」的断言都写成**能被真的打红的形状** —— 不是「输出里没有这两个字段」,而是
「**输入里带着**这两个字段、输出里没有」。只写前者的话,退役之前它也会绿,等于什么都没钉。
退役有两扇门,各配一条变异:

- `fileIdxRowsFromPayload` 那一扇(`tests/mutate-file-index-rows.py`):变异把字节范围写回投影的行
- `buildSegment` 那一扇(`tests/mutate-search-segments.py`):变异把两个字段写回段行。
  **`buildSegment` 照抄记录上被给的东西**,所以只停投影那一扇停不住;只盯 `buildSegment` 那扇,
  上游哪天又开始提供字节范围,它就悄悄回来了

守卫与变异:

- `tests/verify-locator-rows.mjs` → **`tests/verify-file-index-rows.mjs`**(137 行,16 条):
  顺序、形状、fileIdx、同名文件不得按 basename 匹配、无定位不得变成文件 0、邻居保住自己的、
  空输入与空 locator map、无 locator map 与无源文件同义、path 重复时先到先得
- `tests/mutate-locator-rows.py` → **`tests/mutate-file-index-rows.py`**(143 行,4 条),全按名捕获。
  原来那条「字节范围被丢掉」的变异随主体一起没了,换成上面那条反向的
- `tests/verify-search-segments.mjs` 121 条,`tests/verify-search-endpoint.mjs` 97 条(不变)
- `tests/mutate-search-segments.py` 22 → **23** 条,23/23 按名捕获
- `tests/mutate-locator-rows.py` 与 `tests/verify-locator-rows.mjs` 删除(改名,不是退役)

四问与逐个读点在退役审计的 §17:段面字节数(查过,没有任何守卫承诺段面大小)、
schema 版本不匹配的回退(已有覆盖)、`readSegmentHits` 承诺的 5 个字段(不变,新增断言钉住)。

套件 88/88。
renderOverview 有八个调用点,其中七个不动模型的任何输入 —— 筛选、选中、刷选、
滚轮、平移(含 60 ms 定时器)、切会话都带着同一批行和同一个轴重进。建模在
36,525 条记录上占 186.3 ms 里的 59.6 ms(32%),每帧还要 churn 约 73k 个对象。

所以这不是一个新缓存加一套失效策略,是把模型的两个输入比一遍:

  - state.flat 全文件只有一个写者(flattenTurns),且从不原地修改,所以新数组
    = 新行,同一数组 = 同一批行。内容比较要多走一趟每条记录,而那正是要省掉的
    那一趟。
  - state.actualDuration 是另一个输入,它自己的 setter 在值未变时提前返回。

两个键缺一不可。只键行数会把另一个轴上的模型留下来,总览条会把每条记录画在
读者刚关掉的那个轴的位置上 —— 所以第二条变异就是专门去掉轴的键。

它一开始存活了,而原因不是守卫漏了,是守卫自己的前一步断言把另一个数组留在了
缓存里,于是「行」的比较也 miss、重建照常发生,轴那一步观察到的重建什么也证明
不了。补一行把 MIXED 放回缓存之后才死。同一个文件里相邻两条断言,会让第三条
断言通过它本该抓不住的缺陷,这是本次唯一一处需要停下来想的地方。

口径不变:总览条上每个数、每个位置都不动,变的只是同一批 span 对象不再被重复
构造。增量内存约等于零 —— overviewCache.model 本来就活着,缓存是「不再重建」,
不是「多留一份」。

守卫 149 -> 162(数重建次数,不断言字段存在);变异 10 -> 12,逐条按名捕获。
它被跳过的唯一原因是输入。断言要 >1000 条消息、要跨本地午夜,而能拿到的会话
要么是「这台机器恰好有什么」,要么是 60 行的 fixture。三条路都不行:塞进默认
集合等于发红;把数字改到 fixture 够得着,时区算术整个不测;提交一个几 MB 的
真实会话,等于把会话内容打进包里。

现在的做法是自己造会话,交给服务端自己的 buildTrajectoryPayload —— 也就是
/api/trajectory 路由调的那个导出函数。合成的是对话,stats 仍然由被测代码算
出来;第 3 节问的还是「页面的重新推导与服务端的数字是否一致」,两边都还是出厂
实现。

造出来的会话刻意做成让比较咬得动,而不只是能通过:被比较的字段必须非零,否则
0 === 0 全绿而什么也没测。所以 prompt 轮、continuation 轮、未收尾轮、一轮向读者
提问、steers、失败的工具调用、五个 token 计数器都是设计出来的。合成后 compactions
与 compactionTurns 为零,而 compactionTurns === 0 是故意断言的:裸 checkpoint 轮
会让两边在推导不同的人群,而不是对同一份人群有分歧。

起始时刻挂在本地日历上而不是一个 UTC 瞬时值 —— 被测的就是本地午夜,一个固定的
UTC 起点会让它在每台别的机器上落到别处,这个文件就变成在量存储的时区。还会避开
跨夏令时的那几天,因为墙钟动了而 relativeMs 没动时,被比较的两个读数会分家。

两个不显然、第一版都写错的形状:

  - closed 不是 stop reason。服务端是从 kind === 'reply' 的记录推出 closed 的,
    而 reply 由字面量 'stop' 赋值。写成 'endTurn' 会投影成 narration,于是每一轮
    都静默保持 open —— openTurns 出来是 275/275,这个形状看起来很健康,实际什么
    都没测。
  - 一轮只有在工具结果之后再来一条 assistant 行时才算收尾;一条同时带文本和工具
    调用的 assistant 行不是 reply。

新增 mutate-time-range.py 八条,全部按名捕获。其中六条是这份守卫在默认集合之外
的那段时间里手工验过的变异 —— 那是一次性结果,没有任何东西会重跑它,所以后来的
改动可以在这个目录里全部通过而这份文件无人验证。现在它们在这里跑。另有一条反向
用例:只加注释必须不改变任何判定。

152 条断言。run.mjs 的 SKIP 集合随之删除。
存储里一直有 parent_session_id 这一列,而服务端的会话查询一直只取 title 和
session_kind,所以运行时派生的每一次子代理运行,在列表里都和一个顶层会话长得一模一样。
唯一发到客户端的 sessionKind 在 index.html 里出现 0 次——字段一直通着,没有人读它。

链路是四段,缺任何一段都是同一个静默结果:查询取列 → 行映射成 parent(空串算没有)
→ 挂到 descriptor → stripInternalFields 以 ?? null 过闸 → 客户端按字段决定标不标。
所以四段各有一条断言,而不是一条:只看其中一段的实现,另一段被切掉时照样绿。

为什么只标「子会话」而不写父会话的标题:分支一定比派生它的会话新,而列表按最近活动
取前 30 个,父会话通常已经掉出这个窗口。写标题就是一个「读者最想要答案的时候正好失败」
的查询,而静默失败的查询最后会让界面声称一个它看不见的东西。

它是一个标注而不是筛选器:不写任何 state,所以不存在「第二个筛选器对哪些记录在屏有
不同看法」这一类联动异常。

口径不变:纯文本后缀,不改计数、不改统计、不改「最近 N 个」的截断。但它改了
/api/sessions 响应体的字段集合,而此前没有任何守卫钉住那个集合——stripInternalFields
在整个 tests/ 里 0 命中。所以这次补的是一块现在还没有的地板,而不是守住一个已有的数:

  - verify-list-cache.mjs 18 -> 23:四段链路各一条,外加客户端那半读的是去掉注释的
    源码——sessionOptionLabel 上方那段注释就写着「子会话」,对着原始文件断言会被它自己
    要抓的东西满足。
  - client-check.mjs 206 -> 212:把 sessionOptionLabel 跑起来,验身份而不是形状。两个
    只差 sessionParentId 的会话,标签必须只差一个标注。一个无条件加标注的版本能通过
    任何「里面有没有子会话」的检查,过不了这一条。
  - mutate-list-cache-ttl.py 12 -> 17:四段链路各切一处,加上「标注加到每个会话上」
    这个看起来像对、实际上把每个顶层会话都说成子代理运行的方向。
评审给的是 not acceptable,两条都成立,都已修。

**一、`/api/sessions` 的 ETag 没有覆盖它新读的那个字段。**

`sessionsValidator()` 的材料是 dir | sizeBytes | lastActiveAt | title | kind,不含 parent;
而响应体在组装时已经读了 `meta.parent`。body 读一个字段而 validator 不哈希它,就是:parent 变了
而目录统计、标题、kind、limit 都没动时 tag 不动,轮询的客户端拿到 304,不带 validator 的请求
还能复用旧字节。判据是两份只差 parent 的 map 产生相同的材料——读代码就能确认,那条拼串里根本没有
这个字段。parent 已进材料。

守卫 29 → 31。两条新断言把 title 与 kind 都按住不动,所以不可能靠另外两个字段蒙过去;另有一条
空串与 null 必须一致(两者在页面上渲染成同一行)。变异 13 → 14。

连带:改这一处打断了三个已有变异的锚点——title/kind 那几行原本以 `));` 结尾,加一行之后 kind
那行不再是串的结尾。按规矩只改锚点,并把解释性注释挪到 `parts.push(` 的上面,让三行字段保持连续:
锚点不该隔着注释,改个措辞就失效。

**二、两条「比较」其实是自己跟自己比。**

合成会话里 compactions 与 compactionTurns 是零。零意味着把页面上 `stats.compactions += 1` 与
`stats.compactionTurns += 1` 删掉,逐字段比较仍然 0 === 0 全绿——那两条不是比较,是这个文件
自己安排好的一对零。断言数量看不出这一点,只有变异能。

生成器每 23 轮造一个只装一条 checkpoint 的轮次,那是服务端唯一会归成 compaction 的形状。两个
计数器现在都非零,删掉那两行会被抓到并按名报 whole-session compactions / whole-session
compactionTurns(已实测)。

同时删掉一条循环断言。原来那句 `compactionTurns === 0` 断言的是这个文件自己选的数,而它保证的
正是它随后要去比较的前提。真正的不变式——真实会话不该有裸 checkpoint 轮——属于另一个守卫的
既有一条断言。**在自己的 fixture 上断言自己造出来的数,证明不了任何事。**

变异 8 → 10。套件 90/90。
…r count

The whole-session section compares twenty numbers between the page's re-derivation and the
server's own. Both sides are correct in a passing run, so a comparison can only fail if one of
them is broken — which means a field with no mutation is a field whose comparison could be
satisfied by a page that never counts it at all. Three fields had a mutation; seventeen did not.

The fixture is now a generation view over a lineage rather than one whole-session pass, and that
is not tidiness. `messagesAll` counts the conversation and `messages` counts what the view holds;
while the view was the whole conversation the two were one number, so re-scoping 「全程 N」 onto
the range changed nothing any assertion could see. One generation stays outside the payload, and
the guard asserts the two figures differ before asserting anything about either.

Every compared field is now checked non-zero by the guard itself, driven off the list the
comparisons are driven off, so the next field added there arrives covered rather than waiting to
be discovered at zero. Two mutations break this guard's own generator instead of the page's code:
removing the checkpoint turns, and making the view the whole conversation again. Without them the
twenty preconditions are a claim about a session nobody has tried to starve.

`mutate-time-range.py` 10 -> 29, all caught by name.

verify-time-range.mjs 153 -> 172 assertions; suite 90/90.
Two of the time-range control's cases edit the guard's own session generator, which made
`tests/verify-time-range.mjs` a file a control rewrites in place — and it was not in the list the
suite hashes around its mutation controls. A hard kill during that window would have left a
generator with no checkpoint turns in it, and every later run would have reported that as ordinary
red rather than as residue.

The membership rule is "files a control rewrites", not "files that look important", and this one
had stopped being protected the moment it became a target. Checked by planting one byte of
residue in it: the startup warning names the file.
@avatasia

Copy link
Copy Markdown
Contributor Author

这六个提交围绕同一条判据:一条比较只有在两侧之一被破坏时才会红。所以一个没有被变异的字段,就是一条可以由「根本不数它的页面」满足的比较。绿色的数量从来不是证据,能被删掉的那一行才是。


e5d4e23 总览条的模型不再每次重建

overviewCache 原先只键住 flat 的行数。同一批记录内容变了而行数不变时,缓存照样命中。补两个输入键:

  • flat 用数组身份而不是长度 —— 全文件只有一个写者,且从不原地修改,所以身份稳定;
  • 另一个轴 state.actualDuration 入键,而它的 setter 在值未变时提前返回。

两键缺一不可:只键行会把另一个轴的模型留在缓存里。守卫数的是重建次数,不是「字段存不存在」—— 只验形状会被「存了输入但照样重建」的假壳蒙过。

fce491d 时间范围守卫回到默认集合

run.mjs 原先跳过 verify-time-range.mjs,因为它只能向一台跑着的服务要会话,而它要的形状要 1,000 条以上消息、还要跨一个本地午夜。现在它自建合成会话(400 轮、跨午夜),交给服务端自己的 buildTrajectoryPayload —— 合成的是对话,被比较的 stats 仍由被测代码算出。

9604ba1 会话列表认得出「这是子会话」

store 里一直有 parent_session_id,投影、映射、stripInternalFields 四段都在,唯独没人读。四段各一条断言:只看一段的实现,另一段被切掉时照样绿。

只标「子会话」、不写父会话标题:子会话一定比父会话新,列表按最近取前 30,父通常已掉出窗口;一次静默失败的查找会让列表声称它看不见的东西。

fb69e95 两条空转的比较

  1. /api/sessions 的 ETag 材料不含 parent,而 body 读了它 —— body 读一个字段,validator 不哈希它。parent 进材料后,两条新断言把 title 与 kind 按住不动,所以它不可能靠另外两个字段蒙过去。
  2. compactions / compactionTurns 在 fixture 里是零,于是把页面上的 += 1 删掉仍然 0 === 0 全绿。同时删掉一条循环断言:原来那句 compactionTurns === 0 断言的是这个文件自己选的数,保证的正是它随后要去比较的那个前提。

8096e07 一个字段没人变异,就是一个页面可以从不数它

  • fixture 改成 lineage 之上的一个 generation view:造 4 代 × 100 轮,只把第 2–4 代交给 payload(lineage 1,740 条 vs 视图 1,306 条)。messagesAll 数整条会话、messages 数这个视图;原先视图就是整个会话,两个数相等,于是把页面上的 messagesAll: base.messagesAll 换成 base.messages(把「全程 N」悄悄换成「区间 N」)时,全部断言照样全绿。现在先断言两者不相等,再断言别的。
  • 20 个被比较字段逐个一条页面侧变异(15 个标量 + 5 个 token,外加 messagesAll)。其中 userMessages 那行删不掉:它是 if / else if 链的头,删了之后页面直接语法错误,守卫抛异常、连 FAILED_NAMES 都不打印,只能报 UNNAMED —— 那比 caught 弱,套件一样是红的,但没有任何东西说明是哪条规则破了。改成累加零,才测得到要测的那个缺陷。
  • 两条变异改的是这个守卫自己的生成器,不是页面:去掉 checkpoint 轮次(两个计数归零)、把视图变回整个会话(两个数相等)。没有它们,那 20 条非零断言只是「关于一个没人试着饿瘦的生成器的声明」。
  • 非零前提由比较所用的同一份字段列表驱动,原来只有两条手写断言 —— 恰好是先坏的那两个字段。

verify-time-range.mjs 153 → 172 断言;mutate-time-range.py 10 → 29 条,全部按名捕获。

b3a75f3 守卫自己的文件进入哈希清单

第三组变异让 tests/verify-time-range.mjs 变成了「会被就地改写的文件」,而它当时不在 tests/run.mjs 哈希的那份清单里。硬杀落在那个窗口里,会留下一个没有 checkpoint 轮的生成器,之后每一遍都把它当成普通红灯报出来。成员规则是「被改写的文件」,不是「看起来重要的文件」—— 它在变成目标的那一刻就已经不再被保护了。

验证

在 b3a75f3 上跑 node tests/run.mjs:90 项守卫(43 个 verify-*.mjs + 45 个 mutate-*.py + 2 项检查)全绿。整套件不需要跑着的服务,也不读本机的会话目录。

…ustive

Two defects, both of the shape this suite keeps running into: a check that could not fail, and a
rule nobody owned.

The validator joined title, kind and parent on a bare NUL. A SQLite TEXT column can hold any
character, so a title of "a<NUL>b" beside a kind of "c" and a title of "a" beside a kind of
"b<NUL>c" produced byte-identical material — one ETag for two rows a reader can tell apart, and a
304 served for a payload the caller never held. Choosing a different separator cannot fix that;
no character is excluded from a TEXT value. The material is now JSON, which is injective by
construction.

The assertion beside it was named "the separator is a character a title cannot contain" and
checked that the function body contained the escape. It tested the separator's identity and
called the result a property, and the property was false. It is replaced by two that state the
rule the name always meant: two metadata tuples a reader can tell apart must not share a material,
with the ambiguous character placed inside a value rather than at a boundary. Each has a mutation
— one for having no separator at all, one for having a separator a value can contain — because
those are two directions of the same defect and only both being caught makes the property held
rather than assumed.

Second: the suite hashed a hand-maintained list of the files a control rewrites in place, and the
list was the weak point. Two files were missing — the runner itself and a guard — while two
controls rewrite them, and nothing owned the membership rule, so the drift was silent. A list that
has to stay in step with 45 controls is a list that will not. The runner now hashes the whole
package, both directions, and the rule cannot fall behind. It carries a self-test that plants a
byte in one of the two files the old list missed and requires the comparison to name it, so the
check is known to be able to fail; a mutation that narrows the walk makes the self-test stop
catching and is reported by name.

Two assertions in verify-review16.mjs were pinned to the exact wording of a message in the runner.
Renaming a message turned them red for a reason no reviewer could act on, so they now check the
structure instead: a WARN line exists and the leftover never reaches `failed`, and the two labels
are read back out of the file and compared with each other.

verify-sessions-etag.mjs 31 -> 32, mutate-sessions-etag.py 14 -> 15, verify-review16.mjs 41 -> 42,
mutate-review16.py 37 -> 38. Suite 90/90, and the package is byte-identical across the run.
@avatasia

Copy link
Copy Markdown
Contributor Author

更正:上一条评论里关于 b3a75f3 的那段有一句是错的,先收回。

我写过:「成员规则是『被改写的文件』,不是『看起来重要的文件』」。那句话不成立。 那份清单当时确实漏了两个文件 —— tests/run.mjs 自己和 tests/verify-freshness.mjs —— 而 mutate-review16.py 会改写前者、mutate-freshness.py 会改写后者。硬杀落在那个窗口里,这两处的残渣没有任何检查会指名。

为什么漏了没有人报:清单是手工维护的,45 个控制要与它保持一致,而没有任何断言拥有「成员规则」这条,所以漂移完全静默。这与本评论开头那条判据是同一个形状 —— 一个没人能证伪的清单,就是一个迟早会漏的清单。

改法不是补两个条目,是取消清单。 tests/run.mjs 现在哈希整个包目录:第一个变异控制前取一次,最后一个控制后比一次,两个方向都查(被改写的文件,以及运行中凭空多出来的文件)。规则因此不再需要被维护。

这条新规则自带能失败的证明:run.mjs 有一个自测模式,在 tests/verify-freshness.mjs(正是旧清单覆盖不到的那类文件)里放一个字节,要求比对指名它;mutate-review16.py 配了一条变异把遍历范围收窄,自测随即抓不到,verify-review16.mjs 按名报出。


/api/sessions 的 ETag 材料不是单射的

sessionsValidator() 原先用 NUL 拼接 title / kind / parent,而 SQLite 的 TEXT 装得下 NUL。于是 {title: 'a<NUL>b', kind: 'c', parent: 'p'} 与 {title: 'a', kind: 'b<NUL>c', parent: 'p'} 产生逐字节相同的材料:两个读者能分辨的行共用一个 ETag,随后那个 304 是为调用方从未持有的载荷发出的。

旁边那条守卫更值得记:断言名叫「分隔符是标题装不下的字符」,检查的却是「函数体里出现了那个转义写法」—— 验的是分隔符的身份,不是它承诺的性质,而那个性质是假的。 任何字符都能出现在一个 TEXT 列里,所以「换一个分隔符」解决不了,只能由表示本身解决。

改成 JSON.stringify([...]),构造上单射。守卫里那条形状检查换成两条身份断言 —— 把歧义字符放进值里面而不是放在边界上,两个可分辨的元组必须给出不同材料。mutate-sessions-etag.py 各配一条变异:一条管「完全没有分隔符」,一条管「分隔符装得进值里」。这两条是同一个缺陷的两个方向,只有两条都在,那条性质才是被守住而不是被假设的。

数字

之前 现在
verify-sessions-etag.mjs 断言 31 32(去掉 1 条承诺假性质的,加 2 条真的)
mutate-sessions-etag.py 变异 14 15
verify-review16.mjs 断言 41 42
mutate-review16.py 变异 37 38
残渣比对的对象 9 个文件的手工清单 整个包目录

在 e2c514c 上跑 node tests/run.mjs:90 项守卫(43 个 verify-*.mjs + 45 个 mutate-*.py + 2 项检查)全绿。

The previous commit replaced a hand-maintained file list with a whole-package digest and claimed both
directions. The end-of-run comparison does check both. The startup comparison did not: it iterated
the keys of the current tree, so a path the last run recorded and this tree no longer has — a
rename, or a control that deleted a file — was in neither the iteration nor the digest, and nothing
reported it. A disposable reproduction: add a file, record a baseline, delete the file, run again.
The second run printed its result and nothing else.

The comparison now takes the union of both key sets. A path that is NEW since the last run is still
filtered out, because somebody adding a file is a contributor working, and reporting that as residue
would be the false positive that makes a contributor delete their own work.

The self-test that proves the check can fail now has two phases, one per direction, because the two
fail for unrelated reasons: phase one plants a byte in an existing file and asks the end-of-run
check, phase two records a path in the snapshot and asks the startup comparison. Phase one could
never have reached this bug, because the snapshot it compares against was taken from a tree that
still contained the file. One assertion covering both would let either direction fail alone while
the other passed, which is exactly how the previous version reported an exhaustive check that was
silent in one direction.

Three assertions and three anchors in this area were pinned to incidental spelling and had to move
with it: the startup comparison's variable was renamed, so its shape check no longer named a
variable; the snapshot read now appears in the self-test too, so a bare match was satisfied by the
wrong call site; and a lazy span from `readSnapshot` onwards crossed the function boundary and was
still satisfied by the self-test's read. All three now name what they mean instead, and all three
mutations are kept — the point of a control is the rule, not the string.

verify-review16.mjs 42 -> 43, mutate-review16.py 38 -> 39. Suite 90/90, package byte-identical across
the run.
The self-test phase of run.mjs read tests/.shipped-snapshot.json back from
disk to compare against. That file is gitignored and untracked, so on a fresh
checkout there is nothing to read and the self-test reported a discrepancy
that no mutation had caused. The second phase now compares against the snapshot
the run already holds in memory, and run.mjs no longer touches the file at all.

That part is small. Establishing the state the self-test needs is not, and the
first way it was done was wrong in a way worth recording.

The guard has to run --self-test-residue on a package with no baseline, and the
obvious way to arrange that is to move the real baseline aside and put it back.
That makes the guard a writer to a file two other things depend on. It needs a
lock, and then the lock needs a key that identifies the path, and the key needs
to survive a spelling that Windows considers the same one and SHA-256 does not,
and the lock needs an owner recorded inside it, and a recorded pid can be reused
by a process that never held it. Every one of those was found by running the
guard concurrently or killing it: four concurrent invocations left the baseline
deleted outright and appended two blank lines to a file the suite hashes. That is
a measurement of code this commit replaces and does not contain; what the diff
shows is the arrangement that replaced it, not the failures above.

So it does not do that. It copies the package to a temporary directory and runs
the harness there. A copy has no baseline by construction, which is the state
under test arrived at without anybody deleting anything, and the directory is
this process's own, so the lock, the key, the owner and the recycled pid all go
away together with the code that needed them. Six concurrent invocations of this
version were measured on this machine: each passed, and the baseline was
byte-for-byte what it was before. That is a measurement rather than something the
diff shows.

A scratch destination inside the package is refused rather than attempted: copying a
directory into its own subtree is what cpSync declines to do, and it declines by
throwing, which would take the guard down before any assertion ran on a machine whose
TEMP simply points somewhere unusual. That refusal is reported as a named failure
with a reason, not as an exception.

run.mjs also gains --print-startup-leftover, which reports what the startup
comparison actually decided and exits without running the suite. The self-test
builds its own input and hands it straight to filesMissingSinceLastRun, so it
measures the comparison function and says nothing about whether the startup
hands it one at all: with `const previous = null` every assertion stayed green
and `leftover` came back empty on every run. The guard plants a baseline
recording a path the tree does not have, asks the harness what its own startup
made of that, and parses the answer rather than matching it: a baseline was read,
and the reported list is exactly the one path that was planted. Substring
membership would have been satisfied by any output that mentioned the path. The
harness picks no input here, so what is measured is the wiring.

It also reports which branch the warning took, because a comparison that runs
correctly and a comparison whose result is then gated out of the warning look
identical from the values alone -- `if (leftover.length)` could become
`if (false && leftover.length)` and the residue check would compute the right
answer and tell nobody.

mutate-review16.py gains a control for each of those. Its candidate-file list now
searches this guard last: the guard holds the checks for every other file's
wording, so a README string occurs in it exactly once inside a regex, and
searched earlier the guard would win ties that belong to the file the mutation
was written against.
@avatasia

Copy link
Copy Markdown
Contributor Author

接上一条:残渣检查里那个相位,从头到尾测的不是启动路径。

一、自测要了一个它并不拥有的基线文件。 残渣自测的第二个相位原本是从磁盘把 tests/.shipped-snapshot.json 读回来的,而那个文件被 .gitignore 排除、也从没被跟踪过 —— 一份全新克隆出来的检出里它根本不存在。自测没法自己造出来:写入挂在「结束时干净」上,而相位一按设计就是脏的。于是它在一个任何人都没动过的工作区里以 ENOENT 死掉,verify-review16.mjs 跟着一起红。现在对照改用手上已经有的那份记录集;run.mjs 整个不再碰那个文件。

二、那个相位测的是比较函数,不是启动路径。 它自己造输入、直接喂给比较函数,于是从读取基线到报告结果之间那一句赋值,从头到尾没人测过:

const previous = readSnapshot();   →   const previous = null;

改完之后断言全绿、自测两个相位全过,而 leftover 在每一次运行里都是空的 —— 启动残渣检查整个死了,没有一处会红。断言的名字叫「启动比较」,测的却是一个自由函数。(这是把那段赋值改掉之后跑出来的实测;那段代码不在本次 diff 里,diff 里能读到的是它已经不再读那个文件。)

run.mjs 因此多一个模式 --print-startup-leftover:报告启动时真正得出的结果,不跑套件、不碰基线。守卫种一份只记录着一条「这一棵树里没有的路径」的基线,跑那个模式,要求这条路径被点名。守卫在这里不给套件任何输入,所以被测的就是那段接线。

三、为了让自测跑在没有基线的状态上,守卫去动了那个真的基线文件 —— 这一步是后面一连串问题的源头。

移动真基线的做法看起来最直接,但它把守卫变成了别人也依赖的那个文件的写者。于是:

  • 四个 verify-review16.mjs 同时跑,实测结果不是「偶发失败」,是基线文件整个消失,外加 verify-freshness.mjs 尾部多出两行空行 —— 套件哈希覆盖的文件上留下了残渣。成因不是删除,是读:后开的进程把前一个的临时 fixture 当成了自己的基线读走,结束时又写回去。(这一条是改动过程中的实测,复现它需要那段已删掉的中间代码,本次 diff 里没有,CI 也复现不了。)
  • 于是要加锁。锁按路径命名,于是挡不住同一个路径的不同拼写 —— 只改盘符大小写就又拿回了四把不同的锁,基线再次消失。
  • 于是锁要带持有者 pid,才认得出死锁。但 pid 会被复用,一个跟锁毫无关系的活进程会让守卫永远拒跑。
  • 而锁放在临时目录,恰恰让套件自己的残渣检查看不见它 —— 硬杀一次,那把没人持有的锁就留在那里,此后再没有文档告诉任何人去删它。

每一步都是在修上一轮造出来的脚手架,没有一步是原始缺陷需要的。

所以改成:守卫把整个包复制到临时目录,在副本上跑。 副本天生就没有基线 —— 那正是被测状态,而且没有人删掉任何东西;那个目录是本次调用自己 mkdtemp 出来的,于是锁、锁的键、持有者、被复用的 pid 这四样连同需要它们的代码一起去掉了。本轮实测六个并发进程各自 47/47 通过,基线逐字节未变,包内无残留(这一条是实测,不是 diff 能读出来的)。

账要算清,不能只算重构那一步:删掉锁、锁的键、持有者那套脚手架之后,接着两轮又补上了结构化解析与分支观测的断言,所以相对上一次推送是净增约 9 KB(21 KB → 30 KB),断言 41 → 47、变异 37 → 47。少掉的那部分守的是已经不存在的性质,不是被放弃的性质。

补一处环境相关的:TEMP 指向包内时,那份副本就落在自己下面,而 cpSync 拒绝对自己复制、直接抛错 —— 守卫会在断言之前死掉,而这正是这条链上反复拒绝的那种读数。现在副本目录若在包内就被拒绝,并且这个拒绝是一条带名字的失败(带原因码),不是一个异常。

而且这个「在包内」问的是文件系统上的真实路径,不是字符串:TEMP 指向一个指回包内的 junction 时,词法比较会说「在外面」,而真实路径会说「在里面」。这一条我实测过 —— 攻击副本被守卫以 ERR_SCRATCH_INSIDE_PACKAGE 拒掉,包内没留下任何东西,而不是像词法版本那样碰巧由 cpSync 抛错。

mutate-review16.py 为留下的每条断言配一条控制,都按名字抓到。改这几版时还撞上一次静默落错文件:守卫的候选文件表里原本没有守卫自己,而按常规顺序插进去之后,两条 README 相关的变异改了守卫里镜像 README 措辞的那段正则就再也落不到 README 上了 —— 表里第一个「恰好出现一次」的地方赢下这处平局。现在守卫排在最后:它装着每个文件措辞的检查,别处的字符串在它里面同样只出现一次。

还有一条存活的变异值得说,因为它不是守卫弱:它去掉的是清理路径上的一道门闸,而 rmSync 不带 recursive 本来就删不掉目录 —— 去掉它在任何被跑到的状态里都不改变可观测行为。规矩「存活变异 ⇒ 修守卫」在这一条上是例外,判据是被瞄的那道闸本来就不承重;处理是重瞄并把理由写进变异自己的注释,不是删变异。

四、接线那条断言只做子串匹配,于是任何提到那个路径的输出都算数。 stdout.includes(GONE) 满足不了「那确实是启动比较算出来的」—— 一行调试输出、一个错误消息、一个写死的字符串都能满足它,连 previous = null 配一个写死的打印都抓不到。

现在它解析那份结构化输出,要求:hadBaseline === true、leftover 是个数组、里面恰好只有那一条、且就是种下去的那一条。要命的是形状和内容都要对,而不只是「提到过」。

五、再往下一层:算对了不等于告诉了谁。 把警告那一段的 if (leftover.length) 改成 if (false && ...),残渣检查照样算、照样打印那些数字,而屏幕上一个人都不会被提醒 —— 所有守卫仍然全绿。上一条之所以抓不到,正是因为它读的是算出来的值,而一段被关掉的输出分支不会改变任何一个值。

所以诊断现在多报一件事:它走了哪个分支。这条断言要求的是「读到了基线 + 报出了那一条 + 确实警告了」三件事同时成立,而第三件是前两件推不出来的。

顺带一处:复制时的排除条件原来是按文件名比的,于是树上任何叫这个名字的文件都会被丢掉 —— 副本就不再是「少了账本的那个包」而是「少了所有同名文件的包」。现在按**路径**比,并且这条筛选本身被断言测到(tests/… 里的被排除,fixtures/…` 里的同名文件被保留)。

已推送 现在
verify-review16.mjs 断言 41 47
mutate-review16.py 变异 37 47

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢这一轮的大量整理!我在 ff6e1fb 上按 merge-base(e1d5a29)的增量做了复测:

  • Node 22 下 npm run check 55/55 通过。
  • 总览条按像素列聚合:用 12000 条记录的合成会话测试,节点数固定为 3 车道 × 列数(1008px 下是 3024),不随记录数增长。
  • 新鲜度横幅:连续失败 2 次时会显示次数,恢复后轮询返回 304 就能收起。
  • 段 schema 从 2 升到 3 后,旧 manifest 会被拒绝并重建,段行里也不再有 byteStart/byteEnd。
  • /api/sessions 的 ETag 在 NUL、内嵌引号、换行这几类构造下都能区分开,304 正常。
  • ?since= 工具行整轮下发、跳转箭头的 ceil、启动快照读取,这些都没有回归。

需要修改

  1. 总览条的悬停和点击会命中错误的记录(index.html 5811-5816 行,nearestSpanAt)。after = span.end - time 和 before = time - span.start 两个距离都是负数,所以 < bestDistance 选中的是本车道里离指针最远的那条。现在 spanAt() 的悬停和点击都走这里,只要指针时刻不在本车道某条记录内部,就会返回会话的第一条或最后一条。我用 12000 条记录的会话(01:02 到 09:22)在带子上取了 32 个点悬停:0.131 处显示 09:22:18,0.644 处显示 01:02:28,错了 20 多个点。改成 time - span.end 和 span.start - time 以后,提示时间随位置单调递增,点击也一样。建议顺手补一条「指针不在本车道任何 span 内」的断言。
  2. 全新克隆后第一次跑 node tests/run.mjs 会失败:mutate-review16.py 里 “the scratch copy is made with the baseline still in it” 这条变异存活(46/47)。tests/.shipped-snapshot.json 被 .gitignore 排除,全新检出里没有它,所以把复制过滤器换成 () => true 也看不出区别;先跑一次套件生成快照之后就是 47/47。可以先在被复制的源里种一份基线,再验证副本里没有它。
  3. 包根目录新增的 plugin.json(82a6e02)和 package.json 内容完全一样。仓库的清单约定是 .minimax-plugin/plugin.json,其他插件也没有这个文件,看起来是误加的,麻烦删掉。
  4. 版本号还是 1.1.0。按 CONTRIBUTING 的要求,更新已有应用时需要同步版本,麻烦把 .minimax-plugin/plugin.json 和两份 README 一起升一下(比如 1.2.0)。

建议(可选)

  1. readSessionTitles 的 SELECT 新增了 parent_session_id。如果某个 runtime 库里没有这一列,prepare 会抛错并被吞掉,所有标题和 kind 都会丢。我用一个不带这列的测试库对比过,main 上能显示标题,这个分支上会退回首句文本。如果旧版本 MiniMax Code 可能缺这一列,可以考虑缺列时退回不带 parent 的查询。
  2. PR 描述里还是「88/88」,也没有写到后面的子会话、ETag 改 JSON、run.mjs 全目录哈希等改动。我们这边是 squash 合并,描述会成为提交信息,麻烦更新一下。

// time alone, while a pointer also says which row it is over.
if (lane !== undefined && span.lane !== lane) continue;
if (span.end < time) {
var after = span.end - time;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里两个距离的符号写反了:span.end < time 时 span.end - time 是负数,下面的 time - span.start 也是负数,所以最后选中的是离指针最远的那条。改成 time - span.end / span.start - time 以后,我这边悬停和点击都正常了。

…could not tell

Four things an acceptance review named, and one suggestion taken. The first is the only one a
reader of this plugin would ever have noticed.

nearestSpanAt compares two candidate distances and both were subtracted the wrong way round:
`span.end - time` where the span ends before the pointer, and `time - span.start` where it starts
after. Both are negative, and bestDistance starts at Infinity, so the first candidate still gets
in and every later candidate that is FURTHER away wins after it. Hover reported the wrong time and
click selected the wrong record, everywhere except where the pointer happened to fall inside a span.

Nothing owned that arithmetic. client-check.mjs can only read it as source text, and both
subtractions mention span.start, span.end and time, so no regex can tell which way round they go.
Every mutation in mutate-overview.py changed how the hit test resolves -- the model, the lane, the
DOM, the return shape -- and none changed what it compares, because the one place the two distances
are both computed is a gap between two spans, and no assertion ever put the pointer in one.

Seven assertions now do, with every expected value derived by hand from the span coordinates rather
than read back out of the function: a pointer in a gap takes the nearer of the two spans around it,
the same with the nearer one on the other side, two spans on the same side and it is still the
nearer, a pointer with no row is answered from every row, one past the whole session takes the span
that ENDS last rather than the one that ends first, one before it takes the first, and the row is a
filter rather than a preference. All seven fail by name when the code is reverted: four for the
`span.end` subtraction, six for the `span.start` one. Two mutations, one per line, because each is
load-bearing alone -- with a single candidate on that side the flipped subtraction and the correct
one happen to name the same record.

The second is the one that made a clean checkout red. The residue guard copies the package to a
temporary directory and runs the self-test on the copy, so the copy has to arrive without a
baseline. The mutation that removed that property was aimed at the one call site, which looks
harmless because the source being copied IS the package this guard sits in -- and
tests/.shipped-snapshot.json is gitignored, so on a fresh clone there is nothing in it to exclude.
Neuter the filter there and the copy comes out the same either way: 46/47 on a clean checkout,
47/47 on a machine where an earlier suite run had left the file behind. Same commit, decided by
what happened to be on disk.

So the filter is a factory over a root, and the guard builds its own source with a baseline planted
in it. The absence is then a fact about the filter rather than a coincidence about the machine, and
it holds on every machine that runs the suite.

Third: the package root carried a plugin.json byte-identical to package.json, and package.json is
what carries the mcode.miniApp entry point. The other seven plugins in the repository ship
.minimax-plugin/plugin.json and package.json and nothing else. Removed, along with the sentence in
the workspace notes that gave the extra file a job nobody could find.

Fourth: 1.1.0 -> 1.2.0 across the manifest and both READMEs, which is what CONTRIBUTING asks for
when an existing app is updated. The guard asserts equality with plugin.json rather than "not
1.0.0", so all three move together, and the four mutations written against the literal 1.1.0 were
re-anchored -- left alone, three would have stopped resolving and the fourth would have become an
edit that changes nothing.

The suggestion was taken because the failure is worse than it looks. readSessionTitles selects
parent_session_id, db.prepare throws on a column the database does not have, and that throw lands
in the empty catch that exists for a different reason -- "a title is a nicety". On a runtime
database written before child sessions existed, every title and every kind in the page reverted to
the first sentence of the transcript, silently. The column is now optional: asked for when the
database has it, not asked for when it does not, and only the parent is lost. The mapping did not
change at all -- on the fallback row parent_session_id is undefined and the ternary already answers
null.

The four assertions that covered this were regexes over server.mjs, and a regex is satisfied by a
query that works. None is satisfied by a query that fails, which is the entire case. The guard now
builds two real SQLite databases, one with the column and one without, and calls the function: with
the fix reverted the without-column assertion fails by name while the other twenty-four stay green,
which is the clearest possible statement of what those four regexes could not see.

Also narrowed one warning. The new test imports node:sqlite, which prints an experimental notice to
stderr on first load, and run.mjs reads stdout+stderr and prints the last non-empty line -- so the
warning landed where this guard's verdict should be and verify-list-cache.mjs was reported with the
text of an unrelated warning. Filtered to that one message, installed before the import that
provokes it.
…nostic route published a stack

Two findings, from an acceptance review whose question was changed rather than whose code was
changed. Both are product behaviour, and neither is visible to the suite: ninety-two guards were
green throughout.

The review asked for a promise-to-guard map of the whole package instead of a reading of one commit,
and for at least one behavioural experiment on the software rather than on the tests. Fifteen earlier
rounds each looked at one commit and each found a real defect -- all of them the same shape, a check
that could not fail. Not one was a wrong answer from running the program. Running the suite cannot
find a defect in code the suite does not exercise; it can only confirm the holes are not on the paths
it walked.

First: a search that failed once said 「搜索失败,以下为上次结果」 for the rest of the session.

The catch block sets state.searchError. The only two places that cleared it were the two a READER
drives -- typing in the box, and pressing clear. A poll that re-runs the current query after the
transcript changed is not a reader, so nothing it did could clear it: the hit set and the count
updated, the label did not, and because searchStatusLabel reads the error first the stale error
outranked the fresh answer it was describing. The page showed the right rows under the wrong
sentence. An answer that arrives now retires the failure that preceded it.

Every existing check was blind to this from the same side. The searchStatusLabel cases each construct
a state object written out for the case they ask about, and a state cannot be stale because it was
written for that question. The runSearch checks read its source as text, and the line that was missing
is a line they were not written against. So the transition is now RUN rather than read: the guard
slices the SHIPPED source of runSearch, searchHitSet, searchStatusLabel, formatInt and sessionParam
out of index.html and evaluates it, asks for a failing fetch and then a succeeding one, and reads the
page's own state afterwards. Nothing in it is written out for the test except what the server sent.
With the fix reverted it reports got "search_failed", want null and got the failure sentence, want
「命中 1 条」 -- the two strings the reviewer had recorded by hand.

Second: /api/runtime was publishing absolute paths on the failure paths it exists to report.

It is the one route whose entire contract is what it does NOT send -- its own comment says 「Names and
structure only」 and 「this route must never hand the browser a filesystem location」 -- and it was the
only row in that map with no owner. Asking is what turned up the hole rather than a coverage gap.
Three fields in the response are not basenames, counts or flags; they are substrings of exception
text. diagError is the one that leaked on every run: a Node stack frame names the file it is in by
absolute path, once per frame, so 600 characters of one is mostly directory names, and on Windows
those carry the user profile directory. In a package that installs onto every reader's machine that
is the same identity leak this project treats as a release blocker everywhere else -- and it fired
exactly when the reader was already having a bad day.

Two passes now, in that order, then the clip: redact, which knows the three store locations in the
spelling it was handed, and scrubAbsolutePaths, which is spelling-agnostic and blunts anything
absolute. The second pass is not redundant, and the guard measures the gap rather than asserting it:
a stack frame is printed as a URL with forward slashes and a scheme, so the backslash prefixes match
no frame of it, and the store's .minimax fallback rescues the store's own directories while leaving
the node install, a temp directory and every dependency standing. It is deliberately not folded into
createRedactor, which also runs over session CONTENT where blanking an unrelated string would change
what a reader is shown; a stack carries nothing a reader came for.

The guard says which of its checks are which. Behavioural: the real exported scrubber, handed a REAL
stack captured by throwing from a real module written inside a real temporary home, plus a second
throw from outside the store, because the first sits under .minimax where the fallback happens to save
it -- a guard that only tested that would be reporting safety it has not checked. Shape: three
count-checked lines holding the wiring, because the handler is a closure inside start() and this
suite's convention is to never start the runtime, and moving a route handler out of its closure so a
test could call it is a bigger change to shipped code than the property is worth.

One thing that check turned up, recorded because it is the kind of assumption that reads like a fact:
on this runtime a SQLite refusal carries no path at all -- the whole message is 「unable to open
database file」 -- so those two fields are held as a CHANNEL rather than as a leak, and the guard says
so instead of telling a bigger story than it checked.

The shipped-tree scan then caught this commit's own comment, which had spelled out a drive-qualified
user directory to explain the very rule that scan enforces.

verify-runtime-diag.mjs 25 assertions, mutate-runtime-diag.py 6 mutations caught by name,
mutate-search-client.py 12 -> 13.
@avatasia

Copy link
Copy Markdown
Contributor Author

四条必修与两条建议都做完了,另外在改完之后又发现两条产品缺陷,一起在这条里交代。

四条必修

一、总览条悬停/点击命中错误的记录。 两个距离都是「大时间减小时间」,< 留下的是最近的那条。

原来这段算术一处断言都没有:client-check.mjs 只用正则读它的源码,而两处减法都是 span.start/span.end 对 time,读源码看不出方向。mutate-overview.py 里十条变异瞄的是调用点(DOM 解析、lane、返回形状),没有一条动到它比较的东西。

补 7 条行为断言,期望值由 span 坐标手算、不从函数回读:空隙里取两侧较近的一条、较近的一侧换到另一边、同一侧两条候选时仍取较近、无行号时跨行比较、越过整个会话取结束最晚的那条、早于会话取第一条、指针所在行是过滤而不是偏好。两条变异各瞄一行 —— 把任一减法单独翻回去,分别红 4 条与 6 条,两行各自承重。verify-overview.mjs 162 → 169,mutate-overview.py 12 → 14。

二、全新克隆首跑会红。 复现确认:同一条变异在干净检出上 46/47,在跑过一次套件的开发机上 47/47 —— 判定它「抓到了」的那台机器,正是唯一能让它失败的那台。

过滤器改成接收一个 root 的工厂,守卫自己 mkdtemp 一份带基线的源再复制它,于是「副本里没有基线」成了过滤器的事实而不是磁盘的巧合;变异随之改瞄工厂本体。改动后在无基线环境下实测 47/47。verify-review16.mjs 47 → 49(另加一条按路径排除的筛选断言)。

三、根目录多出来的 plugin.json。 已删。它与 package.json 内容完全相同,而 mcode.miniApp 入口指针是后者在承载;仓库里另外七个插件都只有 .minimax-plugin/plugin.json 与 package.json。

四、版本号。 .minimax-plugin/plugin.json 与两份 README 一起 1.1.0 → 1.2.0。守卫断言的是与 plugin.json 的相等,所以三处必须同时动;四条锚在字面量 1.1.0 上的变异跟着重锚 —— 留在原地的话三条会失锚、第四条会变成一次什么都没改的编辑。

建议采纳的一条

readSessionTitles 的列改成可选:有则读、无则退回不带它的查询,而只丢 parent。字段映射一个字没改 —— 降级路径上 row.parent_session_id 是 undefined,原有三元本来就给出 null。

原先四条断言都是对 server.mjs 源码的正则,四条都能被一条跑得通的查询满足,没有一条能被一条跑不通的查询满足。改为在守卫里建两个真的 SQLite 库调用这个函数。实测:还原修法时缺列那条按名失败,而另外 24 条形状断言全绿 —— 这正是那四条正则看不见的东西。verify-list-cache.mjs 23 → 25,mutate-list-cache-ttl.py 17 → 18。

PR 描述已按第二条更新(原来停在 88/88,且没写到后来的子会话、ETag 改 JSON、全目录哈希等改动)。

改完之后又撞到两条,都不在原题目射程里

原来的验收每一轮都只看一个 commit,每一轮都找到真缺陷,但全是同一类:一个本应失败的检查不能失败。没有一轮通过运行产品找到错误答案。所以把题目换成两件必答工作:为整个包建「读者能观察到的行为 → 谁守住它」映射;在产品本身上做至少一次行为实验。

四、搜索失败后自动重试成功,文案还停在失败上。 清 state.searchError 的只有键盘输入与清除按钮 —— 两处都是读者发起的。轮询在转录变化后自动重跑当前查询,那不是读者,于是没有任何路径能清掉它。命中集与条数都更新了,而 searchStatusLabel 先读 error,过期的 error 压过了它正在描述的新命中集。

现有检查对这个形状是同一个盲区:searchStatusLabel 的每个用例都是为那一个用例现造一个 state,而 state 不会过期;runSearch 那几条是源码正则。现在这个转换是被跑出来的 —— 从 index.html 里切出 runSearch、searchHitSet、searchStatusLabel、formatInt、sessionParam 的真源码求值,先要一次失败的 fetch 再要一次成功的,然后读页面自己的 state。撤销修法时它报出的是评审手工记录的那两句。client-check.mjs 212 → 217,mutate-search-client.py 12 → 13。

五、/api/runtime 把异常栈原文交给了响应。 映射表里唯一返回「没有」的一行,而那条路由的全部契约就是「不发送什么」——注释里写着「this route must never hand the browser a filesystem location」。响应里有三个字段不是 basename、不是计数、不是布尔,而是异常文本的子串;diagError 是 600 字符的一段栈,而 Node 的栈帧逐帧写出所在文件的绝对路径。它恰好在这条路由存在的理由(报告失败)的路径上触发。

改成两遍再截断:redact 知道三个 store 位置但只认它被交给的那种拼写,scrubAbsolutePaths 与拼写无关。第二遍不是冗余的 —— 栈帧是 file:///C:/… 的 URL 形式,反斜杠前缀一条都匹配不上,而 node 安装目录、临时目录、依赖目录全都不是 store 目录。

它故意没有折进 createRedactor:那个也跑在会话正文上,在那里抹掉无关字符串会改变读者看到的东西。新守卫 verify-runtime-diag.mjs 25 条,其中行为部分是拿真的 scrubAbsolutePaths 处理真的栈(在临时家目录里写一个会抛的模块,捕获它真实的栈,以及 store 之外另抛一次);形状部分是三条逐行核对「都过两遍、且截断在最后」,因为 handler 是 start() 里的闭包而本套件不启动 runtime。mutate-runtime-diag.py 6 条全部按名捕获。

顺带量出一件与预期相反的事并写进了注释:这个 runtime 上 SQLite 的报错根本不带路径(全文就是 unable to open database file),所以那两个字段是作为通道守着的,不是作为泄漏。

验证

整套件 92/92(44 verify + 46 mutate + 2 checks)
全新克隆首跑 92/92(就是第二条那个场景)
npm run check 55/55
npm run validate 本插件 OK

断言与变异的逐项数字在上面的描述里,mutate-*- 全部按名捕获 —— 崩溃不命名规则,本项目把它算成比红断言更弱的结果。

一并认下的两个过程错误

改这批时我犯了两次自己写在工程约定里的错,都当成了真读数查证过才下结论:一次是套件在跑时又启动了会就地改写文件的变异控制,撞出假象;一次是为止损硬杀套件,留下了一个变异态的 fixture,从干净副本逐字节复位后才重跑。

仍未覆盖

  • 基线 JSON 畸形、多条残渣同时出现、只读临时目录 —— 守卫覆盖面就到这里
  • 并发跑整套件(本轮只测了 6 个守卫并发)
  • 剪贴板与宿主交接、纯键盘导航、浏览器真实指针几何 —— 未端到端跑

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢这么快的更新!我在 fd66f11 上逐项复测了上次的问题:

  • 总览条命中:同一个 12000 条记录的会话,带子上取 32 个点悬停,提示时间都随位置单调递增;再点 15 个点,选中的记录也按位置递增,没有再跳到首条或末条。
  • 全新克隆(没有 .shipped-snapshot.json)跑 node tests/run.mjs 是 92/92,单独跑 mutate-review16.py 也是 47/47。
  • 根目录的 plugin.json 已删除;.minimax-plugin/plugin.json 和两份 README 都是 1.2.0。
  • 缺 parent_session_id 列的旧库上,标题和 kind 都恢复正常了;PR 描述也更新了。
  • 另外两处也复测了:搜索失败后,轮询自动重跑成功会把文案换成「命中 1 条」;/api/runtime 在库损坏、缺表时只返回 SQLite 自身的短消息,不带路径。
  • Node 22 下 npm run check 55/55,和当前 main 试合并没有冲突。

需要修改
无。

建议(可选)

  • scrubAbsolutePaths 的 POSIX 规则只覆盖几种常见的根目录前缀,像 /root/...、/mnt/...、/srv/... 这类不带 file:// 的路径,以及 UNC 路径,会原样保留。目前我没找到会实际触发的路径(栈帧都是 file:// 形式),只是提一下,后面如果这里会出现其他来源的消息,可以考虑放宽。

辛苦了,这一版没问题,我这边 approve。

@MyPrototypeWhat
MyPrototypeWhat merged commit da780da into MiniMax-AI:main Oct 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants