Merge commit '4ebc88329d1da067edad6032d40688e52e401bf5' into codex/product-subagent-one-shot-background

This commit is contained in:
pku-xht
2026-08-13 16:05:35 +08:00
121 changed files with 1947 additions and 409 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md
2026-08-12-collapsed-sidebar-shared-entry-motion.md: c5bc18973db693cf9ba60800fbcf7720593dbad5
2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md: b87ef6322d548061cc2e29c0e8f9896098c09041

View File

@@ -0,0 +1,33 @@
# Agent Note: Collapsed sidebar upper controls share one entry motion
Status: implemented
Archived: 2026-08-12
English | [中文](2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md)
## Problem
The collapsed sidebar rail renders four upper controls owned by two packages: the shell owns the toggle and New Session, while the workspace region owns add and search. Their opacity timing matched, but their geometry did not. Right-aligned controls moved with the narrowing column while left-aligned controls stayed fixed, so add appeared slower than search even under the same fade.
The bottom settings control has a different role. It is pinned to the rail foot and must not join the upper controls' horizontal entry.
## Decision
At the rail settle point, the four upper 36px controls start from one left-anchored layout and share one `150ms` animation from `translateX(49px)` to their final 10px inset. The shell applies the translation to its toggle and New Session seats and once to the workspace region, so add and search inherit the same path without nested transforms. Opacity uses the same animation timeline.
The settings seat uses a separate opacity-only keyframe with the same duration and easing. A page that starts collapsed renders the rail without an entry animation, and reduced-motion mode disables both keyframes.
## Alternatives considered
**Keep every rail control fixed at its final inset.** This removes the mismatch, but it also removes the requested horizontal entry from the four upper controls.
**Animate each workspace button independently.** This would duplicate shell timing inside `ui-workspace` and could apply both a region and child transform. Translating the registered region once keeps animation ownership in the sidebar shell.
**Translate the settings control with the upper controls.** Rejected because settings is a bottom-pinned foot action, not part of the upper control sequence.
## Consequences
- Toggle, New Session, add, and search follow the same horizontal coordinates throughout collapse.
- Settings fades at its final horizontal coordinate.
- Static collapsed renders retain their final geometry without startup motion.
- Style tests pin the shared animation assignments, translation distance, base anchors, and settings exception.

View File

@@ -0,0 +1,33 @@
# Agent Note: 收起侧栏的上方控件共用同一进入动画
Status: implemented
Archived: 2026-08-12
[English](2026-08-12-collapsed-sidebar-shared-entry-motion.md) | 中文
## Problem
收起侧栏轨道的四个上方控件由两个包渲染外壳持有侧栏切换与新建会话Workspace 区域持有添加和搜索。它们的透明度时序相同,但几何行为不同。右对齐控件会随栏变窄而移动,左对齐控件则保持不动,因此添加即使使用相同淡入,视觉上仍比搜索慢。
底部设置控件承担不同角色。它固定在轨道页脚,不能参与上方控件的横向进入。
## Decision
轨道落位时,四个 36px 上方控件从同一个左对齐布局开始,共用一段 `150ms` 动画,从 `translateX(49px)` 移动到最终 10px 内边距。外壳把位移分别应用于侧栏切换、新建会话,并只对 Workspace 区域应用一次,因此添加与搜索会继承同一路径,不产生嵌套变换。透明度使用同一条动画时间线。
设置控件使用时长与缓动相同、但只改变透明度的独立关键帧。页面初始即为收起状态时不会播放进入动画;减少动态效果模式会禁用两段关键帧。
## Alternatives considered
**把每个轨道控件固定在最终内边距。** 这能消除不一致,但也会移除四个上方控件所需的横向进入效果。
**分别为每个 Workspace 按钮添加动画。** 这会在 `ui-workspace` 中重复外壳时序,还可能同时应用区域与子控件变换。只移动一次已注册区域,可以让动画继续由侧栏外壳持有。
**让设置控件随上方控件一起移动。** 不予采纳,因为设置是固定在底部的页脚操作,不属于上方控件序列。
## Consequences
- 侧栏切换、新建会话、添加与搜索在整个收起过程中使用相同横坐标。
- 设置在最终横坐标上淡入。
- 静态收起渲染保持最终几何,不播放启动动画。
- 样式测试固定共用动画分配、位移距离、基础锚点与设置例外。

View File

@@ -97,6 +97,9 @@
"bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3",
"bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6",
"bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723",
"bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034
2026-08-04-composer-tab-gutter-reservation.zh.md: c357dd06c52a834c18d2e8a25246d4ad003db548
2026-08-04-composer-tab-gutter-reservation.md: 8bd9fb2d86982d82b44a82c55b7303fcd9a5bf4d
2026-08-04-composer-tab-gutter-reservation.zh.md: 4b70aeb1d3777384907c345968971fcf75b3e74d

View File

@@ -14,13 +14,11 @@ So for as long as the transcript overflowed — the ordinary state of any sessio
## Decision
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`.
`.scrollBody` declares `scrollbar-gutter: stable` for the Chat state, and the overlay branch overrides it with `scrollbar-gutter: auto` while staying a scroll container on both axes — `overflow-x: hidden; overflow-y: auto`. The reservation is Chat's alone: it holds the seat's content box at the same width whether or not the transcript overflows, so the card never jumps as a growing transcript starts to scroll, nor between the hero phase and the first scrolling turn. The overlay branch reserves nothing — the view owns its own scrollers, so a gutter there would only narrow the view's content — and its seat compensates for the bar instead ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it.
`stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between Chat's two phases — an `auto` gutter would state the bug rather than fix it.
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
@@ -37,7 +35,7 @@ The reservation is worth what it costs only because the bar takes layout space h
## Consequences
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
- The card holds one position across three transitions, by two mechanisms: the reservation keeps Chat's seat at one width across its own phases (short ↔ scrolling transcript, hero ↔ first scrolling turn), and the overlay seat's compensation matches it on the Chat ↔ Trajectory transition ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
@@ -45,6 +43,6 @@ The reservation is worth what it costs only because the bar takes layout space h
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, so the tabs agree with and without the compensation and every comparison in the file holds vacuously. Measured, both bands sit at 0 under the argument and at 8 and 0 with it dropped.
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
The uncompensated cascade is then applied in the page — the overlay seat's `right` compensation dropped to 0 via `!important`, Chat's reservation untouched — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.

View File

@@ -14,13 +14,11 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟
## 决策
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`
`.scrollBody` 为 Chat 状态声明 `scrollbar-gutter: stable`覆盖分支则将其覆盖为 `scrollbar-gutter: auto`,同时保持为双轴滚动容器——`overflow-x: hidden; overflow-y: auto`。这条预留只属于 Chat它让座位的内容盒在 transcript 是否溢出时都保持同一宽度,因此卡片不会在 transcript 增长到开始滚动的那一刻跳动,也不会在 hero 态与第一个可滚动轮次之间跳动。覆盖分支不预留任何槽位——视图自己滚动,槽位只会白白收窄视图内容——它的座位改为补偿滚动条宽度([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md)
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。`stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
`stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是 Chat 两种相位之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。
这条预留之所以值回它的代价前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为而是本客户端的选择ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
@@ -37,7 +35,7 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
## 后果
- Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
- 一条声明覆盖三种切换因为这三者本就是同一个差异Chat ↔ Trajectory、Chat 内部 transcript 较短 ↔ transcript 可滚动,以及 hero ↔ 第一个可滚动轮次
- 卡片在三种切换下保持同一位置,由两种机制达成:预留让 Chat 的座位在自身各相位间保持同一宽度transcript 较短 ↔ 可滚动、hero ↔ 第一个可滚动轮次Chat ↔ Trajectory 的切换则由覆盖座位的补偿来对齐([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md)
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
- 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
@@ -45,6 +43,6 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形分别取卡片处于宽度上限的视口与卡片随列收缩的视口并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两条预留带的宽度都是 0去掉它则分别是 8 与 0。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,因此两个标签页在有补偿与无补偿时同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时两条预留带的宽度都是 0去掉它则分别是 8 与 0。
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture测试前置数据承载的是这次改动所消除的那个差值而不仅仅是它的缺席。
随后,未补偿的级联会被注入页面——通过 `!important` 把覆盖座位的 `right` 补偿降为 0Chat 的预留保持不变——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture测试前置数据承载的是这次改动所消除的那个差值而不仅仅是它的缺席。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md
2026-08-12-composer-overlay-seat-width-compensation.md: 0ec4d1272ac1adab5b724dccf44f567e15cc3368
2026-08-12-composer-overlay-seat-width-compensation.zh.md: 2f66771240fe62e15c481342d55a82dd09cfb864

View File

@@ -0,0 +1,39 @@
# Agent Note: The overlay composer seat compensates for the bar instead of reserving a gutter
Status: implemented
English | [中文](2026-08-12-composer-overlay-seat-width-compensation.zh.md)
## Problem
The [composer-tab gutter reservation](2026-08-04-composer-tab-gutter-reservation.md) made the column's scroller reserve a scrollbar gutter unconditionally, so the composer seat measured the same width in Chat and in a view with a composer overlay. The cost was paid by every overlay view: the view's content column ended 8px short of the column's right edge, because the scroller reserved a gutter for a bar it never draws — the trajectory ledger owns its own scrollers and the outer box never scrolls.
The trajectory table made that cost visible: its full-width row divider lines stopped 8px short of the pane edge, leaving a strip of whitespace at the right of every line and of the whole content column.
## Decision
The reservation now belongs to Chat alone. The overlay branch declares `scrollbar-gutter: auto`, so the view's content spans the full column; the overlay composer seat (absolutely positioned against the padding box) gives back the bar's width with `right: var(--dsh-scrollbar-width)`, so the input card still measures the same width as Chat's seat and does not move between tabs.
The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh-scrollbar-width` (8px on the WebKit path) beside the `::-webkit-scrollbar` rule it mirrors, and the seat reads that variable. The scrollbar-styles spec pairs the variable with the mirrored rule and with the compensation consumer, so a change to the sheet's bar width without the variable — or to the variable without the consumer — fails the gate, not just review.
## Alternatives considered
**Keep the unconditional reservation and shrink every overlay view.** The pre-fix behavior. It keeps one declaration for both tabs but taxes every overlay view with an 8px content column, which the trajectory ledger surfaced as visible whitespace. Rejected because the overlay views own their scrolling; they should not pay for Chat's bar.
**Reserve on the overlay branch too and let the view bleed into the gutter.** More moving parts for the same result: the gutter would still exist on a box that never scrolls, and the view would have to break out of the content box to reclaim its width.
**Accept the 4px card shift.** Dropping the reservation without compensating the seat would move the input card on every tab switch, which is exactly the symptom the earlier note fixed. Rejected: the card position is a deliberate cross-tab invariant.
**Inset the overlay seat by the bar's width.** The [gutter-reservation note](2026-08-04-composer-tab-gutter-reservation.md) rejected exactly this, and this note adopts it; what changed is the rejection's premise. The number was the engine's, not ours — the WebKit path draws the sheet's 8px bar while the Firefox path draws whatever `scrollbar-width: thin` resolves to — so a hardcoded inset would line the two states up in Chromium and drift elsewhere. The overlay branch reserved an engine-resolved gutter of its own back then, so an inset had to match that width exactly. Today the overlay branch reserves nothing, so the compensation is the overlay side's only mechanism, and the literal half of the rejection is answered by making the 8px a variable that mirrors the `::-webkit-scrollbar` rule in the same diff. The Firefox half remains: Chat reserves the engine-resolved width while the compensation stays fixed, and the residual drift where the two differ is recorded as an accepted cost in Consequences.
## Consequences
- Chat keeps its reserved gutter and its stable card position; nothing changes on that tab.
- Overlay views (trajectory) span the full column; the trajectory ledger's divider lines reach the pane edge.
- The input card still holds one horizontal position across the Chat and Trajectory tabs, now by two mechanisms instead of one: Chat reserves, the overlay seat compensates.
- Chat reserves the engine-resolved width while the overlay seat compensates a fixed 8px. Where the two differ — the Firefox path resolves `scrollbar-width: thin` per platform, and the e2e runs only on Chromium — the card drifts by half the difference on tab switch. Accepted residual cost, recorded here rather than asserted away: no measurement of the Firefox thin width on the target platforms exists in this change.
- `--dsh-scrollbar-width` becomes a public ui-theme variable read outside ui-theme; the scrollbar-styles spec pairs it with the mirrored `::-webkit-scrollbar` width rule and with the compensation consumer, closing the indirection-gate gap the variable would otherwise leave.
## Testing
`apps/web/tests/composer-tab-geometry.e2e.ts` still asserts the card holds its position across tabs and now also asserts the split: Chat's scroller keeps `scrollbar-gutter: stable` and a nonzero band, while the overlay branch resolves `auto` with a zero band. The control cascade changed with the mechanism: it now drops the seat's `right` compensation (instead of dropping a gutter Chat never had on that branch) and measures the same 4px shift, proving the equal rectangles are not a tab switch that never reached layout. The committed golden records both states.

View File

@@ -0,0 +1,39 @@
# Agent Note: 覆盖视图的 composer 座位改为补偿滚动条宽度,不再预留滚动条槽
Status: implemented
[English](2026-08-12-composer-overlay-seat-width-compensation.md) | 中文
## 问题
[composer 标签页滚动条槽预留](2026-08-04-composer-tab-gutter-reservation.md) 让会话列滚动容器无条件预留一条滚动条槽,使 composer 座位在 Chat 与带 composer 覆盖的视图中测得相同宽度。代价由每个覆盖视图承担:视图内容列比列右边缘窄 8px因为滚动容器为一条它从不绘制的滚动条预留了槽——trajectory 台账由视图内部自己的滚动容器滚动,外层盒子从不滚动。
trajectory 表格让这个代价显形:整行分隔线在面板右边缘前 8px 处停止,每条线右侧以及整个内容列右侧都留下一条空白带。
## 决策
预留现在只属于 Chat。覆盖分支声明 `scrollbar-gutter: auto`,视图内容占满整列;覆盖分支的 composer 座位(相对 padding box 绝对定位)用 `right: var(--dsh-scrollbar-width)` 让出滚动条宽度,使输入卡仍与 Chat 座位测得相同宽度,切换标签页时不移动。
补偿值不是字面量ui-theme 的 scrollbar.css 在它镜像的 `::-webkit-scrollbar` 规则旁定义 `--dsh-scrollbar-width`WebKit 路径 8px座位读取该变量。scrollbar-styles 规格把该变量与其镜像规则、以及补偿消费者配对检查,因此样式表滚动条宽度一变却不同步变量——或变量一变却不同步消费者——都会让门禁失败,而不只是评审时发现。
## 备选方案
**保留无条件预留,压缩每个覆盖视图。** 修复前行为。两个标签页一条声明,但每个覆盖视图都要付出 8px 内容列trajectory 台账将其显现为可见空白。已拒绝:覆盖视图自己滚动,不应为 Chat 的滚动条买单。
**覆盖分支也预留,并让视图渗入滚动条槽。** 同样结果下更多活动部件:从不滚动的盒子上仍存在滚动条槽,视图还得突破内容盒才能取回宽度。
**接受 4px 卡片位移。** 去掉预留却不补偿座位,会在每次切换标签页时移动输入卡——正是前一份 note 修复的症状。已拒绝:卡片位置是刻意保持的跨标签页不变量。
**把 overlay 座位按滚动条宽度内缩。** [滚动条槽预留 note](2026-08-04-composer-tab-gutter-reservation.md) 当初否决的正是这个方案,本 note 采纳了它变的是否决的前提。这个数字属于引擎而不属于我们——WebKit 路径绘制样式表里的 8px 滚动条Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度——因此硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。当初 overlay 分支自己预留的是引擎解析出的槽宽,内缩必须精确匹配那个宽度。如今 overlay 分支不预留任何槽位,补偿成为覆盖侧唯一的机制;否决的字面量那一半,通过把 8px 变成与 `::-webkit-scrollbar` 规则同处一个 diff 的变量来回应。Firefox 那一半仍然存在Chat 预留引擎解析宽度,补偿保持固定 8px两者不等之处的残余漂移作为接受的代价记录在后果中。
## 后果
- Chat 保留滚动条槽与稳定的卡片位置;该标签页无任何变化。
- 覆盖视图trajectory占满整列trajectory 台账的分隔线到达面板右边缘。
- 输入卡在 Chat 与 Trajectory 标签页间仍保持同一水平位置现在由两种机制而非一种达成Chat 预留,覆盖座位补偿。
- Chat 预留引擎解析宽度,覆盖座位补偿固定的 8px。两者不等之处——Firefox 路径按平台解析 `scrollbar-width: thin`,而 e2e 只在 Chromium 上运行——卡片在切换标签页时会漂移半个差值。这是接受的残余代价,如实记录于此而不断言消除:本次改动并未提供目标平台 Firefox thin 宽度的实测。
- `--dsh-scrollbar-width` 成为 ui-theme 对外、且被 ui-theme 之外读取的变量scrollbar-styles 规格把它与镜像的 `::-webkit-scrollbar` 宽度规则、以及补偿消费者配对检查,补上了该变量本会留下的间接层门禁缺口。
## 测试
`apps/web/tests/composer-tab-geometry.e2e.ts` 仍断言输入卡在标签页间保持位置并新增断言拆分Chat 滚动容器保持 `scrollbar-gutter: stable` 与非零槽宽,覆盖分支解析为 `auto` 且槽宽为零。控制级联随机制改变:现在移除座位的 `right` 补偿(而非移除该分支上 Chat 从未有过的槽),测得同样的 4px 位移,证明相等的矩形并非从未到达布局的标签页切换。提交的 golden 记录两种状态。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md
2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004
2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31

View File

@@ -0,0 +1,23 @@
# Agent Note: Resolve Microsoft Store pwsh aliases
Status: implemented
English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md)
## Problem
`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install.
## Decision
`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1.
## Alternatives considered
**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns.
**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes.
## Consequences
Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform.

View File

@@ -0,0 +1,23 @@
# Agent Note: 解析 Microsoft Store 的 pwsh 别名
Status: implemented
[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文
## 问题
`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias其目标目录的 ACL 拒绝 statEACCES于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。
## 决策
`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。
## 考虑过的替代方案
**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。
**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell这正是本 note 修复的缺陷。
## 后果
Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md
2026-08-12-unlink-fixture-junctions-before-delete.md: 4514a33d728866b817f4e9c1393f16c07976ed45
2026-08-12-unlink-fixture-junctions-before-delete.zh.md: 3c212c052ab0303ccb8d31d2b310a365a1d8cc99

View File

@@ -0,0 +1,23 @@
# Agent Note: Unlink fixture junctions before recursive deletion
Status: implemented
English | [中文](2026-08-12-unlink-fixture-junctions-before-delete.zh.md)
## Problem
The install-lefthook and translation-pairing fixtures junction the repository's real `scripts/`, `node_modules`, and tsx package directories into fixture trees so installer probes resolve through them. Windows recursive deletion can treat a junction (a MOUNT_POINT reparse point) as a directory and follow it into its target; Git's `worktree remove` did exactly that and deleted the repository's tracked `scripts/` and tsx package (the incident's instrumentation pinned the deletion to that step). A fixture cleanup that trusts its deleter therefore deletes the repository's own sources instead of the fixture.
## Decision
`scripts/test-fixture-cleanup.ts` owns junction-safe fixture teardown: `unlinkFixtureLinks` walks a tree and unlinks every reparse point before `removeFixtureSafely` removes the now link-free tree (with Windows async-handle retries). Every affected `afterEach` and the pre-`worktree remove` hook call it. The general rule lives in `docs/defensive-patterns.md`: remove link-shaped paths with unlink, reserve recursive `rmSync` for known real directories.
## Alternatives considered
**Trust recursive deletion alone.** Rejected: whether a given deleter follows junctions is tool- and version-dependent, and one path through `git worktree remove` already destroyed tracked files; no cleanup may bet the repository on that behavior.
**Copy instead of junctioning the real directories.** Rejected: the fixtures exist to probe the real installer paths through their real contents, so copies would stop exercising the boundary under test.
## Consequences
Fixture teardown can no longer reach repository sources through junctions. The extra walk is one lstat/unlink pass over small fixture trees. The data-destroying defect now has its durable why beside the defensive-patterns rule, and the helper is the shared teardown path for future junction fixtures.

View File

@@ -0,0 +1,23 @@
# Agent Note: 递归删除前先解链 fixture junction
Status: implemented
[English](2026-08-12-unlink-fixture-junctions-before-delete.md) | 中文
## 问题
install-lefthook 与 translation-pairing 的 fixture 把仓库真实的 `scripts/``node_modules` 和 tsx 包目录用 junction 链进 fixture 树,让 installer 探测能穿透解析。Windows 的递归删除可能把 junctionMOUNT_POINT 重解析点当作目录并跟随进其目标Git 的 `worktree remove` 正是这样删掉了仓库被跟踪的 `scripts/` 和 tsx 包(事故的插桩把删除定位到这一步)。因此,信任删除器的 fixture 清理删掉的是仓库自己的源码,而不是 fixture。
## 决策
`scripts/test-fixture-cleanup.ts` 拥有 junction 安全的 fixture 拆除:`unlinkFixtureLinks` 先遍历并解链所有重解析点,`removeFixtureSafely` 再删除已无链接的树(带 Windows 异步句柄重试)。所有受影响的 `afterEach``worktree remove` 前的钩子都调用它。通用规则记录在 `docs/defensive-patterns.md`:链接形态的路径用 unlink 删除,递归 `rmSync` 只留给确知为真实目录的路径。
## 考虑过的替代方案
**只信任递归删除。** 否决:特定删除器是否跟随 junction 随工具和版本而异,而 `git worktree remove` 这一条路径已经摧毁过被跟踪文件;任何清理都不该拿仓库去赌这个行为。
**复制而不是 junction 真实目录。** 否决fixture 的意义就是用真实内容探测真实 installer 路径,复制品会失去被测边界。
## 后果
fixture 拆除不再能穿过 junction 触及仓库源码。额外开销只是对小型 fixture 树的一趟 lstat/unlink。这个摧毁数据的缺陷现在在 defensive-patterns 规则旁有了持久化的原因helper 也是未来所有 junction fixture 共享的拆除路径。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md
2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b
2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6

View File

@@ -0,0 +1,25 @@
# Agent Note: Unlink stale profile fallback links instead of rmSync
Status: implemented
English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md)
## Problem
`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call.
## Decision
`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive.
## Alternatives considered
**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard.
**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom.
**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window.
## Consequences
Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation.

View File

@@ -0,0 +1,25 @@
# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync
Status: implemented
[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文
## 问题
`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive``rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。
## 决策
`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。
## 考虑过的替代方案
**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标`recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。
**`rmdirSync(link)`。** Windows 上同样能删 junction但它读起来像“删目录”`unlinkSync` 才是仓库现有的 junction 清理惯例。
**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。
## 后果
Windows 启动现在可以修复迁移后的安装或第二个 checkout而不是以 `ERR_FS_EISDIR` 崩溃POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md
2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e
2026-08-13-bounded-cold-blank-verification.zh.md: 7cfef77a02308a8e75281877f8a774b41bb9559d

View File

@@ -0,0 +1,37 @@
# Agent Note: Bound cold blank-session verification
Status: implemented
English | [中文](2026-08-13-bounded-cold-blank-verification.zh.md)
## Problem
The Web session tree hides blank Sessions and reuses the selected blank entry as New Session. Attached Sessions can derive blankness from their in-memory event log, but `session.list` normally avoids loading every cold log. Treating every materialized cold Session as non-blank exposes empty Sessions left by older versions. Treating a projection-cache `blank: true` as current can instead hide a real conversation after the log advances and the fail-soft cache remains stale.
The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Session appends `session/end-seed`, so a pickup with no human prompt refreshed mtime and promoted that Session above recently used conversations.
## Decision
`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`.
A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible.
`updatedAt` is the later of `createdAt` and `lastPromptAt`. An eligible artifact read supplies exact `lastPromptAt` at no additional I/O cost; other cache misses or stale checkpoints order the Session too old rather than promoting it from an unrelated file write. After each asynchronous cold read, the gateway checks the live store again and replaces the cold result with an attached summary when another request resumed that Session meanwhile.
## Alternatives considered
**Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session.
**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size eligibility check targets small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility. It intentionally does not add a persistence operation solely to make the threshold atomic with the read: concurrent growth may increase one probe's read cost, but the additional events can only preserve visibility or change a blank result to non-blank.
**Store blankness and recency in an authoritative persistence index.** Deferred because JSONL has an immutable first line and would require a second durable artifact with ordered updates, while SQLite would require a schema field. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md).
**Continue ordering JSONL by mtime.** Rejected because mtime records every artifact write, including pickup boundaries, rather than the latest human prompt. Its error direction promotes untouched Sessions to the front.
## Consequences
Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose observed physical size is within the configured threshold when its cache does not already prove non-blank. The default threshold compares compressed bytes for the shipped Zstandard JSONL backend.
Blank artifacts above the threshold and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries for artifacts that are not read fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened.
The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold eligibility, stale-true rejection, monotonic false reuse, exact small-log recency, live-attachment races, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it.

View File

@@ -0,0 +1,37 @@
# Agent Note: 有界验证冷空白会话
Status: implemented
[English](2026-08-13-bounded-cold-blank-verification.md) | 中文
## Problem
Web 会话树会隐藏空白 Session并把当前选中的空白项复用为 New Session。已附加 Session 可以从内存事件日志派生空白状态,但 `session.list` 通常不会加载每一份冷日志。把所有已物化的冷 Session 都视为非空,会暴露旧版本留下的空 Session反过来把 projection cache 中的 `blank: true` 当成当前事实,则可能在日志已经前进而 fail-soft cache 仍然陈旧时隐藏真实对话。
同一份冷列表还曾用 JSONL 工件的 mtime 作为 `updatedAt`。打开 Session 会追加 `session/end-seed`,因此即使没有真人 prompt单纯拾起也会刷新 mtime并把该 Session 提升到最近使用的对话之前。
## Decision
`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank``lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false`lastPromptAt` 只在来源 kind 为 `user``user/message` 上更新。
冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。
`updatedAt``createdAt``lastPromptAt` 中较晚者。符合资格的工件读取无需额外 I/O 即可提供精确 `lastPromptAt`;其他 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。每次异步冷读取后,网关都会再次检查实时 store若另一请求期间已恢复该 Session则用已附加摘要替换冷结果。
## Alternatives considered
**信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。
**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小资格检查只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。该检查有意不为“让阈值与读取原子化”单独新增 persistence 操作:并发增长可能增加一次探测的读取成本,但新增事件只会保持可见,或把空白结果改为非空。
**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变需要增加带有顺序写入要求的第二份持久工件SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)负责。
**继续按 mtime 排序 JSONL。** 拒绝,因为 mtime 记录包括拾起边界在内的每一次工件写入,而非最近真人 prompt其错误方向会把未经操作的 Session 提升到列表开头。
## Consequences
既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且观测物理大小在配置阈值内的每个 Session冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该阈值比较压缩后的字节数。
超过阈值的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。对于未被读取的工件,缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。
网关自有投影是网关 fiber 的 effect卸载网关会移除该 key。单元覆盖固定了临界大小资格、拒绝陈旧 true、复用单调 false、小日志精确最近时间、实时附加竞态、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: 74ed1f8ec6f6efcbf77e9caec7e254cb114efbd9
2026-07-27-trajectory-inspection-ledger.zh.md: be648cb73478037979fd941753129b7c68be5085
2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f
2026-07-27-trajectory-inspection-ledger.zh.md: 34bb8741124c410fe06ec18bdde801caa987f4fb

View File

@@ -21,7 +21,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data.
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending.
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes.
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
- Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.

View File

@@ -21,7 +21,7 @@ Status: implemented
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture测试前置数据有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后Trajectory 内部会打开检查器其标签页和概述区域随实体类型变化Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失
- 移除独立的 waterfall瀑布式事件标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport不会改变该选区。聚焦后Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
- token 流式输出只更新命中的 Trajectory Assistant Context发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage不保留每条原始 chunk payload而 Session 继续保存原始 Event 窗口。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
2026-08-10-npm-release-sequences.md: e8138aef923e201cc0883232f48ce6921452ca00
2026-08-10-npm-release-sequences.zh.md: 7ce9fa01dbe10cbdae5585f64392d0ca6a9fb868
2026-08-10-npm-release-sequences.md: e74a4ac8f2aadd8665ec0db198c6a317a0c201bc
2026-08-10-npm-release-sequences.zh.md: e152163976f945224f2524fccd7f831ba98e8161

View File

@@ -70,6 +70,10 @@ Publication runs only from GitHub Actions; there is no local publication path. P
The third state catches code that changed without a version bump. The first two provide idempotence — re-running publish over one artifact republishes nothing and needs no manual selection of packages. The same rule resolves the tension between one vendor release carrying several tags and a workflow that can only run from one ref: the workflow never infers which packages to publish from the tag it ran from.
All three sequences decide this way, including the native one: it publishes through its own script rather than a shell loop, because a loop of bare `npm publish` calls cannot be retried — the registry answers a repeat of an existing version permanently, so one failure partway through left no way forward.
Two registry behaviours shape how a publish is attempted. Writes are spaced by at least two seconds and retried with a backoff, because publishing several packages back to back outruns the registry's own processing and earns `E409 Failed to save packument`. And every retry re-reads the registry first: a reported failure can answer a write that landed anyway, so a version that now exists with this tarball's integrity counts as published rather than as a version to place again.
### Workspace-internal references use the `workspace:` protocol
Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substitutes a range matching the target version: sibling `peerDependencies` follow the family version, and a reference to a vendored package follows that package's own line. The Landlock platform packages keep `workspace:*`, which publishes the exact version, because a platform package and its entry must agree exactly.

View File

@@ -70,6 +70,10 @@ tag 只是 commit 指针不是发布成功的证明。bump 会向 registry
第三态拦住「改了代码却没 bump 版本」。前两态给出幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。同一条规则还解决了「一次 vendor 发布携带多个 tag而 workflow 只能从一个 ref 触发」的矛盾workflow 从不从触发它的 tag 去推断该发哪些包。
三条序列都按这套判定native 也在内:它通过自己的脚本发布,而不是 shell 循环——一串裸 `npm publish` 无法重试registry 对「重发已存在的版本」的回答是永久失败,因此中途失败一次就没有前路了。
registry 的两个行为决定了「怎么尝试一次发布」。写入之间至少间隔两秒并带退避重试,因为连续背靠背发多个包会超出 registry 自身的处理速度,换来 `E409 Failed to save packument`。而每次重试都先重查 registry报出来的失败可能对应一次其实已经落地的写入所以「该版本现在存在且 integrity 与本 tarball 相同」算作已发布,而不是又一个待放置的版本。
### workspace 内部引用走 `workspace:` 协议
所有指向 workspace 成员的引用都用 `workspace:^`,由 `pnpm pack` 替换成匹配目标版本的范围:兄弟包的 `peerDependencies` 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 `workspace:*`(发布成精确版本),因为平台包与它的入口必须版本完全一致。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
2026-07-29-durable-last-activity-index.md: 0e441f54a719b29a1a450c133e08cdf7d2c82e9e
2026-07-29-durable-last-activity-index.zh.md: ebc2e2167d7743eafc5a4600fe9a0f687349e1a3
2026-07-29-durable-last-activity-index.md: 99e50dd40b789db5d896cb7f9e25fa8893b02ae2
2026-07-29-durable-last-activity-index.zh.md: e317fb192d53353295e6b52f631707ba6b400b66

View File

@@ -6,17 +6,17 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md)
## Problem
A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
A cold (persisted, unattached) session has no authoritative stored answer to "when did the user last prompt here". `dsh-host-apiproxy` serves `updatedAt` from the optional projection cache's `lastPromptAt`, falling back to `createdAt`, and the Web client sorts its Session tree by that value. The cache is fail-soft and checkpointed asynchronously, so a missing or delayed row makes a recently prompted Session sort too old.
mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. `dsh-host-apiproxy` keeps `session.history` inspection-only, but any Agent-bound ordinary-session control resumes through `agentFor()` and is enough to promote the cold artifact.
The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff.
The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read used for metadata verification makes eligible small-artifact recency exact, but it does not make large-log ordering exact.
The [boundary change](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround.
## Proposal
Store last-activity time where a listing already reads — the session index — so `summarizeCold()` can serve it without opening the log. The coordinator computes the value, because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, and keeps one definition of "activity" shared with the in-log `lastActivityTime()`.
Store the latest human-prompt time where a listing already reads — the Session index — so `summarizeCold()` can serve it without opening the log or depending on a cache checkpoint. The coordinator computes the value because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, with the same event predicate as the attached projection: `user/message` whose `source.kind` is `user`.
The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them:
@@ -25,7 +25,7 @@ The two shipped backends have opposite constraints, and the proposal is delibera
Three questions must be answered before implementation, and none of them is settled here:
**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/end-seed`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
**How is the shared predicate owned?** A stored field encodes the rule at write time, where the writer sees one batch, while the attached summary folds a whole log. Both must use one exported event predicate or reducer so new message-source variants cannot make attached and cold ordering disagree.
**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
@@ -39,28 +39,29 @@ Three questions must be answered before implementation, and none of them is sett
**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition.
**Derive activity from a projection cache.** This is the current interim implementation. `session-projection-cache` folds tails past a watermark without changing the persistence format, but it is optional and fail-soft. Its absence or checkpoint delay makes ordering depend on cache availability and freshness, so it cannot provide the authoritative value proposed here.
## Acceptance criteria
- `SessionSummary.updatedAt` for a cold session equals the same value the attached projection reports for that session, verified by resuming, quitting without a turn, and asserting the order is unchanged across both paths.
- A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone.
- The activity rule has one definition: a test proves the stored field and `lastActivityTime()` agree over a log containing boundaries, closers, and a plain turn.
- The prompt-time rule has one definition: a test proves the stored field and attached fold agree over a log containing human prompts, injected user messages, boundaries, and closers.
- Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted.
- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance.
## Risks
**Two definitions of activity drift.** The stored field is computed per batch, the projection over a whole log. A new event type classified one way at write time and the other at read time yields a session whose cold and attached orderings disagree — a bug that only appears after a restart, which is where it is hardest to notice.
**Two definitions of prompt time drift.** The stored field is computed per batch, the projection over a whole log. A new message source classified one way at write time and the other at read time yields a Session whose cold and attached orderings disagree — a bug that only appears after restart.
**A JSONL sidecar can disagree with its log.** A crash between the log append and the sidecar write leaves a stale value with no torn-tail marker to repair it. Every consumer would need to treat the sidecar as a hint, which is close to what mtime already is.
**The fallback reorders existing sessions.** Whichever fallback is chosen, users with existing logs see their picker and tree reorder once on upgrade. `createdAt` makes that reordering large.
**Cost may exceed the defect.** The defect is a misordering of abandoned sessions. If the honest answer for JSONL is "keep the approximation", this note's outcome may be documenting that decision rather than implementing a field — and that is an acceptable outcome.
**Cost may exceed the defect.** The remaining defect is conservative misordering when projection metadata is missing or delayed. If the honest answer for JSONL is "keep the cache fallback", this note's outcome may be documenting that decision rather than implementing a field.
## Related
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to small-artifact metadata verification.
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable.
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.

View File

@@ -6,17 +6,17 @@ Status: proposed
## 问题
一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储答案。因此 `dsh-host-apiproxy` `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反JSONL 读出来偏新SQLite 偏旧。
一个冷会话(已持久化、未附加)对「用户上次是什么时候在这里发出 prompt」没有权威的已存储答案。`dsh-host-apiproxy` 从可选 projection cache 的 `lastPromptAt` 提供 `updatedAt`,缺失时回退到 `createdAt`Web 客户端按该值为 Session 树排序。cache 采用 fail-soft 并异步写入 checkpoint因此缺失或延迟的记录会让最近收到 prompt 的 Session 排得过旧。
mtime 回答的是另一个问题:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡中断轮次的那些合成 closer以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。`dsh-host-apiproxy``session.history` 保持只执行检查,但任何绑定到 Agent 的普通会话控件都会通过 `agentFor()` 恢复会话,足以把冷态产物排到前面
网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍
已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长
已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。用于 metadata 验证的 1 KiB 冷读取可以让符合条件的小产物得到精确的最近时间,但不能让大日志的排序精确
[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围在本文,而不是那里
让冷排序变得精确仍是一项持久格式决策,因此范围在本文,而不是网关 workaround 中
## 提案
把最后活动时间存到列举本就会读取的地方,也就是会话索引,这样 `summarizeCold()` 无需打开日志就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素,而不是各后端本地账目,同时让「活动」只保留一个定义,与日志内的 `lastActivityTime()` 共用
把最新真人 prompt 时间存到列举本就会读取的 Session 索引,这样 `summarizeCold()` 无需打开日志或依赖 cache checkpoint 就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素,而不是各后端本地账目,并与已附加投影使用同一个事件谓词:`source.kind``user``user/message`
两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理:
@@ -25,7 +25,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
实现之前必须回答三个问题,本文对它们都没有定论:
**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案
**共享谓词由谁拥有?** 已存储字段在写入时编码规则,写入方只看到一个批次,而已附加摘要折叠整份日志。两者必须使用同一个导出的事件谓词或 reducer避免新的消息来源变体让已附加排序与冷排序发生分歧
**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
@@ -39,28 +39,29 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合
**从投影缓存派生活动时间。** 这是当前的过渡实现。`session-projection-cache` 会折叠水位线之后的尾部,无需改变持久格式,但它是可选且 fail-soft 的。缺失或 checkpoint 延迟会让排序取决于 cache 是否存在以及是否新鲜,因此无法提供本文所提议的权威值
## 验收标准
- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值;验证方式是恢复、不跑轮次就退出,并断言两条路径上的顺序都没有变化。
- 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。
- 活动规则只有一个定义:一个测试证明,在一份同时包含边界、closer 和一个普通轮次的日志上,已存储字段与 `lastActivityTime()`结果一致。
- prompt 时间规则只有一个定义:一个测试证明,在包含真人 prompt、注入式 user message、边界和 closer 的日志上,已存储字段与已附加折叠结果一致。
- 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。
- 按本仓库不做迁移的立场SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。
## 风险
**「活动」的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新事件类型若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生一个冷排序与已附加排序彼此矛盾的会话;这个缺陷只在重启之后才显现,而那正是最难被注意到的地方
**prompt 时间的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新消息来源若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生冷排序与已附加排序彼此矛盾的 Session缺陷只在重启后显现
**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃,会留下一个陈旧的值,而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待,而这与 mtime 今天的地位已经很接近了。
**回退方案会让既有会话重新排序。** 无论选定哪种回退,持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。
**代价可能超过这个缺陷本身。** 缺陷是被弃置会话的排序出错。如果对 JSONL 来说诚实的答案是「保留这套近似」,那么本文的结局可能是记录下这个决定,而不是实现一个字段,而这也是一个可以接受的结局
**代价可能超过这个缺陷本身。** 剩余缺陷是 projection metadata 缺失或延迟时的保守错序。如果对 JSONL 来说诚实的答案是「保留 cache 回退」,那么本文的结局可能是记录决定,而不是实现一个字段。
## 相关
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影
- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为小产物 metadata 验证
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——让 mtime 不适用的非 prompt 写入之一。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。