Просмотр исходного кода

Merge branch 'master' into feat/session-turn-stats-display

Yifffan 4 недель назад
Родитель
Сommit
8f4fcdd792

+ 6 - 0
.agents/notes/implemented/simplification/2026-08-28-windows-only-absent-probe-repair.i18n.yaml

@@ -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/simplification/2026-08-28-windows-only-absent-probe-repair.md
+2026-08-28-windows-only-absent-probe-repair.md: 55fa7bee8d6e16eb96223fa9745f95891cfdf89c
+2026-08-28-windows-only-absent-probe-repair.zh.md: 5a459cab1f3d914934945465be1d1f08e0c4530b

+ 40 - 0
.agents/notes/implemented/simplification/2026-08-28-windows-only-absent-probe-repair.md

@@ -0,0 +1,40 @@
+# Agent Note: The absent-probe parent repair runs on Windows only
+
+Status: implemented
+
+English | [中文](2026-08-28-windows-only-absent-probe-repair.zh.md)
+
+## Problem
+
+`JsonlSessionPersistence.exists` treats ENOENT as absence, and before returning false it stats the path's parent so a session directory blocked by a regular file surfaces as a storage fault rather than as a missing session. Windows needs that: it reports ENOENT, not ENOTDIR, for `regular-file/child`. POSIX does not — `open(2)` specifies ENOTDIR when a component of the path prefix is not a directory, which the repair's own guard comment already recorded.
+
+The stat ran on every platform and on every absent probe. `findLog` issues four probes per project directory — two rejecting the legacy flat-file layout, one for the opposite encoding, one for the log itself — and the coordinator resolves an id twice per `inspect`: once in `prepareCore`, once in `isPreparedSourceCurrent`, which runs even when the prepared source is cached. All but one directory answers absent, so nearly every probe paid the extra stat.
+
+## Decision
+
+The repair is reached only under `process.platform === 'win32'`, matching the platform dispatch `materialize` already uses. POSIX keeps the ENOTDIR that `open` itself reports.
+
+## Testing
+
+Counting `node:fs/promises` calls through the suite's existing module mock, against a store with five project directories — the layout of a real `~/.dsh/sessions`:
+
+| Operation | Before | After |
+|---|---|---|
+| `load` an existing session | 40 open + 41 stat | 40 open + 3 stat |
+| `load` an absent id | 20 open + 20 stat | 20 open + 0 stat |
+
+The package's 242 tests pass unchanged and per-file coverage is identical; the new branch carries the same `v8 ignore` marker as the sibling platform dispatch.
+
+That POSIX never reaches the repair was confirmed directly rather than taken from the comment: opening `regular-file/child` and `regular-file/child/deeper` reports ENOTDIR on macOS, while only a genuinely missing directory reports ENOENT.
+
+## Alternatives considered
+
+**Keep the stat on every platform as defense in depth.** Rejected: on POSIX it can only confirm what `open` already reported, so it detects no fault the caller would otherwise miss — it doubles the syscalls of every absent probe to re-derive a known answer.
+
+**Remove the repair outright and let Windows report absence.** Rejected: it exists so a session directory blocked by a regular file stays a storage fault instead of reading as a missing session, which is the fail-loud stance the backend takes elsewhere.
+
+## Consequences
+
+Path resolution keeps its full cost in `open` calls: four per project directory per lookup, twice per `inspect`. This removes the stat half only, and the remaining cost still grows with the number of project directories rather than with the number of sessions.
+
+Collapsing the scan needs an id-to-path index, which first requires deciding what happens to the per-lookup legacy-artifact guard that `rejects a compressed obsolete flat-file artifact during targeted lookup` pins: that test writes its artifact after `list()` has memoized the root encoding check, so the guard covers store mutation after memoization, and an index hit would return before reaching it. One `readdir` per project directory, which would supply both the id-to-path map and that guard without a cache, measured no faster than this change, so any further win requires memoization and its invalidation.

+ 40 - 0
.agents/notes/implemented/simplification/2026-08-28-windows-only-absent-probe-repair.zh.md

@@ -0,0 +1,40 @@
+# Agent Note: 缺失探测的父目录修复只在 Windows 上执行
+
+Status: implemented
+
+[English](2026-08-28-windows-only-absent-probe-repair.md) | 中文
+
+## Problem
+
+`JsonlSessionPersistence.exists` 把 ENOENT 视为「不存在」,并在返回 false 之前 stat 一次路径的父目录,好让「被普通文件挡住的会话目录」暴露成存储故障,而不是一个不存在的会话。Windows 需要这一步:它对 `regular-file/child` 报的是 ENOENT 而不是 ENOTDIR。POSIX 不需要——`open(2)` 规定路径前缀中有非目录组件时报 ENOTDIR,这一点该修复自己的守卫注释早已写明。
+
+而这次 stat 在所有平台、每一次缺失探测上都会执行。`findLog` 对每个 project 目录发四次探测——两次拒绝 legacy 扁平文件布局、一次查相反编码、一次查日志本身——而 coordinator 每次 `inspect` 要解析两遍 id:一遍在 `prepareCore`,一遍在 `isPreparedSourceCurrent`,后者即使 prepared source 已缓存也照跑。除拥有该会话的那个目录外,其余全部回答「不存在」,所以几乎每次探测都付了这次多余的 stat。
+
+## Decision
+
+该修复现在只在 `process.platform === 'win32'` 下可达,与 `materialize` 既有的平台分派写法一致。POSIX 保留 `open` 自己报出的 ENOTDIR。
+
+## Testing
+
+借用测试套件既有的模块 mock 统计 `node:fs/promises` 调用,对照一个五 project 目录的存储——即真实 `~/.dsh/sessions` 的布局:
+
+| 操作 | 改动前 | 改动后 |
+|---|---|---|
+| `load` 一个已存在的会话 | 40 open + 41 stat | 40 open + 3 stat |
+| `load` 一个不存在的 id | 20 open + 20 stat | 20 open + 0 stat |
+
+该包的 242 个测试原样通过,单文件覆盖率不变;新增分支带的 `v8 ignore` 标记与相邻的平台分派一致。
+
+「POSIX 永远到不了这处修复」是直接验证的,而非采信注释:在 macOS 上打开 `regular-file/child` 与 `regular-file/child/deeper` 都报 ENOTDIR,只有真正缺失的目录才报 ENOENT。
+
+## Alternatives considered
+
+**所有平台都保留这次 stat,作为纵深防御。** 否决:在 POSIX 上它只能确认 `open` 已经报出的结论,检测不到任何调用方本会漏掉的故障——代价是把每次缺失探测的系统调用翻倍,只为重新推导一个已知答案。
+
+**直接删掉该修复,让 Windows 报「不存在」。** 否决:它的存在正是为了让「被普通文件挡住的会话目录」保持为存储故障,而不是读成一个不存在的会话,这与该 backend 别处一贯的 fail-loud 立场一致。
+
+## Consequences
+
+路径解析在 `open` 调用上的开销原样保留:每次查找每个 project 目录四次,每次 `inspect` 两轮。本次只削掉了 stat 那一半,剩余开销仍随 project 目录数增长,而不随会话数增长。
+
+要压掉这轮扫描需要一份 id→path 索引,而它首先要求决定:`rejects a compressed obsolete flat-file artifact during targeted lookup` 钉住的那条「每次查找都做的 legacy 产物守卫」该如何安置。该测试是在 `list()` 已经记忆化 root 编码检查之后才写入产物的,所以这条守卫覆盖的是记忆化之后的存储变动,而索引命中会在到达它之前就返回。「每个 project 目录一次 `readdir`」本可以同时提供 id→path 映射和这条守卫且无需缓存,但实测并不比本次改动更快,所以更进一步的收益必然要引入记忆化及其失效处理。

+ 9 - 1
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -54,7 +54,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
     return () => { document.removeEventListener('keydown', onKeyDown) }
   }, [onClose])
 
-  // Baseline focus management: entering the dialog lands on the close button.
+  // Entering the dialog focuses the close button; the root restores its trigger on close.
   const closeButton = useRef<HTMLButtonElement | null>(null)
   useEffect(() => { closeButton.current?.focus() }, [])
 
@@ -106,10 +106,17 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   const [open, setOpen] = useState(false)
   const [activeId, setActiveId] = useState<string | undefined>(undefined)
   const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
+  const triggerButton = useRef<HTMLButtonElement | null>(null)
+  const wasOpen = useRef(open)
   const close = useCallback(() => {
     setOpen(false)
     setActiveId(undefined)
   }, [])
+  // Restore after the close commit, when the dialog can no longer own focus.
+  useEffect(() => {
+    if (wasOpen.current && !open) triggerButton.current?.focus()
+    wasOpen.current = open
+  }, [open])
   const openSection = useCallback((id: string) => {
     setActiveId(id)
     setOpen(true)
@@ -142,6 +149,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   return (
     <>
       <button
+        ref={triggerButton}
         type="button"
         className={clsx(css.trigger, !wide && css.rail)}
         aria-haspopup="dialog"

+ 13 - 7
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -81,7 +81,10 @@ function mount({
 }
 
 function openPanel() {
-  fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
+  const trigger = screen.getByRole('button', { name: 'Settings' })
+  trigger.focus()
+  fireEvent.click(trigger)
+  return trigger
 }
 
 describe('SettingsRoot trigger', () => {
@@ -131,26 +134,29 @@ describe('SettingsPanel chrome seats', () => {
 })
 
 describe('SettingsPanel close paths', () => {
-  it('closes via the header button', () => {
+  it('closes via the header button and restores trigger focus', async () => {
     mount()
-    openPanel()
+    const trigger = openPanel()
     fireEvent.click(screen.getByRole('button', { name: 'Close' }))
     expect(screen.queryByRole('dialog')).toBeNull()
+    await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
   })
 
-  it('closes via a mask click', () => {
+  it('closes via a mask click and restores trigger focus', async () => {
     mount()
-    openPanel()
+    const trigger = openPanel()
     const dialog = screen.getByRole('dialog')
     fireEvent.click(dialog.parentElement!.firstElementChild!)
     expect(screen.queryByRole('dialog')).toBeNull()
+    await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
   })
 
-  it('closes via document-level Escape and unhooks the listener with the panel', () => {
+  it('closes via document-level Escape, restores trigger focus, and unhooks the listener', async () => {
     mount()
-    openPanel()
+    const trigger = openPanel()
     fireEvent.keyDown(document, { key: 'Escape' })
     expect(screen.queryByRole('dialog')).toBeNull()
+    await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
     // Ignored while closed (listener removed with the panel) and non-Escape
     // keys are ignored while open.
     fireEvent.keyDown(document, { key: 'Escape' })

+ 6 - 3
packages/session/session-persistence-jsonl/src/index.ts

@@ -953,11 +953,14 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     } catch (error) {
       // Only ENOENT means absent. A permission/I/O error must surface rather
       // than letting load or collision checks proceed under false absence.
-      // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
-      // the immediate parent so a blocked session directory remains a storage fault.
       /* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
       if (isENOENT(error)) {
-        await this.assertLogParentAllowsAbsence(path)
+        // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`, so it
+        // alone verifies the immediate parent to keep a blocked session
+        // directory a storage fault. POSIX open already reported ENOTDIR before
+        // this point, where the extra stat would only cost a syscall per probe.
+        /* v8 ignore next -- native Windows coverage exercises this platform dispatch; POSIX reports ENOTDIR from open */
+        if (process.platform === 'win32') await this.assertLogParentAllowsAbsence(path)
         return false
       }
       /* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */

+ 6 - 4
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -442,15 +442,17 @@ describe('SessionProjectionCache cold-read seeding', () => {
     // Host-only unit: folded but not served; the refreshed row is written
     // back (fail-soft, fire-and-forget) once the write lands.
     expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
-    await settle()
-    expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
+    await vi.waitFor(async () => {
+      expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
+    })
     // No cached row yet: the first cold read folds from init over the full
     // log and creates the cache row (the `?? {}` seed path).
     const fresh = headerOf(SessionId('cold-fresh'), 10)
     cache.coldSnapshot(fresh, events)
     expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
-    await settle()
-    expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
+    await vi.waitFor(async () => {
+      expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
+    })
   })
 
   it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {