From c0edb128746856257f330496cb39c798b0d95ad0 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 18:05:29 +0800 Subject: [PATCH 01/26] fix(locking): use portalocker so the memory-root lock works on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import fcntl` at module scope was the single import-time blocker for running EverOS on Windows. It sits on `core.persistence.__init__`, which every persistence path imports, so even `everos --help` died with ImportError before printing anything. portalocker is already a dependency (the OME single-engine guard uses it). It dispatches to `fcntl.flock` on POSIX and `LockFileEx` on Windows, so POSIX semantics are unchanged: same whole-file advisory lock, same release on process exit. Note it is `flock`, not `lockf` — no POSIX record-lock semantics creep in. `AlreadyLocked` is not an OSError subclass, so the contention branch catches it instead of BlockingIOError. Verified: the existing cross-process contention tests (spawn context, which is what Windows uses) pass, and blocking `fcntl` behind an import hook lets every EverOS layer import cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- src/everos/core/persistence/locking.py | 22 ++++++++++--------- .../core/persistence/markdown/writer.py | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/everos/core/persistence/locking.py b/src/everos/core/persistence/locking.py index 71bb3ef7d..a3ffaca62 100644 --- a/src/everos/core/persistence/locking.py +++ b/src/everos/core/persistence/locking.py @@ -1,7 +1,8 @@ """Process-wide exclusive lock on a memory-root. -Uses ``fcntl.flock`` (POSIX advisory locking, available on Linux + macOS; -Windows is not supported — see project README on platform scope). The +Uses ``portalocker`` for the exclusive lock, which dispatches to +``fcntl.flock`` on POSIX and ``LockFileEx`` on Windows — the same +whole-file, released-on-process-exit semantics on both. The public surface is an :func:`contextlib.asynccontextmanager` so callers use ``async with memory_root_lock(mr):``; the underlying syscalls have no async equivalent so they run in a worker thread via @@ -21,13 +22,14 @@ from __future__ import annotations -import fcntl import os import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager import anyio +import portalocker +from portalocker.exceptions import AlreadyLocked from everos.core.observability.logging import get_logger @@ -50,7 +52,7 @@ alive but wedged inside its critical section, the only case this bounds — giving up sooner does not un-stick it: the error and the operator's next move (inspect the holding process) are the same at 5 minutes or 30. -``flock`` is released by the kernel on process exit, so a *dead* holder +The lock is released by the OS on process exit, so a *dead* holder never needs this. """ @@ -89,7 +91,7 @@ async def memory_root_lock( lock_path = memory_root.lock_file # Open the anchor file (create on first use). The fd, not the path, is - # what fcntl operates on. ``os.open`` is microsecond-fast but offloaded + # what the lock operates on. ``os.open`` is microsecond-fast but offloaded # for consistency with the rest of the lock acquisition flow. fd = await anyio.to_thread.run_sync( lambda: os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) @@ -102,10 +104,10 @@ async def memory_root_lock( while True: try: await anyio.to_thread.run_sync( - fcntl.flock, fd, fcntl.LOCK_EX | fcntl.LOCK_NB + portalocker.lock, fd, portalocker.LOCK_EX | portalocker.LOCK_NB ) break - except BlockingIOError as exc: + except AlreadyLocked as exc: if not blocking: raise LockError( "another process already holds the memory-root lock " @@ -123,8 +125,8 @@ async def memory_root_lock( "timed out after " f"{time.monotonic() - started:.1f}s waiting for the " f"memory-root lock at {lock_path}. The holder is " - "still alive (the kernel releases a dead process's " - "flock automatically) — inspect the process holding " + "still alive (the OS releases a dead process's " + "lock automatically) — inspect the process holding " f"{lock_path} rather than retrying this one" ) from exc await anyio.sleep(_LOCK_POLL_INTERVAL_SECONDS) @@ -146,6 +148,6 @@ async def memory_root_lock( yield finally: try: - await anyio.to_thread.run_sync(fcntl.flock, fd, fcntl.LOCK_UN) + await anyio.to_thread.run_sync(portalocker.unlock, fd) finally: await anyio.to_thread.run_sync(os.close, fd) diff --git a/src/everos/core/persistence/markdown/writer.py b/src/everos/core/persistence/markdown/writer.py index f6af81709..86011b680 100644 --- a/src/everos/core/persistence/markdown/writer.py +++ b/src/everos/core/persistence/markdown/writer.py @@ -31,7 +31,7 @@ Process-level coordination (multi-process writers against the same memory-root) remains the job of :func:`everos.core.persistence.locking.memory_root_lock`, which uses -``fcntl.flock``. The two locks compose: per-path async lock serialises +``portalocker``. The two locks compose: per-path async lock serialises tasks within one process, ``memory_root_lock`` serialises processes against each other. """ From 9d212dbc133a09523236cebae47c532105ac5143 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 18:05:38 +0800 Subject: [PATCH 02/26] ci: run the unit suite on Windows Windows had no CI coverage at all, so "it should work" was the strongest claim available. The job mirrors the Linux unit job on windows-latest. It calls uv directly rather than through make, because the Windows image has no usable make. `uv sync --frozen` resolves there: the lockfile is universal, carrying sys_platform == 'win32' resolution markers and pywin32 for portalocker's Win32 locker. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3462cb1c5..2b24402e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,30 @@ jobs: - name: Unit tests run: make test + unit-windows: + name: unit tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.12 + + # No `make` on the Windows image — call uv directly. The lock is + # universal (it carries sys_platform == 'win32' resolution markers and + # pywin32 for portalocker's Win32 locker), so --frozen resolves here. + - name: Install dependencies (frozen) + run: uv sync --frozen + + - name: Unit tests + run: uv run pytest tests/unit -q + unit-py313: name: unit tests (3.13) runs-on: ubuntu-latest From 5bb064c4149d51941c2b1fe4a9b19279d445e1ee Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 18:05:38 +0800 Subject: [PATCH 03/26] docs: add the Windows / WSL2 install guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the preflight that tells you whether this machine needs a reboot, the two-phase scripted install, and the one failure that is silent: a memory root on /mnt/c gets no filesystem events, so the cascade watcher starts, logs normally, and sees nothing. Also documents why LibreOffice has to be the Linux build inside the distro, and why 0.0.0.0 is not the fix for a localhost that will not connect — EverOS ships no auth of its own, and how far 0.0.0.0 reaches depends on the WSL networking mode. Ships a Chinese mirror alongside, which the CJK language-policy gate allows for `*.zh.md` translated mirrors. Co-Authored-By: Claude Opus 5 (1M context) --- docs/index.md | 2 + docs/windows-wsl2.md | 223 ++++++++++++++++++++++++++++++++++++++++ docs/windows-wsl2.zh.md | 219 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 docs/windows-wsl2.md create mode 100644 docs/windows-wsl2.zh.md diff --git a/docs/index.md b/docs/index.md index 466a2d8f6..b707cca3e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,8 @@ specific thing (drain a queue, recover from a stuck row, etc.). | Doc | Purpose | |---|---| | [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows | +| [windows-wsl2.md](windows-wsl2.md) | Install and run EverOS on Windows via WSL2 | +| [windows-wsl2.zh.md](windows-wsl2.zh.md) | Chinese mirror of the Windows / WSL2 guide | | [github-sync.md](github-sync.md) | Guardrails for refreshing GitHub from internal exports without overwriting GitHub-only workflow files | | [benchmarks/README.md](../benchmarks/README.md) | LoCoMo benchmark — run and evaluate | diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md new file mode 100644 index 000000000..90a92cabe --- /dev/null +++ b/docs/windows-wsl2.md @@ -0,0 +1,223 @@ +# Running EverOS on Windows (WSL2) + +> Also available in Chinese: [windows-wsl2.zh.md](windows-wsl2.zh.md) + +EverOS is developed and CI-tested on Linux and macOS. On Windows the +supported path is **WSL2** — a real Linux kernel, so the storage stack +(`fcntl` locking, LanceDB, inotify) behaves exactly as it does on a Linux +server. This page is the end-to-end install, including the parts that +cannot be scripted and the one failure that is silent. + +## Table of contents + +- [Before you start](#before-you-start) + - [Will this machine need a reboot?](#will-this-machine-need-a-reboot) +- [Install](#install) + - [Phase 1 — WSL2 and the distro](#phase-1--wsl2-and-the-distro) + - [Phase 2 — EverOS inside the distro](#phase-2--everos-inside-the-distro) + - [Scripted install](#scripted-install) +- [Verify](#verify) +- [Where the memory root must live](#where-the-memory-root-must-live) +- [Office document support](#office-document-support) +- [Reaching the API from Windows](#reaching-the-api-from-windows) +- [Troubleshooting](#troubleshooting) +- [Native Windows](#native-windows) + +## Before you start + +| Requirement | Notes | +|---|---| +| Windows 11, or Windows 10 build 19041+ | `winver` to check | +| Administrator rights | Enabling the WSL feature needs elevation | +| Hardware virtualization enabled in firmware | Task Manager → Performance → CPU → "Virtualization: Enabled" | +| ~3 GB disk for the distro, ~1 GB more with LibreOffice | | + +On a managed corporate machine, Hyper-V and the virtual machine platform +are sometimes blocked by policy. That is a hard stop — it needs IT, not a +workaround. + +### Will this machine need a reboot? + +`wsl --install` enables the `VirtualMachinePlatform` Windows feature, and +enabling it requires a restart. But the feature is often **already on** — +Docker Desktop, Hyper-V, Windows Sandbox, the Android emulator and +virtualization-based security all turn it on. Check before you plan around +a reboot: + +```powershell +(Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State +``` + +- `Enabled` → no reboot; the whole install runs in one pass. +- `Disabled` → one reboot, once, on this machine only. + +## Install + +### Phase 1 — WSL2 and the distro + +Run PowerShell **as Administrator**: + +```powershell +wsl --install --no-launch +``` + +`--no-launch` matters: without it, `wsl --install` opens the distro's +first-run wizard and blocks on an interactive "Enter new UNIX username" +prompt, which is what breaks unattended installs. + +Reboot if the preflight above said `Disabled`. Then create the distro +without the interactive account setup: + +```powershell +wsl --install -d Ubuntu --no-launch +ubuntu install --root +``` + +### Phase 2 — EverOS inside the distro + +```bash +wsl -d Ubuntu -u root -- bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq python3-venv curl + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + uv venv --python 3.12 /opt/everos + uv pip install --python /opt/everos/bin/python everos +' +``` + +### Scripted install + +The two phases combine into one idempotent script. Re-running it after the +reboot picks up where it stopped. + +```powershell +# everos-setup.ps1 — run as Administrator +$ErrorActionPreference = 'Stop' + +# Phase 1: WSL platform. +if (-not (Get-Command wsl -ErrorAction SilentlyContinue) -or -not (wsl --version 2>$null)) { + wsl --install --no-launch + Write-Host 'WSL installed. Reboot, then re-run this script.' -ForegroundColor Yellow + exit 0 +} + +# Phase 2: distro. +if (-not (wsl -l -q | Select-String -Quiet 'Ubuntu')) { + wsl --install -d Ubuntu --no-launch + ubuntu install --root +} + +# Phase 3: EverOS. +wsl -d Ubuntu -u root -- bash -lc @' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq python3-venv curl +curl -LsSf https://astral.sh/uv/install.sh | sh +export PATH="$HOME/.local/bin:$PATH" +uv venv --python 3.12 /opt/everos +uv pip install --python /opt/everos/bin/python everos +'@ + +Write-Host 'Done. Start with: wsl -d Ubuntu -- /opt/everos/bin/everos serve' -ForegroundColor Green +``` + +Detect WSL with `wsl --version`, not `wsl --status` — the latter's exit +code is not a reliable "is it installed" signal. + +## Verify + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos --version +wsl -d Ubuntu -- /opt/everos/bin/everos init +wsl -d Ubuntu -- /opt/everos/bin/everos serve +``` + +From there follow the [Quick Start](../README.md#quick-start) — inside the +distro everything behaves as on any Ubuntu host. + +## Where the memory root must live + +> [!IMPORTANT] +> Keep the memory root on the WSL2 filesystem (`/home/...`, `/opt/...`). +> **Never put it under `/mnt/c/`.** + +Filesystem events do not propagate from the Windows host into WSL2. If the +memory root sits on a `/mnt/c` mount, the cascade watcher starts without +error, logs normally, and receives **zero events** — edits to markdown +files never reach the index, and searches silently answer from stale data. +Nothing in the logs says so. + +If you must keep files on the Windows side, fall back to polling: + +| Option | How | +|---|---| +| Scanner sweep (default 30 s) | Already on; bounded but eventually consistent | +| Faster sweep | Drop the scan interval to ~5 s for a small root | +| Explicit sync | `everos cascade sync` after batch edits | + +Details in the [cascade runbook](cascade_runbook.md#wsl2--network-mounts). + +## Office document support + +Install the **Linux** LibreOffice inside the distro — the Windows build +cannot serve a process running in WSL2: + +```bash +wsl -d Ubuntu -u root -- apt-get install -y libreoffice +``` + +The parser resolves the binary with `shutil.which("soffice")`, a plain +PATH lookup inside the distro, so a Windows `soffice.exe` is never found. +Without it, office uploads return `503 CAPABILITY_UNAVAILABLE`; see +[multimodal.md](multimodal.md#libreoffice-office-documents-only). + +## Reaching the API from Windows + +Yes — WSL2 forwards `localhost` by default (`localhostForwarding`), so a +server started inside the distro answers on the same port from Windows. +`everos serve` binds `127.0.0.1` inside the distro; the forwarder relays +to it, so the loopback default does not need changing: + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos serve # listens inside the distro +curl http://localhost:8000/health # from Windows PowerShell +``` + +If that call does not connect, the usual causes are a VPN client taking +over the WSL network, or a wedged forwarder. Restart WSL first: + +```powershell +wsl --shutdown # next `wsl` command restarts it and rebuilds the relay +``` + +> [!WARNING] +> Do not reach for `host = "0.0.0.0"` as a fix. **EverOS ships no +> authentication of its own** — loopback is what keeps the API private +> (see [SECURITY.md](../SECURITY.md)). How far `0.0.0.0` actually reaches +> depends on the WSL networking mode, and the two differ sharply: +> +> | `networkingMode` in `.wslconfig` | What `0.0.0.0` exposes | +> |---|---| +> | `nat` (default) | The distro's private virtual network plus the Windows host. Other machines on your LAN cannot reach it without an explicit `netsh portproxy`. | +> | `mirrored` (WSL 2.0+) | Every interface the Windows machine has — **the API becomes reachable from the LAN**. | +> +> Only bind `0.0.0.0` once your own gateway or auth layer sits in front. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `wsl --install` hangs on "Enter new UNIX username" | `--no-launch` omitted | Ctrl-C, re-run with `--no-launch`, then `ubuntu install --root` | +| "The virtual machine could not be started" | Virtualization off in firmware | Enable VT-x / AMD-V in BIOS/UEFI | +| `WslRegisterDistribution failed with error: 0x80370102` | Same, or Hyper-V blocked by policy | Firmware setting, or IT | +| Install succeeded but nothing runs after reboot | Script not re-run | Re-run it; it is idempotent | +| Markdown edits never appear in search | Memory root on `/mnt/c` | Move it into the distro filesystem | +| Office upload returns `503` | LibreOffice missing **inside** the distro | `apt-get install -y libreoffice` | + +## Native Windows + +Running EverOS directly on Windows, without WSL2, is not currently a +supported configuration. There is no CI coverage for it and the +combination has not been validated end to end. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md new file mode 100644 index 000000000..ea03206a2 --- /dev/null +++ b/docs/windows-wsl2.zh.md @@ -0,0 +1,219 @@ +# 在 Windows 上跑 EverOS(WSL2) + +EverOS 的开发和 CI 都在 Linux 和 macOS 上。Windows 上走 WSL2。 + +WSL2 是 Windows 自带的一套真 Linux 内核,不是模拟器也不是虚拟机软件。装完之后 +EverOS 就跟跑在一台 Ubuntu 服务器上没区别,文件锁、向量索引、文件监听全部是 +Linux 原生行为。所以这条路的好处不是「能跑」,是**它和我们测过的环境完全一致**。 + +这篇写完整的安装过程,包括脚本绕不过去的那一步,和一个不看文档必踩、踩了还没有 +任何报错的坑。 + +## 目录 + +- [开始之前](#开始之前) + - [这台机器要不要重启](#这台机器要不要重启) +- [安装](#安装) + - [第一步:装 WSL2 和 Ubuntu](#第一步装-wsl2-和-ubuntu) + - [第二步:在 Ubuntu 里装 EverOS](#第二步在-ubuntu-里装-everos) + - [一个脚本跑完](#一个脚本跑完) +- [确认装好了](#确认装好了) +- [数据目录千万别放 C 盘](#数据目录千万别放-c-盘) +- [Office 文档支持](#office-文档支持) +- [从 Windows 这边访问 API](#从-windows-这边访问-api) +- [出问题了](#出问题了) +- [直接在 Windows 上装行不行](#直接在-windows-上装行不行) + +## 开始之前 + +| 要求 | 怎么确认 | +|---|---| +| Windows 11,或 Windows 10 build 19041 以上 | 运行 `winver` | +| 管理员权限 | 开 WSL 功能需要提权 | +| CPU 虚拟化已在固件里打开 | 任务管理器 → 性能 → CPU → 「虚拟化:已启用」 | +| 磁盘 3 GB 起,装 LibreOffice 再加 1 GB | | + +公司发的电脑上,Hyper-V 和虚拟机平台有可能被组策略锁死。这种情况没有绕法,得找 IT。 + +### 这台机器要不要重启 + +`wsl --install` 要打开 Windows 的 `VirtualMachinePlatform` 功能,而打开它需要重启。 + +但这个功能**很多机器上本来就是开的** —— 装过 Docker Desktop、开过 Hyper-V、用过 +Windows 沙盒或安卓模拟器、公司推过基于虚拟化的安全策略,任意一条命中就已经开了。 +先查一下,别白白安排一次重启: + +```powershell +(Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State +``` + +- 结果是 `Enabled` → 不用重启,整个安装一口气跑完。 +- 结果是 `Disabled` → 要重启一次,而且只有这一次,以后升级 EverOS 都不用。 + +## 安装 + +### 第一步:装 WSL2 和 Ubuntu + +用**管理员身份**打开 PowerShell: + +```powershell +wsl --install --no-launch +``` + +`--no-launch` 不能省。不加的话 `wsl --install` 装完会直接把 Ubuntu 拉起来,停在 +「Enter new UNIX username」等你输用户名 —— 脚本就卡死在这儿了。 + +如果上面预检查出来是 `Disabled`,现在重启。然后创建发行版,同样跳过交互: + +```powershell +wsl --install -d Ubuntu --no-launch +ubuntu install --root +``` + +### 第二步:在 Ubuntu 里装 EverOS + +```bash +wsl -d Ubuntu -u root -- bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq python3-venv curl + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + uv venv --python 3.12 /opt/everos + uv pip install --python /opt/everos/bin/python everos +' +``` + +### 一个脚本跑完 + +上面两步可以合成一个脚本。它是幂等的 —— 重启之后原样再跑一遍,会从断掉的地方接上, +不会重复装。 + +```powershell +# everos-setup.ps1 —— 用管理员身份运行 +$ErrorActionPreference = 'Stop' + +# 第一步:WSL 平台 +if (-not (Get-Command wsl -ErrorAction SilentlyContinue) -or -not (wsl --version 2>$null)) { + wsl --install --no-launch + Write-Host 'WSL 装好了。重启电脑,然后再跑一遍这个脚本。' -ForegroundColor Yellow + exit 0 +} + +# 第二步:Ubuntu +if (-not (wsl -l -q | Select-String -Quiet 'Ubuntu')) { + wsl --install -d Ubuntu --no-launch + ubuntu install --root +} + +# 第三步:EverOS +wsl -d Ubuntu -u root -- bash -lc @' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq python3-venv curl +curl -LsSf https://astral.sh/uv/install.sh | sh +export PATH="$HOME/.local/bin:$PATH" +uv venv --python 3.12 /opt/everos +uv pip install --python /opt/everos/bin/python everos +'@ + +Write-Host '装完了。启动:wsl -d Ubuntu -- /opt/everos/bin/everos serve' -ForegroundColor Green +``` + +判断 WSL 装没装用 `wsl --version`,别用 `wsl --status` —— 后者的退出码在几种情况下 +都是 0,判断不出来。 + +## 确认装好了 + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos --version +wsl -d Ubuntu -- /opt/everos/bin/everos init +wsl -d Ubuntu -- /opt/everos/bin/everos serve +``` + +到这儿就可以照着 [快速开始](../README.md#quick-start) 往下走了。进了 Ubuntu 之后, +所有操作和在一台普通 Ubuntu 机器上完全一样。 + +## 数据目录千万别放 C 盘 + +> [!IMPORTANT] +> EverOS 存记忆的目录(memory root)要放在 Ubuntu 自己的文件系统里,比如 +> `/home/...` 或 `/opt/...`。**不要放在 `/mnt/c/` 底下。** + +这是这条路上唯一一个**不报错的坑**,所以单独拿出来讲。 + +EverOS 有个后台组件一直盯着这个目录里的 md 文件,文件一改就更新索引。而 Windows +那边的文件改动**不会通知到 WSL2 里面** —— 这是 WSL2 的机制限制,不是 EverOS 的 bug。 + +后果是:目录放在 `/mnt/c` 上时,这个监听组件会正常启动、正常打日志、看起来一切健康, +但一个文件事件都收不到。你改了 md 文件,索引不动;然后搜索安安静静地返回旧数据。 +日志里没有任何一行提示这件事。 + +如果因为别的原因必须把文件放在 Windows 那边,就只能退回定时扫描: + +| 办法 | 怎么做 | +|---|---| +| 用默认的定时扫描(30 秒一轮) | 本来就开着,慢一点但最终会同步上 | +| 把间隔调短 | 目录不大的话调到 5 秒左右 | +| 改完手动同步一次 | `everos cascade sync` | + +细节见 [cascade 运维手册](cascade_runbook.md#wsl2--network-mounts)。 + +## Office 文档支持 + +要解析 Word / Excel / PPT,得在 Ubuntu 里装 **Linux 版** LibreOffice: + +```bash +wsl -d Ubuntu -u root -- apt-get install -y libreoffice +``` + +Windows 版的 LibreOffice 装了也没用。EverOS 是在 Ubuntu 内部用 `shutil.which("soffice")` +去 PATH 里找这个程序的,找不到 Windows 那边的 `soffice.exe`。 + +没装的话,上传 Office 文件会返回 `503 CAPABILITY_UNAVAILABLE`。其他格式(图片、 +PDF、音频)不受影响,见 [multimodal.md](multimodal.md#libreoffice-office-documents-only)。 + +## 从 Windows 这边访问 API + +能访问,不用额外配置。WSL2 默认开着 localhost 转发,Ubuntu 里监听的端口,Windows +上用同一个端口就能连。`everos serve` 在 Ubuntu 内部绑的是 `127.0.0.1`,转发照样能 +中继过去,所以这个默认值不用改: + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos serve # 在 Ubuntu 里监听 +curl http://localhost:8000/health # Windows 这边直接连 +``` + +连不上的话,最常见的两个原因是 VPN 客户端接管了 WSL 的网络,或者转发进程卡住了。 +先重启一下 WSL: + +```powershell +wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重建 +``` + +> [!WARNING] +> 别拿 `host = "0.0.0.0"` 当解决办法。**EverOS 自己不带任何鉴权** —— 绑回环地址 +> 就是它保持私有的唯一手段(见 [SECURITY.md](../SECURITY.md))。而且 `0.0.0.0` +> 到底暴露到哪,取决于 WSL 的网络模式,两种差别很大: +> +> | `.wslconfig` 里的 `networkingMode` | 绑 `0.0.0.0` 会暴露给谁 | +> |---|---| +> | `nat`(默认) | 只到 Ubuntu 的虚拟网段和 Windows 本机。局域网里的其他机器连不上,除非你手动配了 `netsh portproxy`。 | +> | `mirrored`(WSL 2.0 以上) | Windows 这台机器的**所有网卡** —— 这个无鉴权的 API 就挂到局域网上了。 | +> +> 只有在自己的网关或鉴权层已经挡在前面时,才用 `0.0.0.0`。 + +## 出问题了 + +| 你看到的现象 | 原因 | 怎么办 | +|---|---|---| +| `wsl --install` 卡在 「Enter new UNIX username」 | 漏了 `--no-launch` | Ctrl-C,加上 `--no-launch` 重跑,然后 `ubuntu install --root` | +| 提示「无法启动虚拟机」 | 固件里虚拟化没开 | 进 BIOS/UEFI 打开 VT-x 或 AMD-V | +| 报错 `WslRegisterDistribution failed with error: 0x80370102` | 同上,或者 Hyper-V 被组策略禁了 | 改固件设置,或找 IT | +| 重启完就没动静了 | 脚本没再跑一遍 | 再跑一遍,它是幂等的 | +| 改了 md 文件,搜索还是旧内容 | 数据目录放在 `/mnt/c` 上了 | 挪进 Ubuntu 的文件系统 | +| 传 Office 文件返回 `503` | Ubuntu **里面**没装 LibreOffice | `apt-get install -y libreoffice` | + +## 直接在 Windows 上装行不行 + +目前不支持。这个组合没有 CI 覆盖,也没有端到端验证过。 From f7a31c77456e36e78b99d859fdb0e15bc49c85ec Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 19:01:12 +0800 Subject: [PATCH 04/26] fix(windows): make file uris, text io, and port probing platform-correct The first `unit-windows` run came back 12 failed / 2538 passed, which split into three real defects and four tests carrying POSIX assumptions. Defects: - `file://` uris never worked on Windows. `unquote(parsed.path)` leaves the uri's leading-slash drive form `/C:/x`, which is a different path from `C:\x`. `urllib.request.url2pathname` is the stdlib converter for exactly this and is a no-op beyond percent-decoding on POSIX. - Text written without an explicit encoding follows the locale, which is cp1252 on Windows, so the TUI demo's box-drawing glyphs raised UnicodeEncodeError. Fixed at all seven `read_text`/`write_text` sites that lacked one; the markdown layer already passed utf-8 everywhere, so memory data itself was never at risk. - `SO_REUSEADDR` means the opposite thing on Windows: it lets a second socket bind a port the probe already holds. The benchmark fleet's hold-until-spawn therefore excluded nobody and handed two concurrent runs the same port -- the exact race its docstring says the bind is there to close. Windows spells that exclusivity `SO_EXCLUSIVEADDRUSE`. Tests: build file uris with `Path.as_uri()` instead of f-string concatenation, set `USERPROFILE` alongside `HOME` (expanduser reads the former on Windows), `re.escape` a path used as a regex, and `json.dumps` a path going into a JSON-valued env var -- a Windows path is not a valid JSON string literal, `\U` is an invalid escape. One timing test was widened, not weakened: Windows' ~15ms timer granularity stretches the 50ms rebuild interval to ~110ms, so the observation window grew from 0.2s to 0.6s while the assertion stays at >= 3 sweeps. Verified: full unit suite 2550 passed / 4 skipped on macOS, unchanged from before. The Windows branch of the uri fix was exercised directly through `nturl2path.url2pathname`, which is what `url2pathname` resolves to there: all three drive-letter cases round-trip, where the old `unquote` produced `/C:/...`. Co-Authored-By: Claude Opus 5 (1M context) --- benchmarks/run.py | 10 +++++++++- src/everos/entrypoints/tui/demo/readme_media.py | 8 +++++--- .../infra/persistence/lancedb/__init__.py | 16 ++++++++++++---- src/everos/memory/extract/parser/mapping.py | 9 +++++++-- tests/unit/test_benchmark_cli_portability.py | 3 ++- .../test_persistence/test_memory_root.py | 2 ++ .../test_memory/test_cascade/test_worker.py | 4 +++- .../test_extract/test_parser/test_enrich.py | 2 +- .../test_extract/test_parser/test_mapping.py | 17 ++++++++++------- 9 files changed, 51 insertions(+), 20 deletions(-) diff --git a/benchmarks/run.py b/benchmarks/run.py index af14373b5..ad8296237 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -528,7 +528,15 @@ def _free_port(start: int) -> tuple[int, socket.socket]: for port in range(start, start + 400): s = socket.socket() try: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # SO_REUSEADDR means the opposite thing on Windows: it lets a + # second socket bind a port this one already holds, so the + # hold-until-spawn above would stop excluding anyone and two + # concurrent runs would be handed the same port. Windows spells + # the exclusivity this probe needs SO_EXCLUSIVEADDRUSE. + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", port)) s.listen(1) except OSError: diff --git a/src/everos/entrypoints/tui/demo/readme_media.py b/src/everos/entrypoints/tui/demo/readme_media.py index 846e99238..268b14be8 100644 --- a/src/everos/entrypoints/tui/demo/readme_media.py +++ b/src/everos/entrypoints/tui/demo/readme_media.py @@ -129,7 +129,7 @@ async def render_media(out_dir: Path) -> tuple[Path, Path]: frame_paths.append(frame_path) animation = out_dir / "everos-demo-tui-animation.svg" - animation.write_text(_build_animation_svg(frame_paths, plan)) + animation.write_text(_build_animation_svg(frame_paths, plan), encoding="utf-8") return screenshot, animation @@ -169,7 +169,9 @@ async def _export_frame( finally: if no_color is not None: os.environ["NO_COLOR"] = no_color - await anyio.Path(path).write_text(normalize_svg_terminal_ids(screenshot)) + await anyio.Path(path).write_text( + normalize_svg_terminal_ids(screenshot), encoding="utf-8" + ) def _export_screenshot_svg(app) -> str: @@ -229,7 +231,7 @@ def _build_animation_svg(frame_paths: Sequence[Path], plan: Sequence[FramePlan]) def _read_svg_dimensions(path: Path) -> tuple[str, str, str]: - svg_open = re.search(r"]+)>", path.read_text()) + svg_open = re.search(r"]+)>", path.read_text(encoding="utf-8")) if svg_open is None: raise ValueError(f"could not find SVG root in {path}") view_box_match = re.search(r'viewBox="([^"]+)"', svg_open.group(0)) diff --git a/src/everos/infra/persistence/lancedb/__init__.py b/src/everos/infra/persistence/lancedb/__init__.py index 78ea873ed..5f4d0ba31 100644 --- a/src/everos/infra/persistence/lancedb/__init__.py +++ b/src/everos/infra/persistence/lancedb/__init__.py @@ -118,7 +118,11 @@ async def migrate_fts_indexes() -> None: async with memory_root_lock(memory_root): marker = memory_root.lancedb_dir / ".fts_index_version" try: - current = int(marker.read_text().strip()) if marker.exists() else 0 + current = ( + int(marker.read_text(encoding="utf-8").strip()) + if marker.exists() + else 0 + ) except (ValueError, OSError): current = 0 if current >= _FTS_INDEX_SCHEMA_VERSION: @@ -140,7 +144,7 @@ async def migrate_fts_indexes() -> None: # so compaction no longer decodes a position List. with contextlib.suppress(Exception): await table.optimize(cleanup_older_than=dt.timedelta(seconds=0)) - marker.write_text(str(_FTS_INDEX_SCHEMA_VERSION)) + marker.write_text(str(_FTS_INDEX_SCHEMA_VERSION), encoding="utf-8") logger.info("fts_index_migration_done", version=_FTS_INDEX_SCHEMA_VERSION) @@ -219,7 +223,11 @@ async def migrate_table_schemas() -> None: async with memory_root_lock(memory_root): marker = memory_root.lancedb_dir / ".table_schema_version" try: - current = int(marker.read_text().strip()) if marker.exists() else 0 + current = ( + int(marker.read_text(encoding="utf-8").strip()) + if marker.exists() + else 0 + ) except (ValueError, OSError): current = 0 if current >= _TABLE_SCHEMA_VERSION: @@ -266,7 +274,7 @@ async def migrate_table_schemas() -> None: marker.parent.mkdir(parents=True, exist_ok=True) try: - marker.write_text(str(_TABLE_SCHEMA_VERSION)) + marker.write_text(str(_TABLE_SCHEMA_VERSION), encoding="utf-8") except OSError: logger.error("table_schema_migration_marker_write_failed", path=str(marker)) raise diff --git a/src/everos/memory/extract/parser/mapping.py b/src/everos/memory/extract/parser/mapping.py index 204588c69..78106f750 100644 --- a/src/everos/memory/extract/parser/mapping.py +++ b/src/everos/memory/extract/parser/mapping.py @@ -15,7 +15,8 @@ import base64 from pathlib import Path from typing import Any -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse +from urllib.request import url2pathname import anyio from everalgo.types import RawFile @@ -62,6 +63,10 @@ def _is_file_uri(uri: str) -> bool: def _resolve_file_uri(uri: str) -> Path: """Parse a ``file://`` uri into a canonical local path (symlinks resolved). + ``url2pathname`` does the percent-decoding and, on Windows, turns the + URI's leading-slash drive form (``/C:/x``) into a real path — a plain + ``unquote`` leaves ``/C:/x``, which is not the same file. + Raises ``ValueError`` for a remote host component or a path that does not exist (``resolve(strict=True)``). """ @@ -69,7 +74,7 @@ def _resolve_file_uri(uri: str) -> Path: if parsed.netloc and parsed.netloc not in ("", "localhost"): raise ValueError(f"file uri with remote host not supported: {parsed.netloc!r}") try: - return Path(unquote(parsed.path)).expanduser().resolve(strict=True) + return Path(url2pathname(parsed.path)).expanduser().resolve(strict=True) except OSError as exc: raise ValueError(f"cannot resolve file uri {uri!r}: {exc}") from exc diff --git a/tests/unit/test_benchmark_cli_portability.py b/tests/unit/test_benchmark_cli_portability.py index 096678fda..4738f45ee 100644 --- a/tests/unit/test_benchmark_cli_portability.py +++ b/tests/unit/test_benchmark_cli_portability.py @@ -14,6 +14,7 @@ from __future__ import annotations import importlib +import re import sys from pathlib import Path from typing import Any @@ -67,7 +68,7 @@ def test_a_built_in_name_still_resolves_under_configs(name: str) -> None: def test_the_error_names_the_path_it_actually_opened(tmp_path: Path) -> None: """Otherwise a mis-resolved name is indistinguishable from a missing file.""" missing = tmp_path / "absent.toml" - with pytest.raises(FileNotFoundError, match=str(missing)): + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): BenchmarkConfig.from_toml(str(missing)) with pytest.raises(FileNotFoundError, match=r"nosuchbenchmark\.toml"): diff --git a/tests/unit/test_core/test_persistence/test_memory_root.py b/tests/unit/test_core/test_persistence/test_memory_root.py index 6da6ccd9a..1fd52c2dc 100644 --- a/tests/unit/test_core/test_persistence/test_memory_root.py +++ b/tests/unit/test_core/test_persistence/test_memory_root.py @@ -135,5 +135,7 @@ def test_frozen_dataclass_hashable(tmp_path: Path) -> None: def test_user_expansion(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("HOME", str(tmp_path)) + # Path.expanduser() consults USERPROFILE on Windows and HOME on POSIX. + monkeypatch.setenv("USERPROFILE", str(tmp_path)) mr = MemoryRoot("~/custom") assert mr.root == (tmp_path / "custom").resolve() diff --git a/tests/unit/test_memory/test_cascade/test_worker.py b/tests/unit/test_memory/test_cascade/test_worker.py index e7953c34a..e2f71620e 100644 --- a/tests/unit/test_memory/test_cascade/test_worker.py +++ b/tests/unit/test_memory/test_cascade/test_worker.py @@ -724,7 +724,9 @@ async def test_rebuild_runs_periodically( optimize_rebuild_interval_seconds=0.05, # ~tick every 50ms in this test ) await w.start() - await asyncio.sleep(0.2) # ~4 ticks plus startup sweep + # Windows' ~15ms timer granularity stretches a 50ms interval to ~110ms, + # so size the window off the observed tick, not the requested one. + await asyncio.sleep(0.6) await w.stop() # Startup sweep + at least 2 interval-driven sweeps. assert len(fake.rebuild_calls) >= 3, ( diff --git a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py index acd9793b4..1badd5ba2 100644 --- a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py +++ b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py @@ -156,7 +156,7 @@ async def fake_aparse(raw_file: Any) -> ParsedContent: monkeypatch.setattr(_APARSE_FILE_TARGET, fake_aparse) f = tmp_path / "doc.html" f.write_bytes(b"hello") - items = [{"type": "html", "uri": f"file://{f}"}] + items = [{"type": "html", "uri": f.as_uri()}] await enrich_content_items(items) assert items[0]["parsed_content"] == "FILE PARSED" diff --git a/tests/unit/test_memory/test_extract/test_parser/test_mapping.py b/tests/unit/test_memory/test_extract/test_parser/test_mapping.py index f6746aeca..41296ea2a 100644 --- a/tests/unit/test_memory/test_extract/test_parser/test_mapping.py +++ b/tests/unit/test_memory/test_extract/test_parser/test_mapping.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json from pathlib import Path import pytest @@ -54,7 +55,7 @@ async def test_build_raw_file_hydrates_file_uri(tmp_path: Path) -> None: """file:// is read locally into a hydrated RawFile (content + ext).""" f = tmp_path / "notes.html" f.write_bytes(b"v9.9.9") - rf = await build_raw_file({"type": "html", "uri": f"file://{f}"}) + rf = await build_raw_file({"type": "html", "uri": f.as_uri()}) assert rf.content == b"v9.9.9" assert rf.extension == "html" assert rf.uri == "" # hydrated, not a pointer @@ -63,13 +64,13 @@ async def test_build_raw_file_hydrates_file_uri(tmp_path: Path) -> None: async def test_build_raw_file_file_uri_ext_hint_wins(tmp_path: Path) -> None: f = tmp_path / "blob" # no suffix f.write_bytes(b"%PDF-1.4 ...") - rf = await build_raw_file({"type": "pdf", "uri": f"file://{f}", "ext": "pdf"}) + rf = await build_raw_file({"type": "pdf", "uri": f.as_uri(), "ext": "pdf"}) assert rf.extension == "pdf" async def test_build_raw_file_missing_file_raises(tmp_path: Path) -> None: with pytest.raises(ValueError): - await build_raw_file({"type": "pdf", "uri": f"file://{tmp_path}/nope.pdf"}) + await build_raw_file({"type": "pdf", "uri": (tmp_path / "nope.pdf").as_uri()}) async def test_build_raw_file_oversize_raises( @@ -80,7 +81,7 @@ async def test_build_raw_file_oversize_raises( monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_MAX_BYTES", "10") load_settings.cache_clear() with pytest.raises(ValueError, match="too large"): - await build_raw_file({"type": "html", "uri": f"file://{f}"}) + await build_raw_file({"type": "html", "uri": f.as_uri()}) async def test_build_raw_file_outside_allowlist_raises( @@ -91,7 +92,7 @@ async def test_build_raw_file_outside_allowlist_raises( monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", '["/some/other/root"]') load_settings.cache_clear() with pytest.raises(ValueError, match="outside the allowed roots"): - await build_raw_file({"type": "html", "uri": f"file://{f}"}) + await build_raw_file({"type": "html", "uri": f.as_uri()}) async def test_build_raw_file_inside_allowlist_ok( @@ -99,7 +100,9 @@ async def test_build_raw_file_inside_allowlist_ok( ) -> None: f = tmp_path / "ok.html" f.write_bytes(b"ok") - monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", f'["{tmp_path}"]') + monkeypatch.setenv( + "EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", json.dumps([str(tmp_path)]) + ) load_settings.cache_clear() - rf = await build_raw_file({"type": "html", "uri": f"file://{f}"}) + rf = await build_raw_file({"type": "html", "uri": f.as_uri()}) assert rf.content == b"ok" From 469e98495b10e38e59133690045477cbc106adac Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 19:29:58 +0800 Subject: [PATCH 05/26] test(tui): read the exported svg as utf-8 and drain deferred scrolls Second `unit-windows` run: 3 failed / 2547 passed. Two failures moved rather than vanished. The previous commit made the TUI demo write its SVG frames as utf-8, so the failure shifted from write (UnicodeEncodeError) to the test's own `read_text()` with no encoding, which follows the locale and is cp1252 on Windows -- byte 0x81 has no mapping there. Read back with the encoding the product writes. The third is a race the slower runner exposes. `_record_line` calls `scroll_end(animate=False)` after `Static.update()`, and Textual applies that scroll after the layout refresh the update triggers. After 16 lines a single `pilot.pause()` does not always drain every deferred scroll, so one can land after `scroll_home` and re-pin the panel to the bottom, which is what `assert 13 == 0` shows: 13 is max_scroll_y. Pause twice on each side of the scroll so the queue is empty before and after; the assertions are unchanged. Not reproducible on macOS (39/39 TUI tests pass before and after); the race is reasoned from the widget's deferral, not observed locally. Co-Authored-By: Claude Fable 5.1 --- tests/unit/test_entrypoints/test_tui/test_demo_app.py | 5 +++++ .../unit/test_entrypoints/test_tui/test_demo_readme_media.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_app.py b/tests/unit/test_entrypoints/test_tui/test_demo_app.py index 77532a60f..bafe27e82 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -394,6 +394,10 @@ async def test_conversation_panel_scrolls_when_log_overflows() -> None: for i in range(8): app._record_line("you", f"memory {i}") app._record_line("everos", f"a long recalled answer for round {i}") + # Each _record_line defers its scroll_end past the layout refresh that + # Static.update() triggers; one pause is not always enough to drain 16 + # of them, and a late one landing after scroll_home re-pins the bottom. + await pilot.pause() await pilot.pause() panel = app.query_one("#conversation", VerticalScroll) @@ -401,6 +405,7 @@ async def test_conversation_panel_scrolls_when_log_overflows() -> None: assert panel.scroll_y == panel.max_scroll_y # newest line auto-pinned panel.scroll_home(animate=False) await pilot.pause() + await pilot.pause() assert panel.scroll_y == 0 # user can scroll back to the start diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py index b258f91a1..df7650f02 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py @@ -102,7 +102,7 @@ def fast_mount(self: DotSphereWidget) -> None: await _export_frame(path, FramePlan(state="booting", phase=0.0)) - svg = html.unescape(path.read_text()).replace("\xa0", " ") + svg = html.unescape(path.read_text(encoding="utf-8")).replace("\xa0", " ") assert "working..." in svg assert "ingesting conversation dots" not in svg @@ -112,6 +112,6 @@ async def test_export_frame_preserves_poster_palette(tmp_path) -> None: await _export_frame(path, FramePlan(state="remembered", phase=0.5)) - svg = path.read_text().lower() + svg = path.read_text(encoding="utf-8").lower() assert "#f9b91c" in svg assert "#f5eddc" in svg From 3964f43aa8a05e467d1ee062e0a0cceca8612248 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 19:37:23 +0800 Subject: [PATCH 06/26] docs(windows): state what the green Windows CI run proves The guide closed by saying native Windows had no CI coverage. After this branch it has: the unit suite passes 2550/2550 on windows-latest. Say so, and say exactly what is still missing before it can be called supported -- the integration suite does not run there, and no one has run a full `everos serve` on a Windows machine end to end. Co-Authored-By: Claude Fable 5.1 --- docs/windows-wsl2.md | 10 +++++++--- docs/windows-wsl2.zh.md | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index 90a92cabe..fe0027fe4 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -218,6 +218,10 @@ wsl --shutdown # next `wsl` command restarts it and rebuilds the relay ## Native Windows -Running EverOS directly on Windows, without WSL2, is not currently a -supported configuration. There is no CI coverage for it and the -combination has not been validated end to end. +Running EverOS directly on Windows, without WSL2, is not yet a supported +configuration — but it is closer than it was. The unit suite runs green on +`windows-latest` in CI (the `unit tests (Windows)` job), which covers the +lock, the markdown layer, search and the cascade pipeline. What is still +missing: the integration suite does not run on Windows, and nobody has yet +exercised a full `everos serve` on a Windows machine end to end. Until +both happen, WSL2 is the path this guide stands behind. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index ea03206a2..053cbf313 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -216,4 +216,7 @@ wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重 ## 直接在 Windows 上装行不行 -目前不支持。这个组合没有 CI 覆盖,也没有端到端验证过。 +还不算支持,但比之前近了。单测套件在 CI 的 `windows-latest` 上是绿的(`unit tests +(Windows)` 这个 job),覆盖了锁、md 层、检索和 cascade 流水线。还缺两样:集成套件没在 +Windows 上跑,也没人在真的 Windows 机器上完整跑过一次 `everos serve`。这两件事没做完之前, +这篇指南只为 WSL2 这条路背书。 From 5d02932486f58681238c46c081e70cd694a86200 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 19:46:49 +0800 Subject: [PATCH 07/26] feat(cascade): make scan_interval_seconds a [cascade] setting `scan_interval_seconds` was a `CascadeConfig` dataclass default that `from_settings()` never read: no TOML key, no env var. The cascade runbook nonetheless told operators to "drop the scan interval to ~5 s" on WSL2 and network mounts -- advice that needed a code change to follow. It matters most on exactly those mounts. A Windows directory bound into WSL2 or a container delivers no filesystem events, so the watcher sees nothing and the scanner is the only path an md edit takes to the index; its interval is then the edit-to-searchable latency. Every sweep also stats every md file, which over a slow mount is the cost of shortening it. That is a write-volume-dependent cadence by the settings module's own rule, so it moves alongside the four `optimize_*` cadences. The exact-set test on `CascadeSettings.model_fields` grows by this field. Its rule is that deadlines stay constants and cadences may move; this is a cadence. The forwarding test gains a fifth env var and asserts on the scanner's own `_interval`, not on `CascadeConfig` -- the same reason the existing four assert on the worker. Co-Authored-By: Claude Fable 5.1 --- docs/cascade_runbook.md | 9 ++++++--- src/everos/config/default.toml | 5 +++++ src/everos/config/settings.py | 10 ++++++++++ src/everos/memory/cascade/orchestrator.py | 1 + src/everos/memory/cascade/scanner.py | 2 +- .../unit/test_memory/test_cascade/test_orchestrator.py | 8 +++++++- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/cascade_runbook.md b/docs/cascade_runbook.md index 338d2a7cf..32b091279 100644 --- a/docs/cascade_runbook.md +++ b/docs/cascade_runbook.md @@ -228,9 +228,12 @@ error and silently see nothing. Workarounds: -- Rely on the scanner — at default 30 s interval, throughput is +- Rely on the scanner — at the default 30 s interval, throughput is bounded but eventually-consistent. -- Drop the scan interval to ~5 s if the memory root is small. +- Shorten the interval if the memory root is small: + `scan_interval_seconds = 5.0` under `[cascade]` in `everos.toml`, or + `EVEROS_CASCADE__SCAN_INTERVAL_SECONDS=5`. Every sweep stats every md + file, so over a slow mount a short interval is a steady I/O cost. - Run `everos cascade sync` explicitly after batch edits. ### Daemon process crash mid-batch @@ -293,7 +296,7 @@ and `everos.memory.cascade.worker.CascadeWorker`: | Knob | Default | Effect | |---|---|---| -| `scan_interval_seconds` | 30 | Scanner sweep cadence | +| `scan_interval_seconds` | 30 | Scanner sweep cadence — also settable under `[cascade]` | | `worker_batch_size` | 50 | Rows claimed per worker cycle | | `worker_max_retry` | 3 | Inline retries before `mark_failed(retryable=TRUE)` | | `worker_poll_interval_seconds` | 1 | Idle wait between empty drain attempts | diff --git a/src/everos/config/default.toml b/src/everos/config/default.toml index 3dceee2f4..af2344401 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -207,6 +207,11 @@ optimize_prune_interval_seconds = 300.0 # under LanceDB's 7-day unverified window they then wait out the full 7 days. optimize_prune_retention_seconds = 60.0 optimize_rebuild_interval_seconds = 43200.0 +# How often the scanner walks the memory root for changes the watcher missed. +# On a mount that delivers no filesystem events (a Windows directory bound into +# WSL2 or a container) this is the edit-to-searchable latency. Every sweep also +# stats every md file, which over a slow mount is the cost of a short interval. +scan_interval_seconds = 30.0 [observability] # OpenTelemetry tracing export. Off by default; pure OTLP/HTTP, vendor-neutral diff --git a/src/everos/config/settings.py b/src/everos/config/settings.py index fc1be21ad..fa148cf6f 100644 --- a/src/everos/config/settings.py +++ b/src/everos/config/settings.py @@ -549,12 +549,22 @@ class CascadeSettings(BaseModel): Full index rebuild per kind, which collapses the active index fragment count that every ``optimize()`` grows. Bounded by rebuild cost, not correctness — a missed sweep only defers cleanup. + + ``scan_interval_seconds``: + How often the scanner walks the memory root for what the watcher + missed: files written while the daemon was down, editors that + move-replace, and mounts that deliver no filesystem events at all (a + Windows directory bound into WSL2 or a container). On such a mount this + is the only path an md edit takes to the index, so it is the + edit-to-searchable latency. Every sweep also ``stat``-s every md file, + which over a slow mount is the cost to weigh against it. """ optimize_heartbeat_seconds: float = 60.0 optimize_prune_interval_seconds: float = 300.0 optimize_prune_retention_seconds: float = 60.0 optimize_rebuild_interval_seconds: float = 12 * 60 * 60.0 + scan_interval_seconds: float = 30.0 class IndexSettings(BaseModel): diff --git a/src/everos/memory/cascade/orchestrator.py b/src/everos/memory/cascade/orchestrator.py index 794fec2d8..9a73b21ea 100644 --- a/src/everos/memory/cascade/orchestrator.py +++ b/src/everos/memory/cascade/orchestrator.py @@ -108,6 +108,7 @@ def from_settings(cls) -> CascadeConfig: optimize_rebuild_interval_seconds=( cascade.optimize_rebuild_interval_seconds ), + scan_interval_seconds=cascade.scan_interval_seconds, ) diff --git a/src/everos/memory/cascade/scanner.py b/src/everos/memory/cascade/scanner.py index 9a5811365..7e1d1cf98 100644 --- a/src/everos/memory/cascade/scanner.py +++ b/src/everos/memory/cascade/scanner.py @@ -7,7 +7,7 @@ - WSL2 / network mounts where fsevents don't propagate. The scanner closes those gaps by walking the memory root every -``scan_interval`` seconds (default 30s, configurable later), matching +``scan_interval`` seconds (default 30s, ``[cascade] scan_interval_seconds``), matching paths against the kind registry, reading prior state, and running the pure :func:`reconcile` function to emit the upsert plan. diff --git a/tests/unit/test_memory/test_cascade/test_orchestrator.py b/tests/unit/test_memory/test_cascade/test_orchestrator.py index d67bbf6a8..7a7601880 100644 --- a/tests/unit/test_memory/test_cascade/test_orchestrator.py +++ b/tests/unit/test_memory/test_cascade/test_orchestrator.py @@ -186,6 +186,7 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_PRUNE_INTERVAL_SECONDS", "22") monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_PRUNE_RETENTION_SECONDS", "33") monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_REBUILD_INTERVAL_SECONDS", "44") + monkeypatch.setenv("EVEROS_CASCADE__SCAN_INTERVAL_SECONDS", "55") load_settings.cache_clear() # type: ignore[attr-defined] try: cfg = CascadeConfig.from_settings() @@ -194,7 +195,8 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( cfg.optimize_prune_interval_seconds, cfg.optimize_prune_retention_seconds, cfg.optimize_rebuild_interval_seconds, - ) == (11.0, 22.0, 33.0, 44.0) + cfg.scan_interval_seconds, + ) == (11.0, 22.0, 33.0, 44.0, 55.0) orch = CascadeOrchestrator( memory_root=MemoryRoot.resolve(), tokenizer=build_tokenizer(), config=cfg @@ -204,6 +206,9 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( assert worker._optimize_prune_interval == 22.0 assert worker._optimize_prune_retention == 33.0 assert worker._optimize_rebuild_interval == 44.0 + # The scanner is the other consumer; on an event-less mount it is the + # only one, so it must see the value too, not just CascadeConfig. + assert orch._scanner._interval == 55.0 finally: load_settings.cache_clear() # type: ignore[attr-defined] @@ -225,6 +230,7 @@ def test_deadlines_are_deliberately_not_configurable() -> None: "optimize_prune_interval_seconds", "optimize_prune_retention_seconds", "optimize_rebuild_interval_seconds", + "scan_interval_seconds", } assert not any("timeout" in f or "deadline" in f for f in exposed) From fd3c9da00e76c45fe009e1ea7d5510bb6c357c9a Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 20:23:04 +0800 Subject: [PATCH 08/26] fix(cascade): record a late modified for a gone path as deleted `_Handler.on_modified` never checked whether the path still existed. On FSEvents, which coalesces and reorders, a create followed by an unlink inside its latency window can deliver the ``modified`` leg last; the upsert then overwrote the ``deleted`` row with ``modified`` / mtime 0.0, and the state table resurrected a file that was no longer on disk until the scanner's next sweep (up to 30 s) noticed. Not data loss -- the worker fails to read the file and the scanner reconciles -- but a wrong terminal state and a spurious failed row. Surfaced by the new real-observer test `test_unlink_enqueues_deleted`, which failed 2 in 6 runs on macOS with the row sitting at ``'modified'/'pending'``. `on_deleted` and `on_moved` already guard the mirror case (a deletion for a path that still exists); this closes the other direction in `_enqueue`, the single exit all four callbacks share. Disk is the truth: an ``added`` / ``modified`` for a path that is gone is recorded as ``deleted``. Pinned deterministically by `test_modified_for_a_gone_path_is_recorded_as_deleted` in the next commit; removing the guard turns it red on that exact symptom. Co-Authored-By: Claude Fable 5.1 --- src/everos/memory/cascade/watcher.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/everos/memory/cascade/watcher.py b/src/everos/memory/cascade/watcher.py index 9c30032b9..ac5ba47f8 100644 --- a/src/everos/memory/cascade/watcher.py +++ b/src/everos/memory/cascade/watcher.py @@ -122,6 +122,14 @@ def _enqueue(self, raw_path: str, change_type: str) -> None: spec = match_kind(rel) if spec is None: return + # A late ``added`` / ``modified`` for a path that is already gone. + # FSEvents coalesces and reorders: a create followed by an unlink + # inside its latency window can deliver the modified leg last, and + # letting it through would overwrite the ``deleted`` row and + # resurrect a file that no longer exists (until the scanner's next + # sweep notices). Disk is the truth; record what is actually there. + if change_type != "deleted" and not Path(raw_path).exists(): + change_type = "deleted" mtime = _safe_mtime(raw_path) asyncio.run_coroutine_threadsafe( _enqueue_async(spec, rel, change_type, mtime), From 4734b9f9fb1821fa92fa2cdd84cb7704517f0f58 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 20:23:04 +0800 Subject: [PATCH 09/26] test(windows): cover watcher events and lance filesystem semantics The watcher's unit coverage was two pure helpers; the four `_Handler` callbacks, both drop paths in `_enqueue`, `start()` creating the root, and the observer -> SQLite delivery path had none. The integration test that exercised them does not run on Windows CI, which only runs `tests/unit`, so on the one platform where the backend differs (ReadDirectoryChangesW) nothing had ever been asserted. `test_watcher_events.py` -- 16 tests, asserting on the `md_change_state` row, never on event order, so one test holds on inotify, FSEvents and Win32 alike. Ten call the callbacks directly (the LanceDB-wipe guard in `on_deleted`, both `on_moved` guards, both drops, the swallowed upsert failure, `ensure()` on start). Six run a real `Observer` on the memory root: new file, in-place save, temp-file + `os.replace` over an existing target (Windows reports that as REMOVED + RENAMED, so `on_deleted` fires for a path that still exists), unlink, rename within root. `test_platform_filesystem.py` -- four tests for what NTFS changes and POSIX never shows: the husk sweep continuing past a refused `rmdir` (sharing violation), table files releasable after `close()` so the memory root can be deleted or moved, prune completing while a second handle reads the table, and a memory root deep enough to cross MAX_PATH. Every test that a POSIX mutation can reach was shown red by one: 13 mutations of `watcher.py` / `repository.py` (guards removed, branches inverted, fallback constants moved, `schedule()` dropped) each turned their target tests red on the claimed symptom, sources restored byte-identical afterwards. The three Lance probes for handle release, open-reader prune and path length cannot be reddened on POSIX; they exist to be run on windows-latest. One assertion was reworked during that pass. "No row" for a non-kind path could not distinguish a clean drop from an exception swallowed downstream (the `except` in `_enqueue_async` dereferences `spec.name` itself, so a `None` spec fails silently into the unretrieved future); the test now records what the guard hands downstream instead. Full unit suite on macOS: 2570 passed, 4 skipped. Co-Authored-By: Claude Fable 5.1 --- .../test_lancedb/test_platform_filesystem.py | 195 +++++++++ .../test_cascade/test_watcher_events.py | 383 ++++++++++++++++++ 2 files changed, 578 insertions(+) create mode 100644 tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py create mode 100644 tests/unit/test_memory/test_cascade/test_watcher_events.py diff --git a/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py b/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py new file mode 100644 index 000000000..802e36ad2 --- /dev/null +++ b/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py @@ -0,0 +1,195 @@ +"""LanceDB against filesystem semantics that differ by platform. + +Every test here passes trivially on POSIX and exists to be run by the Windows +CI job, where NTFS and Win32 change the rules a local store leans on: a file +with an open handle cannot be deleted or renamed, ``rmdir`` on a directory +something else holds is a sharing violation, and a path past 260 characters +needs an opt-in. None of these are Lance bugs. They are the reasons a store +that works on a Mac can fail the first time a Windows user deletes, moves or +deeply nests their memory root. +""" + +from __future__ import annotations + +import datetime as dt +import gc +import inspect +import os +import shutil +import time +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +from everos.config import LanceDBSettings +from everos.core.persistence import ( + BaseLanceTable, + MemoryRoot, + Vector, + open_lancedb_connection, +) +from everos.core.persistence.lancedb import LanceDailyLogRepoBase, LanceRepoBase +from everos.core.persistence.lancedb.repository import ( + _HUSK_MIN_AGE_SECONDS, + _remove_empty_index_dirs, +) + + +class _Probe(BaseLanceTable): + TABLE_NAME: ClassVar[str] = "_probe" + + id: str + owner_id: str + app_id: str = "default" + project_id: str = "default" + entry_id: str + session_id: str = "s" + parent_type: str = "memcell" + parent_id: str = "mc" + md_path: str = "users/u/notes/x.md" + text: str = "x" + vector: Vector(4) # type: ignore[valid-type] + + +class _ProbeRepo(LanceDailyLogRepoBase[_Probe]): + schema = _Probe + + +def _rows(n: int, *, prefix: str = "e") -> list[_Probe]: + return [ + _Probe( + id=f"u_{prefix}{i}", + owner_id="u", + entry_id=f"{prefix}{i}", + vector=[1.0, 0, 0, 0], + ) + for i in range(n) + ] + + +async def _close(obj: Any) -> None: + """``AsyncTable.close`` / ``AsyncConnection.close`` are sync in 0.34; stay + correct if a later release makes them awaitable.""" + r = obj.close() + if inspect.isawaitable(r): + await r + + +@pytest.fixture(autouse=True) +def _reset_write_locks() -> None: + LanceRepoBase._reset_locks_for_tests() + + +# ── sharing violations ────────────────────────────────────────────────────── + + +def test_husk_sweep_skips_a_refused_rmdir_and_keeps_going( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """On Windows an ``rmdir`` of a directory another process holds open is + ``PermissionError``, not ``ENOTEMPTY``. One refused husk must not abort + the sweep of the others -- the sweep is best-effort by contract.""" + indices = tmp_path / "_indices" + husks = [indices / n for n in ("aaa", "bbb", "ccc")] + for h in husks: + h.mkdir(parents=True) + old = time.time() - _HUSK_MIN_AGE_SECONDS * 2 + os.utime(h, (old, old)) + + real_rmdir = Path.rmdir + + def refusing_rmdir(self: Path) -> None: + if self.name == "bbb": + raise PermissionError(32, "The process cannot access the file", str(self)) + real_rmdir(self) + + monkeypatch.setattr(Path, "rmdir", refusing_rmdir) + + removed = _remove_empty_index_dirs( + str(tmp_path), live_uuids=frozenset(), min_age_seconds=1.0 + ) + + assert removed == 2 + assert not husks[0].exists() and not husks[2].exists() + assert husks[1].exists(), "the refused one is left for the next sweep" + + +# ── handle release ────────────────────────────────────────────────────────── + + +async def test_table_files_are_releasable_after_close(tmp_path: Path) -> None: + """Deleting or moving the memory root is an ordinary user action. + + POSIX lets you unlink an open file; Windows does not. If Lance keeps a + handle or mapping alive past ``close()``, ``rmtree`` fails with + ``PermissionError`` on the first Windows user who tries -- and pytest's + own tmp_path cleanup would have hidden it, since that runs at the *next* + session start. + """ + mr = MemoryRoot(tmp_path / "root") + mr.ensure() + conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + await table.add([r.model_dump() for r in _rows(2)]) + assert await table.count_rows() == 2 + + await _close(table) + await _close(conn) + del table, conn + gc.collect() + + shutil.rmtree(mr.lancedb_dir) + assert not mr.lancedb_dir.exists() + + +async def test_prune_completes_while_another_handle_reads_the_table( + tmp_path: Path, +) -> None: + """A second connection holds the table open while prune reclaims old + versions. On Windows the reclaimed files may be the ones the reader has + mapped; the store must still finish and both handles must agree on the + rows afterwards.""" + mr = MemoryRoot(tmp_path) + mr.ensure() + writer_conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await writer_conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + repo = _ProbeRepo(table=table) + await repo.add(_rows(3, prefix="a")) + await repo.add(_rows(3, prefix="b")) # a second version to reclaim + + reader_conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + reader = await reader_conn.open_table(_Probe.TABLE_NAME) + assert await reader.count_rows() == 6 + + await repo.prune(dt.timedelta(seconds=0)) + + assert await repo.count() == 6 + assert await reader.count_rows() == 6 + await _close(reader) + await _close(reader_conn) + await _close(table) + await _close(writer_conn) + + +# ── path length ───────────────────────────────────────────────────────────── + + +async def test_deep_memory_root_still_stores_and_counts(tmp_path: Path) -> None: + """Windows caps paths at 260 characters unless long paths are enabled. + + The memory root is user-chosen and Lance nests ``.lance/data/`` + and ``_indices//`` under it, so a deep root can cross the cap while + looking perfectly ordinary. A red here on Windows means the store needs + ``LongPathsEnabled`` and the install guide has to say so. + """ + deep = tmp_path.joinpath(*(["x" * 40] * 4)) # +164 chars before Lance adds its own + mr = MemoryRoot(deep) + mr.ensure() + conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + await table.add([r.model_dump() for r in _rows(3)]) + assert await table.count_rows() == 3 + assert len(str(mr.lancedb_dir)) > 200, "probe must actually be deep" + await _close(table) + await _close(conn) diff --git a/tests/unit/test_memory/test_cascade/test_watcher_events.py b/tests/unit/test_memory/test_cascade/test_watcher_events.py new file mode 100644 index 000000000..ec4d50e11 --- /dev/null +++ b/tests/unit/test_memory/test_cascade/test_watcher_events.py @@ -0,0 +1,383 @@ +"""Watcher behaviour on a real filesystem, asserted in the state table. + +The pure helpers are covered in ``test_watcher_helpers.py``. This module +covers everything else: the four ``_Handler`` callbacks, the two drop paths +in ``_enqueue``, ``start()`` creating the root, and -- the part only a real +observer can prove -- that this OS's filesystem events reach +``md_change_state`` at all. + +That last part is what makes the Windows CI job an oracle. inotify, FSEvents +and ReadDirectoryChangesW disagree about what an editor's save looks like. +Windows reports ``os.replace`` over an existing file as a REMOVED of the +target followed by a RENAMED pair, so ``on_deleted`` fires for a path that +still exists -- the shape that, unguarded, drives the worker to +``delete_by_md_path`` and wipes LanceDB while the md is fine. Every assertion +here is on the final row, never on event order, so one test holds on all +three backends. + +Assertions read ``md_change_state`` directly; that table owns the fact. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from collections.abc import AsyncIterator, Callable +from pathlib import Path + +import pytest +from sqlmodel import SQLModel, select +from watchdog.events import ( + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, +) + +from everos.core.persistence import MemoryRoot +from everos.core.persistence.sqlite import session_scope +from everos.infra.persistence.sqlite import ( + MdChangeState, + dispose_engine, + get_engine, + get_session_factory, + md_change_state_repo, +) +from everos.memory.cascade import watcher as watcher_mod +from everos.memory.cascade.registry import match_kind +from everos.memory.cascade.watcher import CascadeWatcher, _enqueue_async, _Handler + +_EPISODE_DIR = ("default_app", "default_project", "users", "u1", "episodes") +# windows-latest delivers in about a second; the rest is margin for a loaded +# runner. A passing test never waits this long. +_EVENT_DEADLINE_S = 15.0 +# Long enough for a trailing REMOVED / RENAMED leg to land after the row first +# appeared, so a "never deleted" assertion is not just "not deleted yet". +_TRAILING_EVENT_GRACE_S = 1.5 + + +@pytest.fixture +async def runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[MemoryRoot]: + """Boot the system db against a tmp memory_root; no LanceDB needed here.""" + monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) + await dispose_engine() + engine = get_engine() + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + mr = MemoryRoot.resolve() + mr.ensure() + yield mr + await dispose_engine() + + +@pytest.fixture +async def watcher(runtime: MemoryRoot) -> AsyncIterator[CascadeWatcher]: + """A real observer on the memory root -- inotify / FSEvents / Win32.""" + w = CascadeWatcher(runtime, asyncio.get_running_loop()) + w.start() + # Let the native observer thread arm before the test writes, so the very + # first event is not lost to a race with startup. + await asyncio.sleep(0.5) + yield w + w.stop() + + +def _episode(root: Path, name: str = "episode-2026-01-01.md") -> Path: + d = root.joinpath(*_EPISODE_DIR) + d.mkdir(parents=True, exist_ok=True) + return d / name + + +def _rel(root: Path, p: Path) -> str: + return p.resolve().relative_to(root).as_posix() + + +async def _row(md_path: str) -> MdChangeState | None: + async with session_scope(get_session_factory()) as s: + stmt = select(MdChangeState).where(MdChangeState.md_path == md_path) + return (await s.execute(stmt)).scalars().first() + + +async def _all_paths() -> list[str]: + async with session_scope(get_session_factory()) as s: + rows = (await s.execute(select(MdChangeState))).scalars().all() + return sorted(r.md_path for r in rows) + + +async def _wait_row( + md_path: str, + where: Callable[[MdChangeState], bool] = lambda _r: True, + *, + deadline: float = _EVENT_DEADLINE_S, +) -> MdChangeState: + """Poll for a row matching ``where``; on timeout say what IS there.""" + end = time.monotonic() + deadline + last: MdChangeState | None = None + while time.monotonic() < end: + last = await _row(md_path) + if last is not None and where(last): + return last + await asyncio.sleep(0.1) + seen = f"row is {last.change_type!r}/{last.status!r}" if last else "no row" + pytest.fail( + f"{md_path!r}: {seen} after {deadline}s; rows present: {await _all_paths()}" + ) + + +async def _settle() -> None: + """Let a ``run_coroutine_threadsafe`` hop land on this loop.""" + for _ in range(5): + await asyncio.sleep(0.02) + + +# ── _Handler callbacks, called directly ───────────────────────────────────── + + +async def test_created_enqueues_added(runtime: MemoryRoot) -> None: + p = _episode(runtime.root) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_created(FileCreatedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.kind, row.change_type, row.status) == ("episode", "added", "pending") + assert row.mtime > 0 + + +async def test_modified_enqueues_modified(runtime: MemoryRoot) -> None: + p = _episode(runtime.root) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_modified(FileModifiedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert row.change_type == "modified" + + +async def test_deleted_for_a_path_that_still_exists_is_ignored( + runtime: MemoryRoot, +) -> None: + """The LanceDB-wipe guard. + + FSEvents (``os.replace``) and ReadDirectoryChangesW (REMOVED of an + overwritten target) both hand the handler a deletion for a path that is + still there. Propagating it would enqueue ``deleted`` and the worker would + drop the rows for a file that is intact. + """ + p = _episode(runtime.root) + p.write_text("still here", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_deleted(FileDeletedEvent(str(p))) + await _settle() + assert await _row(_rel(runtime.root, p)) is None + + +async def test_deleted_for_a_gone_path_enqueues_deleted_with_zero_mtime( + runtime: MemoryRoot, +) -> None: + p = _episode(runtime.root) # directory exists, file never written + _Handler(runtime, asyncio.get_running_loop()).on_deleted(FileDeletedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.change_type, row.mtime) == ("deleted", 0.0) + + +async def test_modified_for_a_gone_path_is_recorded_as_deleted( + runtime: MemoryRoot, +) -> None: + """A stale ``modified`` must not resurrect a deleted file. + + FSEvents can deliver the modified leg of a create after the unlink that + followed it (see ``test_unlink_enqueues_deleted``, which flaked 2 in 6 on + macOS before this guard). Disk is the truth: a modification reported for + a path that is no longer there is a deletion. + """ + p = _episode(runtime.root) # directory exists, file never written + _Handler(runtime, asyncio.get_running_loop()).on_modified(FileModifiedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.change_type, row.mtime) == ("deleted", 0.0) + + +async def test_moved_enqueues_source_deleted_and_dest_added( + runtime: MemoryRoot, +) -> None: + src = _episode(runtime.root, "episode-2026-01-01.md") # not on disk + dest = _episode(runtime.root, "episode-2026-01-02.md") + dest.write_text("moved", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_moved( + FileMovedEvent(str(src), str(dest)) + ) + await _settle() + assert (await _wait_row(_rel(runtime.root, src), deadline=2)).change_type == ( + "deleted" + ) + assert (await _wait_row(_rel(runtime.root, dest), deadline=2)).change_type == ( + "added" + ) + + +async def test_moved_keeps_source_when_it_still_exists(runtime: MemoryRoot) -> None: + """A hardlink survives the rename, so the named path is still bound.""" + src = _episode(runtime.root, "episode-2026-01-01.md") + dest = _episode(runtime.root, "episode-2026-01-02.md") + src.write_text("linked", encoding="utf-8") + try: + os.link(src, dest) + except OSError as exc: # filesystem without hardlinks + pytest.skip(f"hardlinks unavailable here: {exc}") + _Handler(runtime, asyncio.get_running_loop()).on_moved( + FileMovedEvent(str(src), str(dest)) + ) + await _settle() + assert (await _wait_row(_rel(runtime.root, dest), deadline=2)).change_type == ( + "added" + ) + assert await _row(_rel(runtime.root, src)) is None + + +async def test_path_outside_root_is_dropped(runtime: MemoryRoot) -> None: + outside = runtime.root.parent.joinpath("elsewhere", *_EPISODE_DIR, "episode-x.md") + _Handler(runtime, asyncio.get_running_loop()).on_created( + FileCreatedEvent(str(outside)) + ) + await _settle() + assert await _all_paths() == [] + + +async def test_path_not_matching_a_kind_is_dropped( + runtime: MemoryRoot, monkeypatch: pytest.MonkeyPatch +) -> None: + """Dropped *before* the async hop, not lost inside it. + + "No row" alone cannot tell a clean drop from a failure downstream: an + exception inside ``_enqueue_async`` -- logged, or left unretrieved in the + ``run_coroutine_threadsafe`` future -- also leaves no row. Recording what + the guard hands downstream is what separates the two. + """ + handed_down: list[tuple[object, ...]] = [] + + async def record(*a: object, **k: object) -> None: + handed_down.append(a) + + monkeypatch.setattr(watcher_mod, "_enqueue_async", record) + p = runtime.root / "notes" / "random.md" + p.parent.mkdir(parents=True) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_created(FileCreatedEvent(str(p))) + await _settle() + assert handed_down == [], "a path with no kind reached the enqueue coroutine" + assert await _all_paths() == [] + + +async def test_upsert_failure_is_logged_not_raised( + runtime: MemoryRoot, monkeypatch: pytest.MonkeyPatch +) -> None: + """The callback runs on the watchdog thread; an escape would kill it.""" + + async def boom(*_a: object, **_k: object) -> int: + raise RuntimeError("sqlite is having a day") + + monkeypatch.setattr(md_change_state_repo, "upsert", boom) + spec = match_kind("/".join((*_EPISODE_DIR, "episode-x.md"))) + assert spec is not None + await _enqueue_async(spec, "whatever.md", "added", 1.0) # must not raise + + +async def test_start_creates_a_missing_root_and_stop_is_clean(tmp_path: Path) -> None: + """watchdog refuses a non-existent path; ``start()`` has to make it first.""" + mr = MemoryRoot(tmp_path / "not-yet") + assert not mr.root.exists() + w = CascadeWatcher(mr, asyncio.get_running_loop()) + w.start() + try: + assert mr.root.is_dir() + finally: + w.stop() + + +# ── a real observer: does this OS deliver at all? ─────────────────────────── + + +async def test_observer_delivers_a_new_file( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + p = _episode(runtime.root) + p.write_text("hello", encoding="utf-8") + row = await _wait_row(_rel(runtime.root, p)) + assert row.kind == "episode" + assert row.change_type in {"added", "modified"} + assert row.status == "pending" + assert row.mtime > 0 + + +async def test_in_place_save_never_registers_as_deleted( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + """Notepad / VS Code / Obsidian truncate and rewrite the same inode.""" + p = _episode(runtime.root) + p.write_text("v1", encoding="utf-8") + rel = _rel(runtime.root, p) + await _wait_row(rel) + p.write_text("v2 -- same file, rewritten in place", encoding="utf-8") + await asyncio.sleep(_TRAILING_EVENT_GRACE_S) + row = await _row(rel) + assert row is not None + assert row.change_type != "deleted" + assert row.status == "pending" + + +async def test_atomic_replace_over_existing_target_keeps_the_row_alive( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + """Write-temp-then-``os.replace`` is how many editors save. + + Windows reports it as REMOVED(target) + RENAMED(tmp -> target); FSEvents + as a synthetic deletion of the old inode plus a move. In both, a deletion + arrives for a path that still exists. Whatever the order, the target's row + must end up live and the temp file must never have been enqueued. + """ + target = _episode(runtime.root) + target.write_text("v1", encoding="utf-8") + rel = _rel(runtime.root, target) + await _wait_row(rel) + + tmp = target.with_name(target.name + ".tmp") # not kind-matched + tmp.write_text("v2 via atomic save", encoding="utf-8") + os.replace(tmp, target) + + await asyncio.sleep(_TRAILING_EVENT_GRACE_S) + row = await _row(rel) + assert row is not None, f"target row vanished; rows: {await _all_paths()}" + assert row.change_type != "deleted", ( + "an atomic save was recorded as a deletion -- the worker would wipe " + "this file's LanceDB rows while the md is intact" + ) + assert row.status == "pending" + assert await _row(_rel(runtime.root, tmp)) is None + assert target.read_text(encoding="utf-8") == "v2 via atomic save" + + +async def test_unlink_enqueues_deleted( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + p = _episode(runtime.root) + p.write_text("doomed", encoding="utf-8") + rel = _rel(runtime.root, p) + await _wait_row(rel) + p.unlink() + row = await _wait_row(rel, lambda r: r.change_type == "deleted") + assert row.mtime == 0.0 + + +async def test_rename_within_root_moves_the_row( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + a = _episode(runtime.root, "episode-2026-01-01.md") + b = _episode(runtime.root, "episode-2026-01-02.md") + a.write_text("renamed later", encoding="utf-8") + rel_a, rel_b = _rel(runtime.root, a), _rel(runtime.root, b) + await _wait_row(rel_a) + os.rename(a, b) + assert (await _wait_row(rel_b)).change_type in {"added", "modified"} + await _wait_row(rel_a, lambda r: r.change_type == "deleted") From 6ba1d56924b4dd840ec27934c71ff0142db1a740 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 21:09:37 +0800 Subject: [PATCH 10/26] docs(windows): name the VC++ Redistributable as a native prerequisite Found on a stock Windows 11 Enterprise box while running the branch there: `import greenlet` fails with `DLL load failed while importing _greenlet`. System32 has none of the VC++ runtime DLLs and the VC++ 2015-2022 x64 Redistributable is not installed. greenlet is a C++ extension whose wheel does not bundle `msvcp140.dll`; uv's managed Python ships only the `vcruntime140*.dll` pair. SQLAlchemy's async engine depends on greenlet, so every SQLite call dies and `everos serve` cannot start. pyarrow and lancedb import fine on the same machine because their wheels bundle the runtime, which is why the four LanceDB tests passed there while all fifteen SQLite-backed watcher tests errored. windows-latest has the redistributable preinstalled, so CI is green and cannot catch this. Co-Authored-By: Claude Fable 5.1 --- docs/windows-wsl2.md | 13 +++++++++++++ docs/windows-wsl2.zh.md | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index fe0027fe4..dd815147e 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -225,3 +225,16 @@ lock, the markdown layer, search and the cascade pipeline. What is still missing: the integration suite does not run on Windows, and nobody has yet exercised a full `everos serve` on a Windows machine end to end. Until both happen, WSL2 is the path this guide stands behind. + +One prerequisite is already known from a stock Windows 11 Enterprise +machine: the **Microsoft Visual C++ Redistributable (x64)**. `greenlet`, +which SQLAlchemy's async engine depends on, is a C++ extension whose wheel +does not bundle the runtime, so without it every SQLite call fails with a +cryptic `DLL load failed while importing _greenlet`. GitHub's CI image has +the redistributable preinstalled, which is why CI cannot catch this. Install +it once, from an administrator PowerShell: + +```powershell +Invoke-WebRequest https://aka.ms/vs/17/release/vc_redist.x64.exe -OutFile "$env:TEMP\vc_redist.x64.exe" +& "$env:TEMP\vc_redist.x64.exe" /install /quiet /norestart +``` diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index 053cbf313..f9d23cd2e 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -220,3 +220,13 @@ wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重 (Windows)` 这个 job),覆盖了锁、md 层、检索和 cascade 流水线。还缺两样:集成套件没在 Windows 上跑,也没人在真的 Windows 机器上完整跑过一次 `everos serve`。这两件事没做完之前, 这篇指南只为 WSL2 这条路背书。 + +有一个前置条件已经在一台干净的 Windows 11 企业版机器上踩出来了:**Microsoft Visual C++ +运行库(x64)**。SQLAlchemy 的异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库, +缺了之后每一次 SQLite 调用都会报一句看不懂的 `DLL load failed while importing _greenlet`。 +GitHub 的 CI 镜像预装了这个运行库,所以 CI 抓不到。装一次就好,管理员 PowerShell: + +```powershell +Invoke-WebRequest https://aka.ms/vs/17/release/vc_redist.x64.exe -OutFile "$env:TEMP\vc_redist.x64.exe" +& "$env:TEMP\vc_redist.x64.exe" /install /quiet /norestart +``` From d91702a612bb46acc45ce8f575db9d803ad07af7 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 21:21:58 +0800 Subject: [PATCH 11/26] feat(windows): ship the MSVC runtime so pip install just works On a stock Windows machine `pip install everos` followed by `everos server start` died before serving anything: `import greenlet` -> `DLL load failed while importing _greenlet`. greenlet sits under SQLAlchemy's async engine, so every SQLite call was dead with it. Of the 27 compiled packages in the environment it was the only one to fail -- the rest either bundle the MSVC C++ runtime in their wheel or do not need it; greenlet's wheel links `msvcp140.dll` and does not ship it, and a stock Windows does not have it. Two pieces make the install self-sufficient, with no installer and no administrator rights: - A Windows-only dependency, `msvc-runtime; sys_platform == 'win32'`. It places the runtime DLLs in `sys.prefix` and `sys.prefix\Scripts`. - `everos/__init__.py` registers those directories with the DLL loader via `os.add_dll_directory` before anything else in the package is imported. Placement alone is not enough: a venv's `python.exe` is a launcher (uv's is a trampoline), so the loader's "application directory" is the base interpreter's and the DLLs next door are never searched. Verified on a Windows 11 Enterprise box with no VC++ redistributable: without the call greenlet fails, with it greenlet imports. Handles are kept in a module-level list -- a collected handle unregisters its directory again. Feature-detected (`getattr(os, "add_dll_directory", None)`), keeping the codebase's zero `sys.platform` checks. On POSIX, and on Windows with the redistributable already installed, the hook is a no-op. Supply-chain note, stated rather than buried: `msvc-runtime` on PyPI has no author or homepage metadata and its license field reads "Proprietary" (the Microsoft redistributable terms it repackages). It ships per-CPython win32/amd64/arm64 wheels and tracks MSVC versions (14 releases, 14.29 to 14.44). uv.lock pins it by hash. The alternative -- telling every Windows user to install the redistributable by hand -- is the manual step this change exists to remove. The three unit tests exercise the factored function with a fake prefix and a recorded `add_dll_directory`, so they hold on every platform; each was shown red by a mutation (isfile check removed, None-guard removed, Scripts dir dropped). The guides now say the runtime is handled, not that it must be installed. Full unit suite on macOS: 2573 passed, 4 skipped. Co-Authored-By: Claude Fable 5.1 --- docs/windows-wsl2.md | 12 ++-- docs/windows-wsl2.zh.md | 9 +-- pyproject.toml | 4 ++ src/everos/__init__.py | 42 +++++++++++++ tests/unit/test_package_init_windows_dlls.py | 62 ++++++++++++++++++++ uv.lock | 21 +++++++ 6 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_package_init_windows_dlls.py diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index dd815147e..c71f7a33b 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -231,10 +231,8 @@ machine: the **Microsoft Visual C++ Redistributable (x64)**. `greenlet`, which SQLAlchemy's async engine depends on, is a C++ extension whose wheel does not bundle the runtime, so without it every SQLite call fails with a cryptic `DLL load failed while importing _greenlet`. GitHub's CI image has -the redistributable preinstalled, which is why CI cannot catch this. Install -it once, from an administrator PowerShell: - -```powershell -Invoke-WebRequest https://aka.ms/vs/17/release/vc_redist.x64.exe -OutFile "$env:TEMP\vc_redist.x64.exe" -& "$env:TEMP\vc_redist.x64.exe" /install /quiet /norestart -``` +the redistributable preinstalled, which is why CI cannot catch this. EverOS +now carries the runtime itself: the Windows-only `msvc-runtime` dependency +puts the DLLs in `sys.prefix`, and `everos/__init__.py` registers that +directory with the DLL loader before anything else is imported. Nothing to +install, no administrator rights needed. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index f9d23cd2e..0762ac457 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -224,9 +224,6 @@ Windows 上跑,也没人在真的 Windows 机器上完整跑过一次 `everos 有一个前置条件已经在一台干净的 Windows 11 企业版机器上踩出来了:**Microsoft Visual C++ 运行库(x64)**。SQLAlchemy 的异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库, 缺了之后每一次 SQLite 调用都会报一句看不懂的 `DLL load failed while importing _greenlet`。 -GitHub 的 CI 镜像预装了这个运行库,所以 CI 抓不到。装一次就好,管理员 PowerShell: - -```powershell -Invoke-WebRequest https://aka.ms/vs/17/release/vc_redist.x64.exe -OutFile "$env:TEMP\vc_redist.x64.exe" -& "$env:TEMP\vc_redist.x64.exe" /install /quiet /norestart -``` +GitHub 的 CI 镜像预装了这个运行库,所以 CI 抓不到。现在 EverOS 自己带着它:Windows 专用的 +`msvc-runtime` 依赖把 DLL 放进 `sys.prefix`,`everos/__init__.py` 在导入任何东西之前把这个 +目录注册给 DLL 加载器。不用装任何东西,也不需要管理员。 diff --git a/pyproject.toml b/pyproject.toml index f8b6c22b3..befabdec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,10 @@ dependencies = [ # OME (Offline Memory Evolution) async scheduler & file I/O "apscheduler>=3.10.4,<4.0", # async strategy scheduler chassis "portalocker>=2.8.2", # cross-platform file lock for single-engine guard + # Windows only: the MSVC C++ runtime (msvcp140.dll et al.) that greenlet's + # wheel links against and a stock Windows does not ship. It lands in + # sys.prefix; everos/__init__.py registers that dir with the DLL loader. + "msvc-runtime>=14.44; sys_platform == 'win32'", "watchfiles>=0.21.0", # native fs watcher for config hot reload "anyio>=4.0", # Async file I/O (anyio.Path, to_thread.run_sync) for the markdown layer diff --git a/src/everos/__init__.py b/src/everos/__init__.py index c04b28d18..1fd5f5dca 100644 --- a/src/everos/__init__.py +++ b/src/everos/__init__.py @@ -1,5 +1,9 @@ """everos — md-first memory extraction framework.""" +from __future__ import annotations + +import os +import sys from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version @@ -9,3 +13,41 @@ # Editable install without dist-info, or running from a source tree that # was never installed. Fall back to a sentinel rather than crash imports. __version__ = "0.0.0+unknown" + + +# Handles returned by ``os.add_dll_directory``; a collected handle would +# silently unregister its directory again, so they live for the process. +_dll_dir_handles: list[object] = [] + + +def _register_runtime_dll_dirs(prefix: str = sys.prefix) -> list[str]: + """On Windows, let compiled extensions find the MSVC C++ runtime. + + ``greenlet`` -- under SQLAlchemy's async engine, so under every SQLite + call -- is a C++ extension whose wheel does not bundle ``msvcp140.dll``, + and a stock Windows install does not ship it: ``import greenlet`` dies + with ``DLL load failed while importing _greenlet``. The ``msvc-runtime`` + dependency drops the runtime DLLs into ``sys.prefix`` and its ``Scripts`` + dir, but that alone is not enough: a venv's ``python.exe`` is a launcher + (uv's is a trampoline), so the loader's "application directory" is the + base interpreter's and the DLLs sit unseen next door. Registering the + directories with ``os.add_dll_directory`` fixes the search path for every + extension imported afterwards -- which is why this runs from the package + ``__init__``: nothing in everos is imported before it. + + Feature-detected rather than platform-checked: ``os.add_dll_directory`` + exists only on Windows, so elsewhere this is a no-op. Returns the + directories it registered. + """ + add = getattr(os, "add_dll_directory", None) + if add is None: + return [] + registered: list[str] = [] + for d in (prefix, os.path.join(prefix, "Scripts")): + if os.path.isfile(os.path.join(d, "msvcp140.dll")): + _dll_dir_handles.append(add(d)) + registered.append(d) + return registered + + +_register_runtime_dll_dirs() diff --git a/tests/unit/test_package_init_windows_dlls.py b/tests/unit/test_package_init_windows_dlls.py new file mode 100644 index 000000000..c67b6f4cd --- /dev/null +++ b/tests/unit/test_package_init_windows_dlls.py @@ -0,0 +1,62 @@ +"""The package-init hook that lets compiled extensions find the MSVC runtime. + +The hook runs at ``import everos``; these tests call the factored function +with a fake prefix and a recorded ``os.add_dll_directory`` so they hold on +every platform. What only Windows can prove -- that greenlet then actually +loads on a machine without the redistributable -- was proven by hand on a +stock Windows 11 Enterprise box; see the commit that introduced this. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import everos + + +def _recording_add(seen: list[str]): # type: ignore[no-untyped-def] + def add(d: str) -> object: + seen.append(d) + return object() + + return add + + +def test_registers_prefix_and_scripts_when_the_runtime_dll_is_there( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "msvcp140.dll").write_bytes(b"") + (tmp_path / "Scripts").mkdir() + (tmp_path / "Scripts" / "msvcp140.dll").write_bytes(b"") + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + got = everos._register_runtime_dll_dirs(str(tmp_path)) + + assert got == seen == [str(tmp_path), str(tmp_path / "Scripts")] + + +def test_skips_directories_without_the_dll( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Registering a dir with no runtime in it would only widen the search + path for nothing; the dll is the signal that msvc-runtime is present.""" + (tmp_path / "Scripts").mkdir() + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + assert everos._register_runtime_dll_dirs(str(tmp_path)) == [] + assert seen == [] + + +def test_is_a_no_op_where_the_os_has_no_add_dll_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """POSIX: the dll may even be there (a shared checkout), still nothing.""" + (tmp_path / "msvcp140.dll").write_bytes(b"") + monkeypatch.delattr(os, "add_dll_directory", raising=False) + + assert everos._register_runtime_dll_dirs(str(tmp_path)) == [] diff --git a/uv.lock b/uv.lock index 3b55b74de..a273a9385 100644 --- a/uv.lock +++ b/uv.lock @@ -598,6 +598,7 @@ dependencies = [ { name = "greenlet" }, { name = "jieba" }, { name = "lancedb" }, + { name = "msvc-runtime", marker = "sys_platform == 'win32'" }, { name = "openai" }, { name = "portalocker" }, { name = "prometheus-client" }, @@ -661,6 +662,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.0" }, { name = "jieba", specifier = ">=0.42.1,<1.0" }, { name = "lancedb", specifier = ">=0.34.0,<0.35.0" }, + { name = "msvc-runtime", marker = "sys_platform == 'win32'", specifier = ">=14.44" }, { name = "openai", specifier = ">=1.0.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.27.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27.0" }, @@ -1356,6 +1358,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msvc-runtime" +version = "14.44.35112" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/7d/f86050e24fd78e76e56145b326bfd3e7ae2c028e87e0732802a0c5144223/msvc_runtime-14.44.35112-cp312-cp312-win32.whl", hash = "sha256:7c5af6756056dbf6f3593f69b0f4575dbbd9e8ec10d3108ff00437da811a7b67", size = 1921505, upload-time = "2025-08-08T03:57:54.509Z" }, + { url = "https://files.pythonhosted.org/packages/21/3b/134d04268ab8e35853cd007582076429b45d60d6abb1036d159be9c50342/msvc_runtime-14.44.35112-cp312-cp312-win_amd64.whl", hash = "sha256:32f9c706009e16ccc319d6947ce3bffe20e5192bee52b18cf48313f9e7bedfbe", size = 1921899, upload-time = "2025-08-08T03:57:55.648Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/f245d6d30a76655293575683aaa5f72a7a70fc8efa1637bf6fd9e5e7e665/msvc_runtime-14.44.35112-cp312-cp312-win_arm64.whl", hash = "sha256:62f272c51f30ce0de1b7a796e50af4d3911b76cbc9bc909917435d094f68d5fe", size = 3208186, upload-time = "2025-08-08T03:57:57.401Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/590fcbf92fee8f0d3ac46cd8b32069b058d6ce7bfb8be6c07a35d9c9188b/msvc_runtime-14.44.35112-cp313-cp313-win32.whl", hash = "sha256:e60781114472891c34fe14cc45a19ac28d80e0084dfbce882b7475c40c743152", size = 1921506, upload-time = "2025-08-08T03:57:58.888Z" }, + { url = "https://files.pythonhosted.org/packages/fe/17/7a1eace5a7c6083bd99db861625c08bb37010dfbe90ed7a0194ae0281689/msvc_runtime-14.44.35112-cp313-cp313-win_amd64.whl", hash = "sha256:d4f6cf106aaf235f2a90952f9ec7de49e9946323880d051abf9745a6d4bf60bf", size = 1921893, upload-time = "2025-08-08T03:58:00.234Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/88d6183846ad33b4a8fcb82fc86f26f9b7e21d776a925b6c8ab89618234a/msvc_runtime-14.44.35112-cp313-cp313-win_arm64.whl", hash = "sha256:359152dc9769559fee4ffdaa19f1fc2b72ab1535631a6105116776b263a235ac", size = 3208191, upload-time = "2025-08-08T03:58:02.35Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/b9ee5402fe9daf6f4dba458f11238289c606df05e0cc9f0750d5ada4ff3b/msvc_runtime-14.44.35112-cp314-cp314-win32.whl", hash = "sha256:99c22caa2755c76cfa34b741e96bee99c461480d709fb9c582492ccc0f03c7ae", size = 1976509, upload-time = "2025-08-08T03:58:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/d2/48/7fb574a4bfa697574a7dc7105bc15ef6882125e7c77c794c5e73429de832/msvc_runtime-14.44.35112-cp314-cp314-win_amd64.whl", hash = "sha256:af179a6c552070e660493765efd5856283af3fed971ddc3577203a7d3f1ee8e7", size = 1976906, upload-time = "2025-08-08T03:58:05.079Z" }, + { url = "https://files.pythonhosted.org/packages/93/b3/bdb7c215d1080ea08deb5e151dc261f74f5b1d25544947e31e902eb1253f/msvc_runtime-14.44.35112-cp314-cp314-win_arm64.whl", hash = "sha256:81eb9346ab2a269ea934bf13492fe5c1cd50af871a2fbb973f0a514031e81b6a", size = 3300540, upload-time = "2025-08-08T03:58:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/95/99/271ff26cf07d875766efa4a376fe9415c033f07fe85a1371a071aaa37aca/msvc_runtime-14.44.35112-cp314-cp314t-win32.whl", hash = "sha256:38d64f2db707be465a79c87f0bec347249af89614550d10ed0345f6a7f80cf73", size = 1976674, upload-time = "2025-08-08T03:58:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/76fd58fd64209e7f3dbfe59237e2c49ee6bdd4c21eba9efe5a2354dacd3e/msvc_runtime-14.44.35112-cp314-cp314t-win_amd64.whl", hash = "sha256:1105f8f8117ee210b85bd8c771dbefff8f8d35771ee7ce3291ddd91d2a03eff1", size = 1977068, upload-time = "2025-08-08T03:58:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/e2/38/b1ff1734e257c99a6450c60a1632d0c9ddbdb049ee4095439a591a0949a5/msvc_runtime-14.44.35112-cp314-cp314t-win_arm64.whl", hash = "sha256:a2ab094e35fa04172f6fd5cfc7d2a2017755ea9c3ff39cad5c2a4456e0bff689", size = 3300780, upload-time = "2025-08-08T03:58:11.061Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" From 9c0fe18c4521957fbb32a787d2655a45983f3274 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 21:24:38 +0800 Subject: [PATCH 12/26] test: import everos first so the DLL hook runs before sqlalchemy On the stock Windows box the runtime hook worked from the command line (`import everos, greenlet` loads greenlet 3.5.3) yet the 15 SQLite-backed watcher tests still errored under pytest. sqlalchemy imports greenlet at import time and records the failure; the first line of the test module is `from sqlmodel import ...`, so greenlet was already broken by the time the package init ran. conftest.py is loaded before any test module, so importing everos there first makes the whole unit suite exercise the shipped runtime the way the `everos` console script does. The comment names what this does not fix: a host application that imports sqlalchemy before everos. That needs the registration to run at interpreter start (a .pth), which is a packaging change and a separate decision. Co-Authored-By: Claude Fable 5.1 --- tests/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index b373d65ff..e41539cad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,16 @@ from __future__ import annotations +# First import in the test process, on purpose. everos/__init__.py registers +# the MSVC runtime DLL directory with the Windows loader, and that only helps +# extensions imported *after* it -- sqlalchemy imports greenlet the moment it +# is imported, and a test module's first line is often `from sqlmodel import`. +# Any host application that imports sqlalchemy before everos has the same +# problem; the package-init hook cannot reach it. Until a startup .pth ships +# the registration for every interpreter in the environment, this line keeps +# the suite meaningful on a Windows machine without the VC++ redistributable. +import everos # noqa: F401 isort: skip + import json from collections.abc import Iterator from pathlib import Path From 167473da83bad7710e6bc38f05eb178d2b37bbfb Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 22:08:45 +0800 Subject: [PATCH 13/26] test: make the slow/live selection collectable and survivable on Windows Running `-m "slow or live_llm"` on the Windows box surfaced three problems in the suite itself, none in the product. 1. `tests/e2e/test_reflection_e2e.py` aborted collection of the whole run (`ModuleNotFoundError: No module named 'adapters'`), so zero tests executed. It imported `benchmarks.run` as a package, but since #425 the runner imports its siblings bare (`import adapters`) and only resolves with `benchmarks/` itself on sys.path -- the unit tests that use it already do that dance; this file now mirrors them. Verified against two detached worktrees: origin/main fails, e8612b9 (v1.3.0, before #425) collects. Not Windows-specific; CI never collects this file. The same refactor moved the prompts into `adapters/locomo.py`, added a `config` parameter to `_build_context`, and a `{current_date_line}` placeholder to ANSWER_PROMPT; the script body is adapted to those by signature. It has no `def test_` -- it is a manual driver with a `test_` filename -- and the adapted body has not been executed here; that needs a Tier 2 server and a LoCoMo run. 2. `test_real_sigint_during_phase_await_returns_130_with_resume_hint` sends itself `os.kill(os.getpid(), signal.SIGINT)`. On Windows that call is TerminateProcess with the signal number as exit code, so the test killed the pytest process mid-run: 27 of 29 had run, the summary and every traceback were lost, and `pytest_exit=2` was the literal SIGINT value, not pytest's "interrupted". Skipped on win32 with the mechanism in the reason. 3. `test_add_html_file_uri_parsed_into_buffer` built its uri as `f"file://{doc}"`, which on Windows is `file://C:\...` -- drive letter in the host slot. Same fix as the unit tests in f7a31c7: `Path.as_uri()`. Collection of the live selection is now clean (29 selected, 0 errors); the default `test_backfill_flags.py` selection still passes locally. Co-Authored-By: Claude Fable 5.1 --- tests/e2e/test_multimodal_add_e2e.py | 2 +- tests/e2e/test_reflection_e2e.py | 45 ++++++++++++------- .../test_cli/test_backfill_flags.py | 6 +++ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/tests/e2e/test_multimodal_add_e2e.py b/tests/e2e/test_multimodal_add_e2e.py index 31134635f..20b85a18e 100644 --- a/tests/e2e/test_multimodal_add_e2e.py +++ b/tests/e2e/test_multimodal_add_e2e.py @@ -129,7 +129,7 @@ async def test_add_html_file_uri_parsed_into_buffer( "sender_id": "alice", "role": "user", "timestamp": 1780304400000, - "content": [{"type": "html", "uri": f"file://{doc}"}], + "content": [{"type": "html", "uri": doc.as_uri()}], } ], }, diff --git a/tests/e2e/test_reflection_e2e.py b/tests/e2e/test_reflection_e2e.py index 882483b57..03502b8d8 100644 --- a/tests/e2e/test_reflection_e2e.py +++ b/tests/e2e/test_reflection_e2e.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import importlib import json import logging import sys @@ -17,22 +18,30 @@ from pathlib import Path from typing import Any -# benchmarks/run.py is the benchmark runner; add repo root to sys.path so -# the benchmarks package is importable from any working directory. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from benchmarks.run import ( - ANSWER_PROMPT, - JUDGE_SYSTEM_PROMPT, - JUDGE_USER_PROMPT, - EverosClient, - LLMClientPool, - _build_context, - _extract_final_answer, - _extract_json, - _parse_session_timestamp, - print_section, -) +# benchmarks/run.py is a script, not a package module: it imports its +# siblings bare (``import adapters``), which only resolves with +# ``benchmarks/`` itself on sys.path. Importing it as ``benchmarks.run`` +# therefore fails at collection and, by pytest's default, aborts the whole +# run before a single test executes. The unit tests that use the runner do +# this same dance (``test_benchmark_cli_portability.py``); mirror it. +_BENCH = Path(__file__).resolve().parents[2] / "benchmarks" +if str(_BENCH) not in sys.path: + sys.path.insert(0, str(_BENCH)) +_run = importlib.import_module("run") +# The prompts moved from run.py into the per-benchmark adapters when the +# runner was generalised (#425); this suite is LoCoMo conv_0, so LoCoMo's. +_locomo = importlib.import_module("adapters.locomo") +_LOCOMO_CONFIG = importlib.import_module("config").BenchmarkConfig.from_toml("locomo") +ANSWER_PROMPT = _locomo.ANSWER_PROMPT +JUDGE_SYSTEM_PROMPT = _locomo.JUDGE_SYSTEM_PROMPT +JUDGE_USER_PROMPT = _locomo.JUDGE_USER_PROMPT +EverosClient = _run.EverosClient +LLMClientPool = _run.LLMClientPool +_build_context = _run._build_context +_extract_final_answer = _run._extract_final_answer +_extract_json = _run._extract_json +_parse_session_timestamp = _run._parse_session_timestamp +print_section = _run.print_section logger = logging.getLogger(__name__) @@ -292,8 +301,10 @@ def answer_and_judge( search_data.get("profiles", []), speaker_a, speaker_b, + _LOCOMO_CONFIG, ) - prompt = ANSWER_PROMPT.format(context=context, question=query) + # No session date is known here; the runner passes "" in that case too. + prompt = ANSWER_PROMPT.format(context=context, current_date_line="", question=query) try: resp = llm_client.chat.completions.create( model=llm_model, diff --git a/tests/integration/test_cli/test_backfill_flags.py b/tests/integration/test_cli/test_backfill_flags.py index 4a0abdd7c..71c88f18f 100644 --- a/tests/integration/test_cli/test_backfill_flags.py +++ b/tests/integration/test_cli/test_backfill_flags.py @@ -38,6 +38,7 @@ import hashlib import os import signal +import sys import threading import time from collections.abc import AsyncIterator @@ -237,6 +238,11 @@ async def _raise_interrupt(*_args: object, **_kwargs: object) -> None: @pytest.mark.slow +@pytest.mark.skipif( + sys.platform == "win32", + reason="os.kill(pid, SIGINT) is TerminateProcess on Windows: it kills the " + "pytest process itself (exit code 2) instead of delivering a Ctrl-C", +) def test_real_sigint_during_phase_await_returns_130_with_resume_hint( backfill_runtime: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 89ac544f787b703a666d025685c546c210a22a92 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 22:17:34 +0800 Subject: [PATCH 14/26] test(cascade): rename test asserts the source leg after a sweep `test_rename_within_root_moves_the_row` failed once on windows-latest (2572 passed, 1 failed): after `os.rename(a, b)` the row for `a` sat at ``added`` for the full 15 s while `b` was ``added`` as expected. ReadDirectoryChangesW reports a rename as RENAMED_OLD then RENAMED_NEW and watchdog pairs them only when both arrive in the same read -- the pairing variable is local to one `queue_events` call and starts empty. Split across two reads, the OLD leg is dropped and the watcher never sees the source path. The scanner is the system's answer to exactly this: a state row whose path is gone from disk is re-emitted as ``deleted`` on the next sweep. The test asserted more than the watcher promises on Windows. It now asserts the destination leg on the watcher, then runs one `scan_once` and asserts the source leg -- the contract the system actually offers, on every backend. Nothing was loosened: both legs are still required. Co-Authored-By: Claude Fable 5.1 --- .../test_cascade/test_watcher_events.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_memory/test_cascade/test_watcher_events.py b/tests/unit/test_memory/test_cascade/test_watcher_events.py index ec4d50e11..b5b2c1df3 100644 --- a/tests/unit/test_memory/test_cascade/test_watcher_events.py +++ b/tests/unit/test_memory/test_cascade/test_watcher_events.py @@ -46,6 +46,7 @@ ) from everos.memory.cascade import watcher as watcher_mod from everos.memory.cascade.registry import match_kind +from everos.memory.cascade.scanner import CascadeScanner from everos.memory.cascade.watcher import CascadeWatcher, _enqueue_async, _Handler _EPISODE_DIR = ("default_app", "default_project", "users", "u1", "episodes") @@ -373,6 +374,19 @@ async def test_unlink_enqueues_deleted( async def test_rename_within_root_moves_the_row( runtime: MemoryRoot, watcher: CascadeWatcher ) -> None: + """The destination is the watcher's job; the source is the sweep's. + + ReadDirectoryChangesW reports a rename as RENAMED_OLD then RENAMED_NEW, + and watchdog pairs them only when both land in the same read -- the + pairing variable is local to one ``queue_events`` call. Split across two + reads, the OLD leg is dropped and the watcher never learns the source + path (CI saw the source row sit at ``added`` for 15 s). That is not a + defect the watcher can fix; it is why the scanner exists: a state row + whose path is gone from disk is re-emitted as ``deleted`` on the next + sweep. So assert the immediate leg on the watcher and the source leg + after one sweep, which is the contract the system actually offers on + every backend. + """ a = _episode(runtime.root, "episode-2026-01-01.md") b = _episode(runtime.root, "episode-2026-01-02.md") a.write_text("renamed later", encoding="utf-8") @@ -380,4 +394,5 @@ async def test_rename_within_root_moves_the_row( await _wait_row(rel_a) os.rename(a, b) assert (await _wait_row(rel_b)).change_type in {"added", "modified"} - await _wait_row(rel_a, lambda r: r.change_type == "deleted") + await CascadeScanner(runtime).scan_once(kinds={"episode"}) + await _wait_row(rel_a, lambda r: r.change_type == "deleted", deadline=5) From 47b96eb618148184e83a6e970b3442e78da555ab Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 22:46:53 +0800 Subject: [PATCH 15/26] fix(markdown): retry the staging swap past a Windows sharing violation The soak dry run on Windows killed its load feeder 62 s in with `PermissionError: [WinError 5]` from `os.replace(tmp, target)`: the target was open in another process at that instant (the cascade worker reading it, or Defender scanning the file it had just written). On Windows a file another process holds open cannot be replaced; on POSIX it always can. The server's own markdown writer is the same primitive at `writer.py:158`, so under the same conditions `/add` would have failed the same way. `_replace_with_retry` retries only `PermissionError`, with exponential backoff (8 attempts, ~5 s of patience), and re-raises unchanged after the budget -- nothing is swallowed, and any other `OSError` is not retried at all. Each attempt is still a single atomic `os.replace`. On POSIX the first attempt succeeds, so behaviour there is unchanged; no platform check. Three tests fake `os.replace` so the behaviour is pinned on every platform: transient refusal retried and the write lands (with the backoff sequence asserted), persistent refusal propagates after exactly the budget with the staging file cleaned up, and a non-permission error is not retried. Each was shown red by a mutation (retry bypassed, `except OSError`, budget check removed). Full unit suite: 2576 passed, 4 skipped. Co-Authored-By: Claude Fable 5.1 --- .../core/persistence/markdown/writer.py | 36 +++++++- .../test_writer_replace_retry.py | 89 +++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py diff --git a/src/everos/core/persistence/markdown/writer.py b/src/everos/core/persistence/markdown/writer.py index 86011b680..1f029da41 100644 --- a/src/everos/core/persistence/markdown/writer.py +++ b/src/everos/core/persistence/markdown/writer.py @@ -41,6 +41,7 @@ import asyncio import contextlib import os +import time import uuid from collections.abc import Mapping, Sequence from pathlib import Path @@ -155,7 +156,7 @@ async def write(self, path: Path, content: str) -> Path: tmp = target.parent / f".{target.name}.tmp.{uuid.uuid4().hex}" try: await anyio.to_thread.run_sync(_write_and_fsync, tmp, content) - await anyio.to_thread.run_sync(os.replace, tmp, target) + await anyio.to_thread.run_sync(_replace_with_retry, tmp, target) except Exception: # Best-effort cleanup of the staging file on failure. await _unlink_quiet(tmp) @@ -338,6 +339,39 @@ async def _append_entries_unlocked( return await self.write_markdown(target, frontmatter=meta, body=body) +_REPLACE_ATTEMPTS = 8 +_REPLACE_FIRST_BACKOFF_S = 0.02 # doubles each time: ~5 s of patience in total + + +def _replace_with_retry(tmp: Path, target: Path) -> None: + """``os.replace`` that outlasts a Windows sharing violation. + + On Windows a file some other process holds open cannot be replaced: the + cascade worker reading it, an antivirus scan right after the last write, + an editor with it open -- ``os.replace`` raises ``PermissionError`` + (WinError 5 / 32). POSIX never does; there the first attempt succeeds and + this is a plain ``os.replace``. Those holds last milliseconds, so a short + exponential backoff is the standard idiom (git, pip and uv all do it). + Only ``PermissionError`` is retried, each attempt is still one atomic + ``os.replace``, and after the budget the error propagates unchanged -- + nothing is swallowed. Sync on purpose: it runs in the same worker thread + as the fsync'ed staging write. + + Surfaced by the Windows soak, where the load feeder hit exactly this 62 s + in; the server's own writer here is the same primitive. + """ + delay = _REPLACE_FIRST_BACKOFF_S + for attempt in range(_REPLACE_ATTEMPTS): + try: + os.replace(tmp, target) + return + except PermissionError: + if attempt == _REPLACE_ATTEMPTS - 1: + raise + time.sleep(delay) + delay *= 2 + + def _write_and_fsync(tmp: Path, content: str) -> None: """Sync helper: write + fsync the staging file. Offloaded to a thread.""" with open(tmp, "w", encoding="utf-8") as fh: diff --git a/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py b/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py new file mode 100644 index 000000000..010045f87 --- /dev/null +++ b/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py @@ -0,0 +1,89 @@ +"""The staging-to-target swap must outlast a Windows sharing violation. + +On Windows, ``os.replace`` onto a file another process has open raises +``PermissionError``; on POSIX it never does. These tests fake ``os.replace`` +so the behaviour is pinned on every platform: a transient refusal is retried +and the write lands, a persistent one propagates after the budget with the +staging file cleaned up, and any other error is not retried at all. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +import pytest + +from everos.core.persistence import MemoryRoot +from everos.core.persistence.markdown import writer as writer_mod +from everos.core.persistence.markdown.writer import MarkdownWriter + + +class _Refusing: + """Stand-in for a Windows loader that refuses the first ``n`` replaces.""" + + def __init__(self, refuse: int, error: type[OSError] = PermissionError) -> None: + self.refuse = refuse + self.error = error + self.calls = 0 + self._real = os.replace + + def __call__(self, src: str | Path, dst: str | Path) -> None: + self.calls += 1 + if self.calls <= self.refuse: + raise self.error(5, "The process cannot access the file", str(dst)) + self._real(src, dst) + + +@pytest.fixture +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: + slept: list[float] = [] + monkeypatch.setattr(time, "sleep", slept.append) + return slept + + +async def test_transient_refusal_is_retried_and_the_write_lands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + refusing = _Refusing(refuse=2) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "survived") + + assert target.read_text(encoding="utf-8") == "survived" + assert refusing.calls == 3 + assert no_sleep == [0.02, 0.04], "exponential backoff between the two refusals" + assert not list(target.parent.glob(".*.tmp.*")), "no staging file left behind" + + +async def test_persistent_refusal_propagates_after_the_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + refusing = _Refusing(refuse=10**6) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + with pytest.raises(PermissionError): + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "never lands") + + assert refusing.calls == writer_mod._REPLACE_ATTEMPTS + assert len(no_sleep) == writer_mod._REPLACE_ATTEMPTS - 1 + assert not target.exists() + assert not list(target.parent.glob(".*.tmp.*")), "staging file cleaned on failure" + + +async def test_other_errors_are_not_retried( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + """Retrying would only delay a real failure (disk full, bad path).""" + refusing = _Refusing(refuse=10**6, error=FileNotFoundError) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + with pytest.raises(FileNotFoundError): + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "x") + + assert refusing.calls == 1 + assert no_sleep == [] From 5692bafefbac167668b1a19222a8157973390c79 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 23:49:48 +0800 Subject: [PATCH 16/26] fix(ome): never hand a strategy an event class it did not declare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch(strategy_filter=...)` skipped the subscription lookup, so a bare `ManualTick` from `POST /api/v2/ome/trigger` reached strategies declared `Immediate(on=[AgentCaseExtracted])` / `Immediate(on=[SkillClusterUpdated])`. Their handlers read `event.agent_id` → AttributeError → dead_letter. Found while driving the four memory kinds on the Windows box. The filter now keeps the name restriction but still requires the strategy to subscribe to `type(event)`; a manual tick at a business-event strategy comes back as `not_dispatched`. Strategies that list `ManualTick` are unchanged. Co-Authored-By: Claude Fable 5.1 --- src/everos/infra/ome/_dispatch/dispatcher.py | 16 +++++++++---- src/everos/infra/ome/engine.py | 4 +++- .../test_infra/test_ome/test_dispatcher.py | 15 +++++++++++- tests/unit/test_infra/test_ome/test_engine.py | 24 +++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/everos/infra/ome/_dispatch/dispatcher.py b/src/everos/infra/ome/_dispatch/dispatcher.py index 525375fbf..90d4166ba 100644 --- a/src/everos/infra/ome/_dispatch/dispatcher.py +++ b/src/everos/infra/ome/_dispatch/dispatcher.py @@ -61,10 +61,14 @@ async def dispatch( force_enabled: Bypass the ``meta.enabled`` gate. ``applies_to`` and the counter still apply. Used by manual triggers with ``force=True``. - strategy_filter: Restrict to one strategy name regardless of - whether it subscribes to ``type(event)``. Manual triggers - use this when targeting a strategy with a caller-supplied - event. Raises ``KeyError`` if the name is not registered. + strategy_filter: Restrict to one strategy name. The strategy + must still subscribe to ``type(event)`` — a handler is never + handed an event class it did not declare (a bare + ``ManualTick`` aimed at ``Immediate(on=[AgentCaseExtracted])`` + would crash reading fields the tick does not carry). Manual + triggers use this when targeting a strategy with a + caller-supplied event. Raises ``KeyError`` if the name is + not registered. ``applies_to`` callables raised by a single strategy are caught, logged, and treated as ``False`` for that strategy alone — sibling @@ -72,7 +76,9 @@ async def dispatch( I/O) propagate. """ if strategy_filter is not None: - metas: list[StrategyMeta] = [self._registry.get(strategy_filter)] + target = self._registry.get(strategy_filter) + subscribed = {m.name for m in self._registry.lookup_by_event(type(event))} + metas: list[StrategyMeta] = [target] if target.name in subscribed else [] else: metas = list(self._registry.lookup_by_event(type(event))) out: list[tuple[StrategyMeta, str]] = [] diff --git a/src/everos/infra/ome/engine.py b/src/everos/infra/ome/engine.py index 212cef761..0c474bd3d 100644 --- a/src/everos/infra/ome/engine.py +++ b/src/everos/infra/ome/engine.py @@ -618,7 +618,9 @@ async def trigger_manual( ) -> tuple[BaseEvent, list[tuple[StrategyMeta, str]]]: """Manually trigger one strategy. - - ``event=None`` → engine self-emits ``ManualTick(strategy_name=name)`` + - ``event=None`` → engine self-emits ``ManualTick(strategy_name=name)``; + a strategy that does not list ``ManualTick`` in its trigger is not + dispatched (``routes`` comes back empty) - ``force=True`` → bypass the ``enabled`` gate (``applies_to`` and ``Counter`` still apply) diff --git a/tests/unit/test_infra/test_ome/test_dispatcher.py b/tests/unit/test_infra/test_ome/test_dispatcher.py index af1711d7b..05d463a7d 100644 --- a/tests/unit/test_infra/test_ome/test_dispatcher.py +++ b/tests/unit/test_infra/test_ome/test_dispatcher.py @@ -11,7 +11,7 @@ from everos.infra.ome._stores.storage import OMEStorage from everos.infra.ome.context import StrategyContext from everos.infra.ome.decorator import offline_strategy -from everos.infra.ome.events import BaseEvent, CronTick +from everos.infra.ome.events import BaseEvent, CronTick, ManualTick from everos.infra.ome.gates import Counter from everos.infra.ome.triggers import Cron, Immediate @@ -169,6 +169,19 @@ async def test_dispatch_strategy_filter_scopes_to_single_strategy( assert [m.name for m, _ in routes] == ["s_a"] +@pytest.mark.asyncio +async def test_dispatch_strategy_filter_refuses_undeclared_event_class( + dispatcher: EventDispatcher, +) -> None: + # A bare ManualTick aimed at an ``Immediate(on=[_E])`` strategy must not + # reach the handler: it reads ``_E`` fields the tick does not carry. + dispatcher._registry.register(_make_strategy("s_a")) + routes = await dispatcher.dispatch( + ManualTick(strategy_name="s_a"), strategy_filter="s_a" + ) + assert routes == [] + + @pytest.mark.asyncio async def test_dispatch_strategy_filter_unknown_raises( dispatcher: EventDispatcher, diff --git a/tests/unit/test_infra/test_ome/test_engine.py b/tests/unit/test_infra/test_ome/test_engine.py index 682ce3f63..0d7f57710 100644 --- a/tests/unit/test_infra/test_ome/test_engine.py +++ b/tests/unit/test_infra/test_ome/test_engine.py @@ -292,6 +292,30 @@ async def s(event: ManualTick, ctx: StrategyContext) -> None: assert len(seen) == 1 +@pytest.mark.asyncio +async def test_trigger_manual_default_tick_skips_strategy_not_listening_to_it( + cfg: OMEConfig, +) -> None: + # ``POST /ome/trigger`` on a business-event strategy: no ManualTick in + # ``on`` → empty routes (``not_dispatched``), handler never sees the tick. + seen: list = [] + + @offline_strategy(name="on_e_only", trigger=Immediate(on=[_E]), emits=[]) + async def s(event: _E, ctx: StrategyContext) -> None: + seen.append(event) + + engine = OfflineEngine(config=cfg) + engine.register(s) + await engine.start() + try: + _, routes = await engine.trigger_manual("on_e_only") + await asyncio.sleep(0.2) + finally: + await engine.stop() + assert routes == [] + assert seen == [] + + @pytest.mark.asyncio async def test_trigger_manual_force_bypasses_enabled( cfg: OMEConfig, From 06509b220c4bf26f63bd8db4bf09f25155282caf Mon Sep 17 00:00:00 2001 From: zhanghui Date: Wed, 23 Sep 2026 23:49:48 +0800 Subject: [PATCH 17/26] fix(api): reject an orphan tool row with 422 instead of 500 A `role: "tool"` message without `tool_call_id` passed DTO validation and only failed deep in extraction (`_boundary._to_conversation_item` raises ValueError, retried as a transient LLM parse error, then 500). Refuse it at the DTO with a `model_validator`; `docs/api.md` states the requirement. Co-Authored-By: Claude Fable 5.1 --- docs/api.md | 3 +- src/everos/entrypoints/api/routes/memorize.py | 10 ++- .../test_memorize_route_validation.py | 63 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index d19361f4e..4dbf92665 100644 --- a/docs/api.md +++ b/docs/api.md @@ -275,7 +275,8 @@ file (`episode-.md` etc.). assistant emitted in this turn (OpenAI Chat Completions shape). **`tool_call_id`** — When `role: "tool"`, the `id` of the call this -message is the response to. +message is the response to. Required for `role: "tool"`; a tool row +without it is rejected with `422`. ### ContentItem diff --git a/src/everos/entrypoints/api/routes/memorize.py b/src/everos/entrypoints/api/routes/memorize.py index 0d1dd6512..c22c71100 100644 --- a/src/everos/entrypoints/api/routes/memorize.py +++ b/src/everos/entrypoints/api/routes/memorize.py @@ -14,7 +14,7 @@ from typing import Annotated, Any, Literal from fastapi import APIRouter, Request -from pydantic import AfterValidator, BaseModel, ConfigDict, Field +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator from everos.entrypoints.api.utils import extract_request_id from everos.service import memorize @@ -111,6 +111,14 @@ class MessageItemDTO(BaseModel): tool_calls: list[ToolCallDTO] | None = None tool_call_id: str | None = None + @model_validator(mode="after") + def _tool_row_needs_call_id(self) -> MessageItemDTO: + # An orphan tool row cannot be mapped to a ConversationItem and would + # surface as a 500 from deep inside extraction; refuse it here (422). + if self.role == "tool" and not self.tool_call_id: + raise ValueError("tool_call_id is required when role is 'tool'") + return self + class MemorizeAddRequest(BaseModel): session_id: str = Field(..., min_length=1, max_length=128) diff --git a/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py b/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py index 826def4d8..0cd80f019 100644 --- a/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py +++ b/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py @@ -10,15 +10,36 @@ from __future__ import annotations +from collections.abc import AsyncIterator +from pathlib import Path + import pytest +from httpx import ASGITransport, AsyncClient from pydantic import ValidationError +from everos.config import load_settings +from everos.entrypoints.api.app import create_app from everos.entrypoints.api.routes.memorize import ( MemorizeAddRequest, MessageItemDTO, ) +@pytest.fixture +async def client( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[AsyncClient]: + """FastAPI app with no lifespan; nothing past DTO validation is reached.""" + monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) + load_settings.cache_clear() + app = create_app(lifespan_providers=[]) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + yield c + load_settings.cache_clear() + + def _message(sender_id: str) -> MessageItemDTO: return MessageItemDTO( sender_id=sender_id, @@ -62,6 +83,48 @@ def test_message_item_accepts_path_safe_sender_id(good_sender_id: str) -> None: assert _message(good_sender_id).sender_id == good_sender_id +def test_message_item_rejects_tool_role_without_call_id() -> None: + # An orphan tool row used to travel to ``_boundary`` and 500 from + # inside extraction; it is refused at the DTO now. + with pytest.raises(ValidationError, match="tool_call_id"): + MessageItemDTO( + sender_id="agent", + role="tool", + timestamp=1_700_000_000_000, + content="x", + ) + + +def test_message_item_accepts_tool_role_with_call_id() -> None: + m = MessageItemDTO( + sender_id="agent", + role="tool", + timestamp=1_700_000_000_000, + content="x", + tool_call_id="call_1", + ) + assert m.tool_call_id == "call_1" + + +async def test_add_orphan_tool_row_is_422_not_500(client: AsyncClient) -> None: + resp = await client.post( + "/api/v1/memory/add", + json={ + "session_id": "s1", + "messages": [ + { + "sender_id": "agent", + "role": "tool", + "timestamp": 1_700_000_000_000, + "content": "x", + } + ], + }, + ) + assert resp.status_code == 422 + assert "tool_call_id" in resp.text + + def test_add_request_rejects_traversal_sender_id_in_messages() -> None: # The guard fires through the nested message list, not just on a bare DTO. with pytest.raises(ValidationError): From c99db496906d0ded0389bc0c9073ec214c5f7c85 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 00:46:17 +0800 Subject: [PATCH 18/26] fix(ome): let a manual trigger reach a Cron strategy again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5692baf made `dispatch(strategy_filter=...)` require a subscription to `type(event)`, which also refused the bare ManualTick that `POST /ome/trigger {"name": "reflect_episodes", "force": true}` sends at a Cron strategy — the documented way to run reflection on demand (docs/reflection.md). Caught by the agent-sim driver against a live server; the unit tests only covered `Immediate(on=[ManualTick])` strategies. A ManualTick at a Cron strategy is accepted: CronTick and ManualTick carry the same single field, so the handler cannot read anything the tick lacks. Idle strategies still have to subscribe (IdleTick has fields ManualTick does not). Dispatcher and route tests pin the Cron case. Co-Authored-By: Claude Fable 5.1 --- src/everos/infra/ome/_dispatch/dispatcher.py | 22 +++++++--- .../test_api/test_routes/test_ome.py | 43 ++++++++++++++++++- .../test_infra/test_ome/test_dispatcher.py | 18 ++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/everos/infra/ome/_dispatch/dispatcher.py b/src/everos/infra/ome/_dispatch/dispatcher.py index 90d4166ba..694c69cba 100644 --- a/src/everos/infra/ome/_dispatch/dispatcher.py +++ b/src/everos/infra/ome/_dispatch/dispatcher.py @@ -29,8 +29,9 @@ from everos.infra.ome._dispatch.registry import StrategyRegistry from everos.infra.ome._stores.counter import CounterStore from everos.infra.ome.decorator import StrategyMeta -from everos.infra.ome.events import BaseEvent +from everos.infra.ome.events import BaseEvent, ManualTick from everos.infra.ome.records import CounterProgress, StrategyRouteInfo +from everos.infra.ome.triggers import Cron logger = get_logger(__name__) @@ -65,9 +66,10 @@ async def dispatch( must still subscribe to ``type(event)`` — a handler is never handed an event class it did not declare (a bare ``ManualTick`` aimed at ``Immediate(on=[AgentCaseExtracted])`` - would crash reading fields the tick does not carry). Manual - triggers use this when targeting a strategy with a - caller-supplied event. Raises ``KeyError`` if the name is + would crash reading fields the tick does not carry). The one + exception is a ``ManualTick`` at a ``Cron`` strategy: that is + the manual run of a scheduled job, and the tick carries every + field a ``CronTick`` does. Raises ``KeyError`` if the name is not registered. ``applies_to`` callables raised by a single strategy are caught, @@ -78,7 +80,17 @@ async def dispatch( if strategy_filter is not None: target = self._registry.get(strategy_filter) subscribed = {m.name for m in self._registry.lookup_by_event(type(event))} - metas: list[StrategyMeta] = [target] if target.name in subscribed else [] + # A manual run of a scheduled job is what the manual trigger is for + # (docs/reflection.md: reflect_episodes + force). CronTick and + # ManualTick carry the same single field, so a Cron handler cannot + # read anything the tick lacks; Idle handlers can (bucket_key, + # idle_seconds) and therefore still have to subscribe. + manual_cron = isinstance(event, ManualTick) and isinstance( + target.trigger, Cron + ) + metas: list[StrategyMeta] = ( + [target] if target.name in subscribed or manual_cron else [] + ) else: metas = list(self._registry.lookup_by_event(type(event))) out: list[tuple[StrategyMeta, str]] = [] diff --git a/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py b/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py index 0ffe9994d..1bc0fef43 100644 --- a/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py +++ b/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py @@ -27,8 +27,8 @@ from everos.infra.ome.context import StrategyContext from everos.infra.ome.decorator import offline_strategy from everos.infra.ome.engine import OfflineEngine -from everos.infra.ome.events import ManualTick -from everos.infra.ome.triggers import Immediate +from everos.infra.ome.events import CronTick, ManualTick +from everos.infra.ome.triggers import Cron, Immediate async def _client_for( @@ -66,6 +66,29 @@ async def _s(event: ManualTick, ctx: StrategyContext) -> None: await engine.stop() +@pytest.fixture +async def cron_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]: + """Engine with one Cron strategy — the ``reflect_episodes`` shape.""" + + @offline_strategy( + name="weekly_job", + trigger=Cron(expr="0 2 * * 1"), + emits=[], + ) + async def _s(event: CronTick, ctx: StrategyContext) -> None: + return None + + engine = OfflineEngine( + config=OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False) + ) + engine.register(_s) + await engine.start() + try: + yield engine + finally: + await engine.stop() + + @pytest.fixture async def always_fails_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]: """Engine with a strategy that raises unconditionally. @@ -129,3 +152,19 @@ async def test_trigger_returns_runs_including_dead_letter( assert len(body["runs"]) == 1 assert body["runs"][0]["status"] == "dead_letter" assert body["runs"][0]["error"] + + +@pytest.mark.asyncio +async def test_trigger_runs_a_cron_strategy_manually( + cron_engine: OfflineEngine, monkeypatch: pytest.MonkeyPatch +) -> None: + """A manual trigger is how a scheduled job is run on demand + (docs/reflection.md); it must dispatch, not report ``not_dispatched``.""" + async with await _client_for(cron_engine, monkeypatch) as client: + resp = await client.post( + "/api/v1/ome/trigger", json={"name": "weekly_job", "force": True} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["dispatched"] == 1 diff --git a/tests/unit/test_infra/test_ome/test_dispatcher.py b/tests/unit/test_infra/test_ome/test_dispatcher.py index 05d463a7d..0f2ea37a9 100644 --- a/tests/unit/test_infra/test_ome/test_dispatcher.py +++ b/tests/unit/test_infra/test_ome/test_dispatcher.py @@ -182,6 +182,24 @@ async def test_dispatch_strategy_filter_refuses_undeclared_event_class( assert routes == [] +@pytest.mark.asyncio +async def test_dispatch_manual_tick_reaches_a_cron_strategy( + dispatcher: EventDispatcher, +) -> None: + # ``POST /ome/trigger {"name": "reflect_episodes", "force": true}`` is the + # documented way to run a scheduled job now; CronTick and ManualTick carry + # the same single field, so the handler is safe. + @offline_strategy(name="weekly", trigger=Cron(expr="0 2 * * 1"), emits=[]) + async def _weekly(event: Any, ctx: StrategyContext) -> None: + return None + + dispatcher._registry.register(_weekly) + routes = await dispatcher.dispatch( + ManualTick(strategy_name="weekly"), strategy_filter="weekly" + ) + assert [m.name for m, _ in routes] == ["weekly"] + + @pytest.mark.asyncio async def test_dispatch_strategy_filter_unknown_raises( dispatcher: EventDispatcher, From 45258d5faf103ad29442640617a3fea06a4890bf Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 09:42:12 +0800 Subject: [PATCH 19/26] docs(windows): record the native Windows verification results The native-Windows section said the configuration was not yet supported and listed what was missing. Both gaps are closed on a stock Windows 11 machine: the integration and live-LLM suites ran, a Tier 3 server produced and served all four memory kinds, and a 10-hour soak with two concurrent CLI sync processes ended with the index intact. The section now states the numbers, the Python range, and the three Windows specifics an operator will meet (sharing violations on replace, Windows Search on the memory root, split rename events). The two soak findings that are not Windows-specific (duplicate rows under cross-process sync, latency under write load) are named so the section does not overclaim. Chinese mirror updated in step. Co-Authored-By: Claude Fable 5.1 --- docs/windows-wsl2.md | 68 ++++++++++++++++++++++++++++++----------- docs/windows-wsl2.zh.md | 44 +++++++++++++++++++------- 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index c71f7a33b..1889b2f65 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -218,21 +218,53 @@ wsl --shutdown # next `wsl` command restarts it and rebuilds the relay ## Native Windows -Running EverOS directly on Windows, without WSL2, is not yet a supported -configuration — but it is closer than it was. The unit suite runs green on -`windows-latest` in CI (the `unit tests (Windows)` job), which covers the -lock, the markdown layer, search and the cascade pipeline. What is still -missing: the integration suite does not run on Windows, and nobody has yet -exercised a full `everos serve` on a Windows machine end to end. Until -both happen, WSL2 is the path this guide stands behind. - -One prerequisite is already known from a stock Windows 11 Enterprise -machine: the **Microsoft Visual C++ Redistributable (x64)**. `greenlet`, -which SQLAlchemy's async engine depends on, is a C++ extension whose wheel -does not bundle the runtime, so without it every SQLite call fails with a -cryptic `DLL load failed while importing _greenlet`. GitHub's CI image has -the redistributable preinstalled, which is why CI cannot catch this. EverOS -now carries the runtime itself: the Windows-only `msvc-runtime` dependency -puts the DLLs in `sys.prefix`, and `everos/__init__.py` registers that -directory with the DLL loader before anything else is imported. Nothing to -install, no administrator rights needed. +EverOS runs directly on Windows. Verified on a stock Windows 11 Enterprise +laptop (Intel Core Ultra 7 155H, 32 GB, no Visual C++ Redistributable +installed) with Python 3.12 from `uv`: + +- `pip install everos` → `everos init` → `everos server start` works with + no manual step. The only Windows-only dependency is `msvc-runtime`, which + supplies the C++ runtime `greenlet` needs (details below). +- Test suites on that machine: unit **2576 passed / 4 skipped**, integration + **183 passed / 5 skipped**, live LLM (`slow`) **28 passed / 1 skipped**. CI + runs the unit suite on `windows-latest` (`unit tests (Windows)`). +- All four memory kinds — episode, profile, agent case, agent skill — were + produced by a Tier 3 server (real LLM, embedding and rerank providers) and + read back through `/get` and `/search`. +- A 10-hour soak (about 78 000 markdown entries written and rewritten, + 16 000 searches, 2 300 `/add` calls through the extraction path, two + concurrent `everos cascade sync` processes on the same tree) ended with + the index intact: every table opens, schemas verify, no entry lost. RSS + levelled at about 2.3 GB after three hours; the LanceDB directory peaked at + 6.7 GB and reclaimed to 2.7 GB (437 MB of live data). Two findings from + that run are tracked separately and are not Windows-specific: concurrent + `cascade sync` processes can insert the same row twice (about 4.5 % + duplicate rows after 10 hours, no corruption), and request latency + degrades under sustained write load. + +Python: 3.12, 3.13 and 3.14 (regular build) work unchanged; 3.11 is refused +by `requires-python` and by the PEP 695 syntax in `src/`; the free-threaded +3.14t build has no `lancedb` wheel. + +Windows specifics worth knowing: + +- `os.replace` fails with `PermissionError` while another process — the + cascade worker, or Defender scanning a fresh file — holds the target open. + The markdown writer retries with backoff; the soak saw 49 such retries, + all of them succeeded. +- Windows Search indexes everything under `%USERPROFILE%`. A memory root + there costs about one CPU core of `SearchIndexer` under heavy writes; put + the root elsewhere or exclude the directory from indexing. +- File-change events come from `ReadDirectoryChangesW`, which can report a + rename as two events. The cascade scanner reconciles against the disk. + +The one prerequisite that used to bite on a clean machine is the +**Microsoft Visual C++ Redistributable (x64)**. `greenlet`, which +SQLAlchemy's async engine depends on, is a C++ extension whose wheel does +not bundle the runtime, so without it every SQLite call fails with a cryptic +`DLL load failed while importing _greenlet`. GitHub's CI image has the +redistributable preinstalled, which is why CI never caught this. EverOS now +carries the runtime itself: the Windows-only `msvc-runtime` dependency puts +the DLLs in `sys.prefix`, and `everos/__init__.py` registers that directory +with the DLL loader before anything else is imported. Nothing to install, +no administrator rights needed. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index 0762ac457..b00d3357f 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -216,14 +216,36 @@ wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重 ## 直接在 Windows 上装行不行 -还不算支持,但比之前近了。单测套件在 CI 的 `windows-latest` 上是绿的(`unit tests -(Windows)` 这个 job),覆盖了锁、md 层、检索和 cascade 流水线。还缺两样:集成套件没在 -Windows 上跑,也没人在真的 Windows 机器上完整跑过一次 `everos serve`。这两件事没做完之前, -这篇指南只为 WSL2 这条路背书。 - -有一个前置条件已经在一台干净的 Windows 11 企业版机器上踩出来了:**Microsoft Visual C++ -运行库(x64)**。SQLAlchemy 的异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库, -缺了之后每一次 SQLite 调用都会报一句看不懂的 `DLL load failed while importing _greenlet`。 -GitHub 的 CI 镜像预装了这个运行库,所以 CI 抓不到。现在 EverOS 自己带着它:Windows 专用的 -`msvc-runtime` 依赖把 DLL 放进 `sys.prefix`,`everos/__init__.py` 在导入任何东西之前把这个 -目录注册给 DLL 加载器。不用装任何东西,也不需要管理员。 +行。在一台干净的 Windows 11 企业版笔记本(Intel Core Ultra 7 155H、32 GB、没装 Visual C++ +运行库)上用 `uv` 的 Python 3.12 验过: + +- `pip install everos` → `everos init` → `everos server start`,中间不需要手工做任何事。 + 唯一一个 Windows 专用依赖是 `msvc-runtime`,它提供 `greenlet` 需要的 C++ 运行库(见下文)。 +- 这台机器上的测试:单测 **2576 通过 / 4 跳过**,集成 **183 通过 / 5 跳过**,真实 LLM 的 + `slow` 用例 **28 通过 / 1 跳过**。CI 在 `windows-latest` 上跑单测(`unit tests (Windows)`)。 +- 四类记忆——episode、profile、agent case、agent skill——都由 Tier 3 服务(真实 LLM、embedding、 + rerank)产出,并通过 `/get` 和 `/search` 取回。 +- 10 小时浸泡(写入和改写约 78 000 条 md 条目、16 000 次检索、2 300 次走抽取路径的 `/add`, + 另有两个并发的 `everos cascade sync` 进程在同一棵目录树上不停跑)结束时索引完好:每张表都能打开、 + schema 校验通过、没有条目丢失;RSS 三小时后稳定在约 2.3 GB;LanceDB 目录峰值 6.7 GB,回收到 + 2.7 GB(真实数据 437 MB)。这次浸泡另外抓到两个与 Windows 无关的问题,单独跟踪:并发的 + `cascade sync` 进程会把同一行插两次(10 小时后约 4.5% 重复行,没有损坏);持续写负载下请求 + 延迟会变差。 + +Python:3.12、3.13、3.14(普通构建)不改任何东西就能跑;3.11 被 `requires-python` 和 `src/` 里的 +PEP 695 语法拒绝;free-threaded 的 3.14t 没有 `lancedb` 轮子。 + +Windows 上值得知道的几件事: + +- 另一个进程(cascade worker,或者 Defender 正在扫刚写好的文件)打开着目标文件时,`os.replace` + 会报 `PermissionError`。md 写入器会带退避重试;浸泡里出现 49 次,全部重试成功。 +- Windows 搜索会索引 `%USERPROFILE%` 下的所有东西。把记忆目录放在那里,重写压力下会多花大约 + 一个核给 `SearchIndexer`;把目录放到别处,或者把它从索引里排除。 +- 文件变更事件来自 `ReadDirectoryChangesW`,一次重命名可能被报成两个事件。cascade 的扫描器会 + 按磁盘实际状态对账。 + +以前在干净机器上会踩的那个前置条件是 **Microsoft Visual C++ 运行库(x64)**。SQLAlchemy 的 +异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库,缺了之后每一次 SQLite 调用都会报一句 +看不懂的 `DLL load failed while importing _greenlet`。GitHub 的 CI 镜像预装了这个运行库,所以 CI +抓不到。现在 EverOS 自己带着它:Windows 专用的 `msvc-runtime` 依赖把 DLL 放进 `sys.prefix`, +`everos/__init__.py` 在导入任何东西之前把这个目录注册给 DLL 加载器。不用装任何东西,也不需要管理员。 From 54b483b7895db0f190041897e3e0e10f63a3f107 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 12:33:33 +0800 Subject: [PATCH 20/26] test: read text fixtures as UTF-8 so a GBK-locale Windows passes Two tests read files with `Path.read_text()` and no encoding, which on Windows means the console codepage. The files hold an em dash, and on a zh-CN machine (cp936 / GBK) that byte sequence is an illegal multibyte sequence: both tests fail with `UnicodeDecodeError`. CI's `windows-latest` is en-US (cp1252), where the same bytes decode to the wrong characters without raising, so CI never saw it; the soak laptop did. Explicit UTF-8, matching what the markdown layer already does everywhere. Co-Authored-By: Claude Fable 5.1 --- tests/unit/test_benchmark_profile_trace_config.py | 10 +++++----- .../test_entrypoints/test_cli/test_init_command.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_benchmark_profile_trace_config.py b/tests/unit/test_benchmark_profile_trace_config.py index 35fefc773..bc944bf4d 100644 --- a/tests/unit/test_benchmark_profile_trace_config.py +++ b/tests/unit/test_benchmark_profile_trace_config.py @@ -32,7 +32,7 @@ def _config(name: str) -> BenchmarkConfig: - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text()) + raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8")) flat = {k: v for k, v in raw.items() if not isinstance(v, dict)} for section in ("answer", "judge"): for k, v in (raw.get(section) or {}).items(): @@ -56,7 +56,7 @@ def _config(name: str) -> BenchmarkConfig: @pytest.mark.parametrize("name", _ARMS) def test_every_benchmark_declares_both_knobs(name: str) -> None: """Explicit in the toml, so the recorded run says what it did.""" - text = (_BENCH / "configs" / f"{name}.toml").read_text() + text = (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") assert "include_profile" in text, f"{name} does not declare include_profile" assert "\ntrace = " in text, f"{name} does not declare trace" cfg = _config(name) @@ -106,7 +106,7 @@ def test_the_longmemeval_baseline_still_names_the_reference_models() -> None: in its own file for the same reason -- which is what `longmemeval_qwen38.toml` was until it was deleted, having served its one operator run. """ - base = tomllib.loads((_BENCH / "configs" / "longmemeval.toml").read_text()) + base = tomllib.loads((_BENCH / "configs" / "longmemeval.toml").read_text(encoding="utf-8")) assert base["backbone_model"] == "deepseek/deepseek-v4-pro-0813" # The decider is named by BENCH_DECIDER_MODEL rather than hardcoded: shipping a # model the reader does not serve, with an endpoint that may be unset, is the @@ -132,7 +132,7 @@ def test_every_benchmark_disables_the_inotify_watcher(name: str) -> None: not correctness -- but ``EVEROS_DISABLE_CASCADE`` would take the worker with it and md would never reach LanceDB at all. """ - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text()) + raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8")) assert raw["retrieval_env"]["EVEROS_DISABLE_CASCADE_WATCHER"] == "1" # The worker must survive: this is an ingesting run. assert "EVEROS_DISABLE_CASCADE" not in raw["retrieval_env"] @@ -147,7 +147,7 @@ def test_smoke_does_not_discard_an_explicit_conv_list() -> None: had chosen, not the ones it was given -- so the only way to catch it was to count graded rows against what you expected. """ - src = (_BENCH / "run.py").read_text() + src = (_BENCH / "run.py").read_text(encoding="utf-8") i = src.index("if args.smoke and not _conv_given:") assert "_conv_given = args.conv is not None" in src[:i] # The guard has to sit on the assignment itself, not merely exist somewhere. diff --git a/tests/unit/test_entrypoints/test_cli/test_init_command.py b/tests/unit/test_entrypoints/test_cli/test_init_command.py index f38be73b0..da2c9e040 100644 --- a/tests/unit/test_entrypoints/test_cli/test_init_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_init_command.py @@ -69,7 +69,7 @@ def test_refuses_overwrite_without_force(runner: CliRunner, tmp_path: Path) -> N result = runner.invoke(app, ["init", "--root", str(target)]) assert result.exit_code == 1 # Original content must be preserved. - assert (target / "everos.toml").read_text() == "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") == "# user-edited\n" def test_force_overwrites(runner: CliRunner, tmp_path: Path) -> None: @@ -80,7 +80,7 @@ def test_force_overwrites(runner: CliRunner, tmp_path: Path) -> None: result = runner.invoke(app, ["init", "--root", str(target), "--force"]) assert result.exit_code == 0 # Content is now the shipped template, not the user edit. - assert (target / "everos.toml").read_text() != "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") != "# user-edited\n" def test_print_writes_stdout_not_disk(runner: CliRunner, tmp_path: Path) -> None: @@ -101,7 +101,7 @@ def test_partial_overwrite_skips_existing(runner: CliRunner, tmp_path: Path) -> result = runner.invoke(app, ["init", "--root", str(target)]) assert result.exit_code == 0 # everos.toml preserved, ome.toml created. - assert (target / "everos.toml").read_text() == "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") == "# user-edited\n" assert (target / "ome.toml").is_file() From 9e632791d9c6c082e5ee2d27481401be083f3e68 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 12:34:41 +0800 Subject: [PATCH 21/26] style(tests): wrap the UTF-8 read_text calls to the line limit Co-Authored-By: Claude Fable 5.1 --- tests/unit/test_benchmark_profile_trace_config.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_benchmark_profile_trace_config.py b/tests/unit/test_benchmark_profile_trace_config.py index bc944bf4d..789482bdb 100644 --- a/tests/unit/test_benchmark_profile_trace_config.py +++ b/tests/unit/test_benchmark_profile_trace_config.py @@ -32,7 +32,9 @@ def _config(name: str) -> BenchmarkConfig: - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8")) + raw = tomllib.loads( + (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") + ) flat = {k: v for k, v in raw.items() if not isinstance(v, dict)} for section in ("answer", "judge"): for k, v in (raw.get(section) or {}).items(): @@ -106,7 +108,9 @@ def test_the_longmemeval_baseline_still_names_the_reference_models() -> None: in its own file for the same reason -- which is what `longmemeval_qwen38.toml` was until it was deleted, having served its one operator run. """ - base = tomllib.loads((_BENCH / "configs" / "longmemeval.toml").read_text(encoding="utf-8")) + base = tomllib.loads( + (_BENCH / "configs" / "longmemeval.toml").read_text(encoding="utf-8") + ) assert base["backbone_model"] == "deepseek/deepseek-v4-pro-0813" # The decider is named by BENCH_DECIDER_MODEL rather than hardcoded: shipping a # model the reader does not serve, with an endpoint that may be unset, is the @@ -132,7 +136,9 @@ def test_every_benchmark_disables_the_inotify_watcher(name: str) -> None: not correctness -- but ``EVEROS_DISABLE_CASCADE`` would take the worker with it and md would never reach LanceDB at all. """ - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8")) + raw = tomllib.loads( + (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") + ) assert raw["retrieval_env"]["EVEROS_DISABLE_CASCADE_WATCHER"] == "1" # The worker must survive: this is an ingesting run. assert "EVEROS_DISABLE_CASCADE" not in raw["retrieval_env"] From b75e755090d8e379d0a7f9b69481cc42a388251f Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 13:51:40 +0800 Subject: [PATCH 22/26] fix(windows): probe the per-user install dir for the runtime DLLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of this branch's product code. The DLL hook looked only under `sys.prefix`; `pip install` without write access to site-packages (Python under Program Files) falls back to a per-user install whose data files land under `site.getuserbase()`, where the hook was a no-op and `import greenlet` still failed. Both bases are probed now, with a test. Smaller corrections from the same review: the locking module claimed `LockFileEx` while portalocker's default Windows locker is `msvcrt.locking` (same semantics, wrong name); the replace-retry budget is ~2.5 s, not the ~5 s the comment and the commit body said; POSIX can raise PermissionError on an immutable target, so "never" was wrong; each retry now logs at debug level so the product path is observable (the soak's 49 retries were the harness's); `scan_interval_seconds` gains `gt=0` (0 would spin the scanner with no interval); the api.md `not_dispatched` sentence names the fifth gate. The two watcher save tests now assert the saved row's mtime advanced, so a lost event fails them — with the handler stubbed out they used to pass. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- docs/api.md | 4 +-- src/everos/__init__.py | 19 +++++++++--- src/everos/config/settings.py | 2 +- src/everos/core/persistence/locking.py | 4 +-- .../core/persistence/markdown/writer.py | 18 +++++++++-- .../test_cascade/test_watcher_events.py | 6 ++++ tests/unit/test_package_init_windows_dlls.py | 31 +++++++++++++++++-- 8 files changed, 69 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b24402e1..7b2ba2418 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: # No `make` on the Windows image — call uv directly. The lock is # universal (it carries sys_platform == 'win32' resolution markers and - # pywin32 for portalocker's Win32 locker), so --frozen resolves here. + # pywin32, which portalocker requires on Windows), so --frozen resolves here. - name: Install dependencies (frozen) run: uv sync --frozen diff --git a/docs/api.md b/docs/api.md index 4dbf92665..99856445e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1086,8 +1086,8 @@ Manually trigger a registered OME strategy. | `runs` | `list[RunSummary]` | One entry per strategy run *attempt*, not per dispatched route: `{run_id: string, status: string, error?: string}`. A strategy that retried before settling contributes multiple entries sharing one `event_id`. `status` is one of `running` / `success` / `failed` / `dead_letter` / `crashed`. Includes dead-lettered runs | **`not_dispatched`** means every subscriber was rejected by one of the -four dispatch gates (`_routes_to` / `enabled` / `applies_to` / -`Counter`). The most common cause is forgetting `"force": true` on a +five dispatch gates (event-class subscription / `_routes_to` / `enabled` / +`applies_to` / `Counter`). The most common cause is forgetting `"force": true` on a strategy that is `enabled=false` in `ome.toml` — e.g. triggering `reflect_episodes` without `force` while it is disabled in config returns `{"status": "not_dispatched", "dispatched": 0, "runs": []}` diff --git a/src/everos/__init__.py b/src/everos/__init__.py index 1fd5f5dca..78c2b1ef8 100644 --- a/src/everos/__init__.py +++ b/src/everos/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import site import sys from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version @@ -20,7 +21,9 @@ _dll_dir_handles: list[object] = [] -def _register_runtime_dll_dirs(prefix: str = sys.prefix) -> list[str]: +def _register_runtime_dll_dirs( + prefix: str = sys.prefix, user_base: str | None = None +) -> list[str]: """On Windows, let compiled extensions find the MSVC C++ runtime. ``greenlet`` -- under SQLAlchemy's async engine, so under every SQLite @@ -42,11 +45,17 @@ def _register_runtime_dll_dirs(prefix: str = sys.prefix) -> list[str]: add = getattr(os, "add_dll_directory", None) if add is None: return [] + # ``pip install`` falls back to a per-user install when site-packages is + # not writable (Python under Program Files); the wheel's data files then + # land under ``site.getuserbase()`` rather than ``sys.prefix``. + if user_base is None: + user_base = site.getuserbase() registered: list[str] = [] - for d in (prefix, os.path.join(prefix, "Scripts")): - if os.path.isfile(os.path.join(d, "msvcp140.dll")): - _dll_dir_handles.append(add(d)) - registered.append(d) + for base in dict.fromkeys((prefix, user_base)): + for d in (base, os.path.join(base, "Scripts")): + if os.path.isfile(os.path.join(d, "msvcp140.dll")): + _dll_dir_handles.append(add(d)) + registered.append(d) return registered diff --git a/src/everos/config/settings.py b/src/everos/config/settings.py index fa148cf6f..354390edb 100644 --- a/src/everos/config/settings.py +++ b/src/everos/config/settings.py @@ -564,7 +564,7 @@ class CascadeSettings(BaseModel): optimize_prune_interval_seconds: float = 300.0 optimize_prune_retention_seconds: float = 60.0 optimize_rebuild_interval_seconds: float = 12 * 60 * 60.0 - scan_interval_seconds: float = 30.0 + scan_interval_seconds: float = Field(default=30.0, gt=0) class IndexSettings(BaseModel): diff --git a/src/everos/core/persistence/locking.py b/src/everos/core/persistence/locking.py index a3ffaca62..cf963980d 100644 --- a/src/everos/core/persistence/locking.py +++ b/src/everos/core/persistence/locking.py @@ -1,8 +1,8 @@ """Process-wide exclusive lock on a memory-root. Uses ``portalocker`` for the exclusive lock, which dispatches to -``fcntl.flock`` on POSIX and ``LockFileEx`` on Windows — the same -whole-file, released-on-process-exit semantics on both. The +``fcntl.flock`` on POSIX and, on Windows, its default ``msvcrt.locking`` +locker — the same whole-file, released-on-process-exit semantics on both. The public surface is an :func:`contextlib.asynccontextmanager` so callers use ``async with memory_root_lock(mr):``; the underlying syscalls have no async equivalent so they run in a worker thread via diff --git a/src/everos/core/persistence/markdown/writer.py b/src/everos/core/persistence/markdown/writer.py index 1f029da41..ea4f995b6 100644 --- a/src/everos/core/persistence/markdown/writer.py +++ b/src/everos/core/persistence/markdown/writer.py @@ -50,6 +50,7 @@ import anyio from everos.core.errors import PathTraversalError +from everos.core.observability.logging import get_logger from ..memory_root import MemoryRoot from .entries import EntryId @@ -339,8 +340,11 @@ async def _append_entries_unlocked( return await self.write_markdown(target, frontmatter=meta, body=body) +logger = get_logger(__name__) + + _REPLACE_ATTEMPTS = 8 -_REPLACE_FIRST_BACKOFF_S = 0.02 # doubles each time: ~5 s of patience in total +_REPLACE_FIRST_BACKOFF_S = 0.02 # doubles each time: ~2.5 s of patience in total def _replace_with_retry(tmp: Path, target: Path) -> None: @@ -349,8 +353,10 @@ def _replace_with_retry(tmp: Path, target: Path) -> None: On Windows a file some other process holds open cannot be replaced: the cascade worker reading it, an antivirus scan right after the last write, an editor with it open -- ``os.replace`` raises ``PermissionError`` - (WinError 5 / 32). POSIX never does; there the first attempt succeeds and - this is a plain ``os.replace``. Those holds last milliseconds, so a short + (WinError 5 / 32). On POSIX the first attempt succeeds and this is a plain + ``os.replace``; the one POSIX ``PermissionError`` (an immutable target, + macOS ``uchg``) is permanent and only costs the backoff before it + surfaces. Those holds last milliseconds, so a short exponential backoff is the standard idiom (git, pip and uv all do it). Only ``PermissionError`` is retried, each attempt is still one atomic ``os.replace``, and after the budget the error propagates unchanged -- @@ -368,6 +374,12 @@ def _replace_with_retry(tmp: Path, target: Path) -> None: except PermissionError: if attempt == _REPLACE_ATTEMPTS - 1: raise + logger.debug( + "markdown_replace_retried", + target=str(target), + attempt=attempt + 1, + backoff_seconds=delay, + ) time.sleep(delay) delay *= 2 diff --git a/tests/unit/test_memory/test_cascade/test_watcher_events.py b/tests/unit/test_memory/test_cascade/test_watcher_events.py index b5b2c1df3..e3bafeffe 100644 --- a/tests/unit/test_memory/test_cascade/test_watcher_events.py +++ b/tests/unit/test_memory/test_cascade/test_watcher_events.py @@ -320,12 +320,15 @@ async def test_in_place_save_never_registers_as_deleted( p.write_text("v1", encoding="utf-8") rel = _rel(runtime.root, p) await _wait_row(rel) + first = await _row(rel) + assert first is not None p.write_text("v2 -- same file, rewritten in place", encoding="utf-8") await asyncio.sleep(_TRAILING_EVENT_GRACE_S) row = await _row(rel) assert row is not None assert row.change_type != "deleted" assert row.status == "pending" + assert row.mtime > first.mtime, "the in-place save was never delivered" async def test_atomic_replace_over_existing_target_keeps_the_row_alive( @@ -343,6 +346,8 @@ async def test_atomic_replace_over_existing_target_keeps_the_row_alive( rel = _rel(runtime.root, target) await _wait_row(rel) + first = await _row(rel) + assert first is not None tmp = target.with_name(target.name + ".tmp") # not kind-matched tmp.write_text("v2 via atomic save", encoding="utf-8") os.replace(tmp, target) @@ -355,6 +360,7 @@ async def test_atomic_replace_over_existing_target_keeps_the_row_alive( "this file's LanceDB rows while the md is intact" ) assert row.status == "pending" + assert row.mtime > first.mtime, "the atomic save was never delivered" assert await _row(_rel(runtime.root, tmp)) is None assert target.read_text(encoding="utf-8") == "v2 via atomic save" diff --git a/tests/unit/test_package_init_windows_dlls.py b/tests/unit/test_package_init_windows_dlls.py index c67b6f4cd..c3bf9b837 100644 --- a/tests/unit/test_package_init_windows_dlls.py +++ b/tests/unit/test_package_init_windows_dlls.py @@ -34,11 +34,32 @@ def test_registers_prefix_and_scripts_when_the_runtime_dll_is_there( seen: list[str] = [] monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) - got = everos._register_runtime_dll_dirs(str(tmp_path)) + got = everos._register_runtime_dll_dirs( + str(tmp_path), user_base=str(tmp_path / "ub") + ) assert got == seen == [str(tmp_path), str(tmp_path / "Scripts")] +def test_registers_the_per_user_install_dir_too( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``pip install`` without write access to site-packages lands the wheel's + data files under ``site.getuserbase()``, not ``sys.prefix``.""" + prefix = tmp_path / "prefix" + prefix.mkdir() + user_base = tmp_path / "AppData" / "Python" + (user_base / "Scripts").mkdir(parents=True) + (user_base / "msvcp140.dll").write_bytes(b"") + (user_base / "Scripts" / "msvcp140.dll").write_bytes(b"") + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + got = everos._register_runtime_dll_dirs(str(prefix), user_base=str(user_base)) + + assert got == seen == [str(user_base), str(user_base / "Scripts")] + + def test_skips_directories_without_the_dll( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -48,7 +69,9 @@ def test_skips_directories_without_the_dll( seen: list[str] = [] monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) - assert everos._register_runtime_dll_dirs(str(tmp_path)) == [] + assert ( + everos._register_runtime_dll_dirs(str(tmp_path), user_base=str(tmp_path)) == [] + ) assert seen == [] @@ -59,4 +82,6 @@ def test_is_a_no_op_where_the_os_has_no_add_dll_directory( (tmp_path / "msvcp140.dll").write_bytes(b"") monkeypatch.delattr(os, "add_dll_directory", raising=False) - assert everos._register_runtime_dll_dirs(str(tmp_path)) == [] + assert ( + everos._register_runtime_dll_dirs(str(tmp_path), user_base=str(tmp_path)) == [] + ) From ab33efeb61b29d89d93bc0c596226d97bcbed097 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 13:51:40 +0800 Subject: [PATCH 23/26] docs(windows): claim only what was run, and where Review of the guide against the evidence: the unit-suite number was a macOS figure, "pip install" was not the path exercised (a uv checkout was), "no entry lost" ignored the deliberately malformed files, the 49 replace retries belonged to the harness's writer, and the 3.13 / 3.14 claims came from Linux CI and macOS. Each sentence now says what ran on which machine. The intro no longer contradicts the native section, the docs index row matches, and two things an operator will meet are added: LongPathsEnabled (both test machines had it on, a stock install does not) and msvc-runtime shipping wheels only, per CPython minor version. Co-Authored-By: Claude Fable 5.1 --- docs/index.md | 2 +- docs/windows-wsl2.md | 66 ++++++++++++++++++++++++++--------------- docs/windows-wsl2.zh.md | 29 ++++++++++++------ 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/docs/index.md b/docs/index.md index b707cca3e..7910aef08 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,7 +49,7 @@ specific thing (drain a queue, recover from a stuck row, etc.). | Doc | Purpose | |---|---| | [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows | -| [windows-wsl2.md](windows-wsl2.md) | Install and run EverOS on Windows via WSL2 | +| [windows-wsl2.md](windows-wsl2.md) | Install and run EverOS on Windows — natively or via WSL2 | | [windows-wsl2.zh.md](windows-wsl2.zh.md) | Chinese mirror of the Windows / WSL2 guide | | [github-sync.md](github-sync.md) | Guardrails for refreshing GitHub from internal exports without overwriting GitHub-only workflow files | | [benchmarks/README.md](../benchmarks/README.md) | LoCoMo benchmark — run and evaluate | diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index 1889b2f65..6c32a3382 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -2,11 +2,13 @@ > Also available in Chinese: [windows-wsl2.zh.md](windows-wsl2.zh.md) -EverOS is developed and CI-tested on Linux and macOS. On Windows the -supported path is **WSL2** — a real Linux kernel, so the storage stack -(`fcntl` locking, LanceDB, inotify) behaves exactly as it does on a Linux -server. This page is the end-to-end install, including the parts that -cannot be scripted and the one failure that is silent. +EverOS is developed and CI-tested on Linux; its unit suite also runs on +Windows in CI, and it has been exercised end to end on a stock Windows 11 +machine (see [Native Windows](#native-windows) at the end). On Windows you +can run it natively or under **WSL2** — a real Linux kernel, so the storage +stack (file locking, LanceDB, inotify) behaves exactly as it does on a +Linux server. Most of this page is the WSL2 install, including the parts +that cannot be scripted and the one failure that is silent. ## Table of contents @@ -222,41 +224,54 @@ EverOS runs directly on Windows. Verified on a stock Windows 11 Enterprise laptop (Intel Core Ultra 7 155H, 32 GB, no Visual C++ Redistributable installed) with Python 3.12 from `uv`: -- `pip install everos` → `everos init` → `everos server start` works with - no manual step. The only Windows-only dependency is `msvc-runtime`, which - supplies the C++ runtime `greenlet` needs (details below). -- Test suites on that machine: unit **2576 passed / 4 skipped**, integration - **183 passed / 5 skipped**, live LLM (`slow`) **28 passed / 1 skipped**. CI - runs the unit suite on `windows-latest` (`unit tests (Windows)`). +- Installed with `uv` from a source checkout, `everos init` → + `everos server start` needs no manual step beyond having Python. The + Windows-only dependencies are `msvc-runtime`, which supplies the C++ + runtime `greenlet` needs (details below), and `pywin32`, which + `portalocker` uses for file locking. +- Test suites: the unit suite runs green in CI on `windows-latest` + (`unit tests (Windows)`, 2583 passed / 4 skipped — the same count as + Linux); on that machine, integration **183 passed / 5 skipped** and live + LLM (`slow`) **28 passed / 1 skipped**, both under Python 3.12. - All four memory kinds — episode, profile, agent case, agent skill — were produced by a Tier 3 server (real LLM, embedding and rerank providers) and read back through `/get` and `/search`. - A 10-hour soak (about 78 000 markdown entries written and rewritten, 16 000 searches, 2 300 `/add` calls through the extraction path, two concurrent `everos cascade sync` processes on the same tree) ended with - the index intact: every table opens, schemas verify, no entry lost. RSS - levelled at about 2.3 GB after three hours; the LanceDB directory peaked at - 6.7 GB and reclaimed to 2.7 GB (437 MB of live data). Two findings from - that run are tracked separately and are not Windows-specific: concurrent - `cascade sync` processes can insert the same row twice (about 4.5 % - duplicate rows after 10 hours, no corruption), and request latency - degrades under sustained write load. - -Python: 3.12, 3.13 and 3.14 (regular build) work unchanged; 3.11 is refused -by `requires-python` and by the PEP 695 syntax in `src/`; the free-threaded + the index intact: every table opens, schemas verify, and every well-formed + entry had its row — the only markdown entries without one were the + deliberately malformed files the run seeds. RSS levelled at about 2.3 GB + after three hours; the LanceDB directory peaked at 6.7 GB and reclaimed + to 2.7 GB (437 MB of live data). Two findings from that run are tracked + separately and are not Windows-specific: concurrent `cascade sync` + processes can insert the same row twice (about 4.5 % duplicate rows after + 10 hours, no corruption), and request latency degrades under sustained + write load. + +Python: 3.12 is what ran on that machine; 3.13 and 3.14 (regular build) +pass the same suites in CI and on macOS without changes; 3.11 is refused by +`requires-python` and by the PEP 695 syntax in `src/`; the free-threaded 3.14t build has no `lancedb` wheel. Windows specifics worth knowing: - `os.replace` fails with `PermissionError` while another process — the cascade worker, or Defender scanning a fresh file — holds the target open. - The markdown writer retries with backoff; the soak saw 49 such retries, - all of them succeeded. + The markdown writer retries with backoff (about 2.5 s of patience) and + logs each retry at debug level; the soak's load generator, which uses + the same idiom, hit 49 such violations and recovered from all of them. - Windows Search indexes everything under `%USERPROFILE%`. A memory root there costs about one CPU core of `SearchIndexer` under heavy writes; put the root elsewhere or exclude the directory from indexing. - File-change events come from `ReadDirectoryChangesW`, which can report a rename as two events. The cascade scanner reconciles against the disk. +- Both machines that ran the suites had long paths enabled + (`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled=1`; + GitHub's image sets it, a stock install does not). Memory roots nest + several directories deep and Lance index directories carry UUID names, + so a root under a long profile path can cross 260 characters — enable + long paths or keep the root short. The one prerequisite that used to bite on a clean machine is the **Microsoft Visual C++ Redistributable (x64)**. `greenlet`, which @@ -267,4 +282,7 @@ redistributable preinstalled, which is why CI never caught this. EverOS now carries the runtime itself: the Windows-only `msvc-runtime` dependency puts the DLLs in `sys.prefix`, and `everos/__init__.py` registers that directory with the DLL loader before anything else is imported. Nothing to install, -no administrator rights needed. +no administrator rights needed. One dependency to watch: `msvc-runtime` +ships wheels only, one per CPython minor version, so a Python newer than +its latest wheel cannot install EverOS on Windows until upstream publishes +one. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index b00d3357f..404fcfa7b 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -1,6 +1,7 @@ # 在 Windows 上跑 EverOS(WSL2) -EverOS 的开发和 CI 都在 Linux 和 macOS 上。Windows 上走 WSL2。 +EverOS 的开发和 CI 在 Linux 上,单测也在 Windows 的 CI 上跑,并且在一台干净的 Windows 11 机器上 +完整验过(见文末「直接在 Windows 上装行不行」)。Windows 上既可以直接装,也可以走 WSL2;这篇主要讲 WSL2。 WSL2 是 Windows 自带的一套真 Linux 内核,不是模拟器也不是虚拟机软件。装完之后 EverOS 就跟跑在一台 Ubuntu 服务器上没区别,文件锁、向量索引、文件监听全部是 @@ -219,33 +220,43 @@ wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重 行。在一台干净的 Windows 11 企业版笔记本(Intel Core Ultra 7 155H、32 GB、没装 Visual C++ 运行库)上用 `uv` 的 Python 3.12 验过: -- `pip install everos` → `everos init` → `everos server start`,中间不需要手工做任何事。 - 唯一一个 Windows 专用依赖是 `msvc-runtime`,它提供 `greenlet` 需要的 C++ 运行库(见下文)。 -- 这台机器上的测试:单测 **2576 通过 / 4 跳过**,集成 **183 通过 / 5 跳过**,真实 LLM 的 - `slow` 用例 **28 通过 / 1 跳过**。CI 在 `windows-latest` 上跑单测(`unit tests (Windows)`)。 +- 用 `uv` 从源码 checkout 装好之后,`everos init` → `everos server start`,除了装 Python 之外 + 不需要手工做任何事。Windows 专用依赖有两个:`msvc-runtime`(提供 `greenlet` 需要的 C++ 运行库, + 见下文)和 `pywin32`(`portalocker` 用它做文件锁)。 +- 测试:单测在 CI 的 `windows-latest` 上是绿的(`unit tests (Windows)`,2583 通过 / 4 跳过, + 与 Linux 同数);这台机器上跑了集成 **183 通过 / 5 跳过** 和真实 LLM 的 `slow` 用例 + **28 通过 / 1 跳过**,都在 Python 3.12 下。 - 四类记忆——episode、profile、agent case、agent skill——都由 Tier 3 服务(真实 LLM、embedding、 rerank)产出,并通过 `/get` 和 `/search` 取回。 - 10 小时浸泡(写入和改写约 78 000 条 md 条目、16 000 次检索、2 300 次走抽取路径的 `/add`, 另有两个并发的 `everos cascade sync` 进程在同一棵目录树上不停跑)结束时索引完好:每张表都能打开、 - schema 校验通过、没有条目丢失;RSS 三小时后稳定在约 2.3 GB;LanceDB 目录峰值 6.7 GB,回收到 + schema 校验通过、每一条格式正确的条目都有对应的行——没有行的只有这轮故意撒进去的畸形文件; + RSS 三小时后稳定在约 2.3 GB;LanceDB 目录峰值 6.7 GB,回收到 2.7 GB(真实数据 437 MB)。这次浸泡另外抓到两个与 Windows 无关的问题,单独跟踪:并发的 `cascade sync` 进程会把同一行插两次(10 小时后约 4.5% 重复行,没有损坏);持续写负载下请求 延迟会变差。 -Python:3.12、3.13、3.14(普通构建)不改任何东西就能跑;3.11 被 `requires-python` 和 `src/` 里的 -PEP 695 语法拒绝;free-threaded 的 3.14t 没有 `lancedb` 轮子。 +Python:这台机器上跑的是 3.12;3.13 和 3.14(普通构建)在 CI 和 macOS 上不改任何东西就能过同一套 +测试;3.11 被 `requires-python` 和 `src/` 里的 PEP 695 语法拒绝;free-threaded 的 3.14t 没有 +`lancedb` 轮子。 Windows 上值得知道的几件事: - 另一个进程(cascade worker,或者 Defender 正在扫刚写好的文件)打开着目标文件时,`os.replace` - 会报 `PermissionError`。md 写入器会带退避重试;浸泡里出现 49 次,全部重试成功。 + 会报 `PermissionError`。md 写入器会带退避重试(总共约 2.5 秒的耐心),每次重试记一条 debug 日志; + 浸泡的加载器用的是同一套写法,撞上 49 次,全部重试成功。 - Windows 搜索会索引 `%USERPROFILE%` 下的所有东西。把记忆目录放在那里,重写压力下会多花大约 一个核给 `SearchIndexer`;把目录放到别处,或者把它从索引里排除。 - 文件变更事件来自 `ReadDirectoryChangesW`,一次重命名可能被报成两个事件。cascade 的扫描器会 按磁盘实际状态对账。 +- 跑过测试的两台机器都开着长路径(`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled=1`; + GitHub 的镜像默认开,干净安装的 Windows 默认不开)。记忆目录嵌套好几层,Lance 的索引目录又是 + UUID 命名,放在很长的用户目录下可能超过 260 个字符——要么开长路径,要么把目录放短一点。 以前在干净机器上会踩的那个前置条件是 **Microsoft Visual C++ 运行库(x64)**。SQLAlchemy 的 异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库,缺了之后每一次 SQLite 调用都会报一句 看不懂的 `DLL load failed while importing _greenlet`。GitHub 的 CI 镜像预装了这个运行库,所以 CI 抓不到。现在 EverOS 自己带着它:Windows 专用的 `msvc-runtime` 依赖把 DLL 放进 `sys.prefix`, `everos/__init__.py` 在导入任何东西之前把这个目录注册给 DLL 加载器。不用装任何东西,也不需要管理员。 +要留意的一点:`msvc-runtime` 只发轮子、按 CPython 小版本发,比它最新轮子更新的 Python 在上游发布之前 +装不了 EverOS 的 Windows 版。 From bafe72243b23faa38723b096389bab79acbc052f Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 14:58:37 +0800 Subject: [PATCH 24/26] docs(windows): state the verified pip-install path and prerequisite The wheel built from this branch was installed with pip into a plain venv on the Windows box (dependencies resolved from PyPI), imported, and started a server that answered /health, /add and /search. The one step between install and start is an LLM api_key in everos.toml: the server refuses to start without it, and `everos init` says so. Co-Authored-By: Claude Fable 5.1 --- docs/windows-wsl2.md | 7 +++++-- docs/windows-wsl2.zh.md | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/windows-wsl2.md b/docs/windows-wsl2.md index 6c32a3382..fbb216e58 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows-wsl2.md @@ -224,8 +224,11 @@ EverOS runs directly on Windows. Verified on a stock Windows 11 Enterprise laptop (Intel Core Ultra 7 155H, 32 GB, no Visual C++ Redistributable installed) with Python 3.12 from `uv`: -- Installed with `uv` from a source checkout, `everos init` → - `everos server start` needs no manual step beyond having Python. The +- `pip install` of the built wheel into a plain venv (dependencies from + PyPI: pyarrow 25.0.1, `msvc-runtime` 14.44, lancedb 0.34) → + `everos init` → put an LLM `api_key` in `everos.toml` (the server refuses + to start without one, and `init` says so) → `everos server start`: healthy + after 35 s, `/add` and `/search` answered. No other manual step. The Windows-only dependencies are `msvc-runtime`, which supplies the C++ runtime `greenlet` needs (details below), and `pywin32`, which `portalocker` uses for file locking. diff --git a/docs/windows-wsl2.zh.md b/docs/windows-wsl2.zh.md index 404fcfa7b..c74bcaa7d 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows-wsl2.zh.md @@ -220,9 +220,11 @@ wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重 行。在一台干净的 Windows 11 企业版笔记本(Intel Core Ultra 7 155H、32 GB、没装 Visual C++ 运行库)上用 `uv` 的 Python 3.12 验过: -- 用 `uv` 从源码 checkout 装好之后,`everos init` → `everos server start`,除了装 Python 之外 - 不需要手工做任何事。Windows 专用依赖有两个:`msvc-runtime`(提供 `greenlet` 需要的 C++ 运行库, - 见下文)和 `pywin32`(`portalocker` 用它做文件锁)。 +- 在一个干净的 venv 里 `pip install` 构建出的轮子(依赖从 PyPI 解析:pyarrow 25.0.1、`msvc-runtime` + 14.44、lancedb 0.34)→ `everos init` → 在 `everos.toml` 里填上 LLM 的 `api_key`(不填服务会拒绝启动, + `init` 也会这么提示)→ `everos server start`:35 秒后健康,`/add`、`/search` 正常。除此之外没有 + 手工步骤。Windows 专用依赖有两个:`msvc-runtime`(提供 `greenlet` 需要的 C++ 运行库,见下文)和 + `pywin32`(`portalocker` 用它做文件锁)。 - 测试:单测在 CI 的 `windows-latest` 上是绿的(`unit tests (Windows)`,2583 通过 / 4 跳过, 与 Linux 同数);这台机器上跑了集成 **183 通过 / 5 跳过** 和真实 LLM 的 `slow` 用例 **28 通过 / 1 跳过**,都在 Python 3.12 下。 From 846331be0f7057f47c9e6aeff7ba0f6b654d7151 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 15:33:06 +0800 Subject: [PATCH 25/26] test(locking): give the spawned child 60 s to import before asserting On Windows the spawn-started child re-imports the package, and pulling in lancedb behind Defender takes 15 s or more, so the 5 s readiness wait failed three of the four cross-process tests on the soak box (idle run: 3 failed, 2587 passed). The readiness wait is not what these tests measure; every timing assertion starts after the child signals ready. Raise only that wait. Co-Authored-By: Claude Fable 5.1 --- .../test_persistence/test_locking.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_core/test_persistence/test_locking.py b/tests/unit/test_core/test_persistence/test_locking.py index bd4ea560c..8683afd7f 100644 --- a/tests/unit/test_core/test_persistence/test_locking.py +++ b/tests/unit/test_core/test_persistence/test_locking.py @@ -27,6 +27,12 @@ async def test_lock_acquire_release_acquire(tmp_path: Path) -> None: pass +# A spawned child re-imports this module and, through everos.core.persistence, +# lancedb: 1-2 s on macOS, 15 s+ on Windows with Defender scanning each file. +# Only readiness is bounded by this; the timing assertions start after ready.set(). +_SPAWN_TIMEOUT_S = 60.0 + + def _hold_lock(memory_root_path: str, ready: object, release: object) -> None: """Subprocess helper: acquire blocking lock, signal, wait, release. @@ -53,13 +59,13 @@ async def test_nonblocking_raises_when_held_by_other_process(tmp_path: Path) -> proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5), "subprocess failed to acquire lock" + assert ready.wait(timeout=_SPAWN_TIMEOUT_S), "subprocess failed to acquire lock" with pytest.raises(LockError): async with memory_root_lock(mr, blocking=False): pass finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -73,7 +79,7 @@ async def test_blocking_waits_for_release(tmp_path: Path) -> None: proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) # Schedule the subprocess to release shortly; main process should # acquire the lock after that. release_started = time.monotonic() @@ -91,7 +97,7 @@ def release_after_short_delay() -> None: assert elapsed >= 0.1 finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -128,7 +134,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) started = time.monotonic() with pytest.raises(LockError, match="timed out"): async with memory_root_lock(mr, timeout_seconds=0.2): @@ -141,7 +147,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] ) finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -180,7 +186,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) threading.Timer(0.2, release.set).start() async with memory_root_lock(mr, timeout_seconds=5.0): pass @@ -190,7 +196,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] ] finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() From 9473019ce466314f58ed79c2f67136a415199d3c Mon Sep 17 00:00:00 2001 From: zhanghui Date: Thu, 24 Sep 2026 17:05:41 +0800 Subject: [PATCH 26/26] docs(windows): title the page for Windows, native first, WSL2 second The page was still called 'Running EverOS on Windows (WSL2)' although the native install is verified and covered by its own CI job. Rename it to docs/windows.md, retitle, and open with the two ways in the order a reader should try them; the WSL2 walkthrough body is unchanged. References updated. Co-Authored-By: Claude Fable 5.1 --- docs/index.md | 4 ++-- docs/{windows-wsl2.md => windows.md} | 18 +++++++++--------- docs/{windows-wsl2.zh.md => windows.zh.md} | 7 ++++--- 3 files changed, 15 insertions(+), 14 deletions(-) rename docs/{windows-wsl2.md => windows.md} (95%) rename docs/{windows-wsl2.zh.md => windows.zh.md} (96%) diff --git a/docs/index.md b/docs/index.md index 7910aef08..e5bd160d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,8 +49,8 @@ specific thing (drain a queue, recover from a stuck row, etc.). | Doc | Purpose | |---|---| | [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows | -| [windows-wsl2.md](windows-wsl2.md) | Install and run EverOS on Windows — natively or via WSL2 | -| [windows-wsl2.zh.md](windows-wsl2.zh.md) | Chinese mirror of the Windows / WSL2 guide | +| [windows.md](windows.md) | Install and run EverOS on Windows — natively or via WSL2 | +| [windows.zh.md](windows.zh.md) | Chinese mirror of the Windows / WSL2 guide | | [github-sync.md](github-sync.md) | Guardrails for refreshing GitHub from internal exports without overwriting GitHub-only workflow files | | [benchmarks/README.md](../benchmarks/README.md) | LoCoMo benchmark — run and evaluate | diff --git a/docs/windows-wsl2.md b/docs/windows.md similarity index 95% rename from docs/windows-wsl2.md rename to docs/windows.md index fbb216e58..0038ac985 100644 --- a/docs/windows-wsl2.md +++ b/docs/windows.md @@ -1,14 +1,14 @@ -# Running EverOS on Windows (WSL2) +# Running EverOS on Windows -> Also available in Chinese: [windows-wsl2.zh.md](windows-wsl2.zh.md) +> Also available in Chinese: [windows.zh.md](windows.zh.md) -EverOS is developed and CI-tested on Linux; its unit suite also runs on -Windows in CI, and it has been exercised end to end on a stock Windows 11 -machine (see [Native Windows](#native-windows) at the end). On Windows you -can run it natively or under **WSL2** — a real Linux kernel, so the storage -stack (file locking, LanceDB, inotify) behaves exactly as it does on a -Linux server. Most of this page is the WSL2 install, including the parts -that cannot be scripted and the one failure that is silent. +EverOS runs on Windows in two ways. **Natively** — `pip install everos` +on a stock Windows 11 machine, verified end to end and covered by the +`unit tests (Windows)` CI job; that path is [Native Windows](#native-windows) +and is where to start. Or under **WSL2** — a real Linux kernel, so the +storage stack (file locking, LanceDB, inotify) behaves exactly as it does +on a Linux server; the rest of this page is that install, including the +parts that cannot be scripted and the one failure that is silent. ## Table of contents diff --git a/docs/windows-wsl2.zh.md b/docs/windows.zh.md similarity index 96% rename from docs/windows-wsl2.zh.md rename to docs/windows.zh.md index c74bcaa7d..73b6a72b4 100644 --- a/docs/windows-wsl2.zh.md +++ b/docs/windows.zh.md @@ -1,7 +1,8 @@ -# 在 Windows 上跑 EverOS(WSL2) +# 在 Windows 上跑 EverOS -EverOS 的开发和 CI 在 Linux 上,单测也在 Windows 的 CI 上跑,并且在一台干净的 Windows 11 机器上 -完整验过(见文末「直接在 Windows 上装行不行」)。Windows 上既可以直接装,也可以走 WSL2;这篇主要讲 WSL2。 +Windows 上有两条路。**直接装**:在干净的 Windows 11 机器上 `pip install everos`,已完整验过,CI 也有 +`unit tests (Windows)` 这一档——见文末「直接在 Windows 上装行不行」,建议从这里开始。**走 WSL2**:一套真 +Linux 内核,存储栈(文件锁、LanceDB、inotify)与 Linux 服务器上完全一致;这篇其余部分讲的是这条路。 WSL2 是 Windows 自带的一套真 Linux 内核,不是模拟器也不是虚拟机软件。装完之后 EverOS 就跟跑在一台 Ubuntu 服务器上没区别,文件锁、向量索引、文件监听全部是