Repository navigation
Releases: westsource/agentctxsync
Release list
v2026.10.09.1
v2026.10.09.1 — dsh 适配 DSH Desktop 0.2.0-rc.2:会话格式 v4(原生发布)+ Workspace 分组重建
客户端发布:
CLIENT_VERSION2026.10.06.1 → 2026.10.09.1(mcp/updater.py、server/client_update.py),各端经/api/client/manifest自动更新、重启 Agent 后生效。服务端侧只有分发用的版本号变更(client_update.py),无 schema 变更。已部署 236(代码提交2ef7dbe,3 个文件 LF 归一化预检 / 回读 / 编译全通过,11 项线上核对全 PASS;线上真包解出后就地跑test_dsh.py40 项 OK)。
背景:同步下来的会话在新版 DSH Desktop 里打不开
DSH Desktop 0.2.0-rc.2 的会话格式已是 v4(挂载 @deepseek-ai/dsh-session-format-v3-to-v4),而适配器仍宣称「当前世代 = v3」并按 v3 形态发布。dsh 读器对 v3 产物跑 v3→v4 迁移,其目标校验要求 session/title 在 source.kind='user' 时 messageSeqs 必须为空,适配器却给用户标题写了 messageSeqs: [<首个 user/message 的 seq>]。结果是每个带标题的同步会话在桌面打开时都报
「历史加载失败:… session/title messageSeqs must be empty exactly for a user title; source v3 artifact remains unchanged」。
同一版本还修掉两个独立问题:同步进来的会话全部堆在侧栏「未分组」(Workspace 索引只在首启扫描),以及超长路径下的会话被静默丢失。
Fixed(v3 日志过不了 v4 校验 —— 打开即「历史加载失败」)
- 定位用 dsh 自己的读器(非自证):把桌面
app.asar里的dsh/node_modules解出,用其中的dsh-session-format-catalog/dsh-session-query对真实 store 跑listSessions+readSession—— 410 个日志 6 通过 / 404 失败,错误全是上面的标题规则;把单个日志的标题事件改成messageSeqs: []后同法复跑即通过。改掉标题后又暴露出两处 v4 原生接纳(不再经迁移)的问题:连续 user 行会让上一回合悬空(turn/start does not open the expected turn)、首行即 assistant 的会话缺少受保护的首个 system head(system/message requires a protected first surface head)。 - 修复(
mcp/adapters/dsh.py):_CURRENT_LOG_GENERATION3 → 4:按 v4 原生形态发布,不再依赖迁移链;旧世代日志即使没有新消息也升级(legacy判定改用当前世代),前代逐字节冻结、后继写成session.v4.*。- 用户标题事件写
messageSeqs: [](v4 目标规则)。 - 每轮注入的
system/message来源由 v3 的{kind:'plugin', plugin:'@deepseek-ai/dsh-system-prompt'}改为 v4 要求的{kind:'system-prompt'}。 - 回合骨架修正:开新回合前关闭上一个回合(该回合有模型回复 →
turn/end.reason.kind='completed',否则'interrupted');首行即模型回复的会话也先开回合并写入 system head,保证整份日志的第一个表层事件是受保护的 system 消息。
Fixed(同步的会话全堆在「未分组」—— Workspace 索引只在首启扫描)
- 根因:DSH Desktop 侧栏按 Workspace 分组,成员来自
workspace存储域(<home>/storages/workspace.json)每条记录的sessionIds;dsh 只在首次初始化(initialized尚为 false 时)按会话头 bootstrap 一次,之后永不重扫。适配器此前刻意不写该域(旧版合成行会破坏其不变式),于是所有同步进来的会话都留在「未分组」—— 实测 414 个会话里 383 个本可归属,域里却只有 1 个。 - 修复(
mcp/adapters/dsh.py::_refresh_workspace_index,每次写入后运行):镜像 dsh 自己的 header bootstrap 加性重建归属 —— 按fs.realpath(cwd)分组,把本机会话挂到路径相同的记录、缺失目录时新建记录(标题取末段、时间取组内最新会话)。既有记录、标题与展示顺序全部保留(新记录按 dshcreate的约定前置);CJK/超长路径、大小写漂移、junction 都经同一规范。只动已初始化且结构自洽的 unit:文件缺失、外来 unit(name/version 不符)、initialized: false、workspaceIds与表键不一致或存在pendingMutation时一律不碰(交给 dsh);cwd 不存在或没有 cwd(_no-cwd)的会话不分组 —— 与 dsh 自身一致。
Fixed(顺带:长路径会话被静默丢失)
_log_file/_session_files/_find_any_log/_log_meta/status/ 投影缓存写入此前用裸Path.iterdir()/read_bytes(),store 根一深(path ≥MAX_PATH)就抛OSError被吞,会话既不读也不写(_session_files少返、_log_file返回None)。现统一走_lp()扩展长度形式(新增_isdir/_scan/_mtime纯辅助)。实测副本根把某些会话目录顶到 267 字符时,修复前 404/413、修复后 414/414。
测试与验证
- 端到端(dsh 自己的读器,非自证):把真实 store 的 409 个会话经修复后的适配器全量写入空 store(
imported: 409 / new_messages: 26066),再用dsh-session-query对写入结果逐个readSession—— 409/409 全通过(含 1018 事件的长会话、CJK/超长路径会话、_no-cwd会话)。对真实 store 的副本跑一次升级写(updated: 413、new_messages: 0):前代 v3 逐字节冻结、每会话新增session.v4.jsonl.zstd后继,414/414 后继readSession通过(修复前 404/413)。桌面实机:重启后打开同步会话正常渲染历史(不再报历史加载失败)。 - Workspace(dsh 真实域 + 桌面实机):真实 store 副本上先跑一遍:记录 33 → 45、成员 1 → 383,三条不变式(唯一所有者 / 一目录一记录 / 顺序 == 表键)断言通过、二次运行幂等;对真实 store 应用后重启桌面实测:侧栏出现
service / client / diagramon / OpenCode / work / Desktop / D:\ …等新分组,agentctxsync(69)、中医健康(16)、hermes-sync-foreign(121) 等各归其位,「未分组」只剩 cwd 在本机不存在的会话(本次快照 24 个)。 - 单测:
mcp/tests/test_dsh.py—— 当前世代落盘为 v4、用户标题messageSeqs为空、system/message为system-prompt来源、当前世代就地重写、v3 前代升级为 v4 且前代逐字节不变、连续 user 行以interrupted收口、模型先行会话以 system head 打头、超MAX_PATH的会话目录仍能被_log_file读到;WorkspaceIndexTest7 例。mcp套件 223 项 OK。 - 236 部署核对(11/11 PASS):manifest(
agent=dsh与agent=omp,v=2026.10.06.1)均返回 2026.10.09.1 且update_available=true;包内manifest.json版本一致;包内mcp/adapters/dsh.py含_CURRENT_LOG_GENERATION = 4、{"kind": "system-prompt"}、_refresh_workspace_index、_lp(;包内mcp/updater.py为新版本号;包内 dsh.py 与清单 sha256 及提交2ef7dbe三者逐字节相符。另把线上agent=dsh归档解出,就地对其mcp/adapters/dsh.py跑test_dsh.py—— 40 项 OK。
Docs
docs/SUPPORTED_AGENTS.md(DSH 行)与docs/ARCHITECTURE.md(dsh 小节):当前世代 v3 → v4,补上用户标题messageSeqs为空、system-prompt来源、v3→v4 边与 v4 原生接纳的拒绝规则;workspace 域由「外部不写」改为「按会话头加性重建归属」并写明其不变式与延迟可见(需重启桌面)。
兼容性
- 已装客户端会在下一次更新检查(每小时一次,或 Agent 重启后 60 秒)自动拉取 2026.10.09.1,重启 Agent 后新逻辑生效;升级后的客户端会把本机既有的 v3 日志在下一次同步时升级为 v4(前代逐字节保留)。
- 未更新的客户端仍按 v3 发布,在 0.2.0-rc.2 桌面里仍会「历史加载失败」——请更新客户端(或重启 Agent 触发自动更新)。
- 服务端无 schema/接口变更,旧客户端行为不变。
生产状态:236 已部署(代码提交 2ef7dbe,记录提交 f68b9f6,tag v2026.10.09.1)。
v2026.10.06.1
v2026.10.06.1 — 模型名不再同步:会话的模型选择是设备本地字段
客户端 + 服务端发布:
CLIENT_VERSION2026.10.02.1 → 2026.10.06.1(mcp/updater.py、server/client_update.py,另含mcp/server.py与 6 个适配器、server/sync.py),各端经/api/client/manifest自动更新、Agent 重启后生效。无 schema 变更(sessions.model列保留,历史值原样留着,只是不再被同步读写)。服务端/push/pull有配套改动:已部署 236(提交f5be193,10 个文件预检/回读/编译全通过,8 项线上核对全 PASS)。
背景:为什么模型名必须退出同步
model 原先既是 canonical 会话字段、又在服务端按 derived/LWW 处理(无字段级并发保护),而 sync_full 是先拉后推——于是设备上刚切好的模型会在同一轮里被服务端旧值回写覆盖。更糟的是各 agent 的落地并不一致:omp / dsh / reasonix 从不把本机模型上行(omp 读文件时忽略自己的 model_change 条目、dsh 只用它给 assistant 消息盖 source 戳、reasonix 根本没有该字段),而 opencode / hermes / workbuddy / openclaw 是双向同步。同一份"会话"在不同设备上表现为"模型会同步"或"模型不会同步",取决于哪个 agent 打开它。
模型本就是"这台机器这次选用什么"——它是设备级设置,不是共享对话内容。本次把它明确定义为本地字段(local-only),彻底切断两个方向。
Changed
- 契约:
mcp/adapters/base.py新增LOCAL_ONLY_SESSION_FIELDS = ("model",),CANONICAL_SESSION_FIELDS移除model;通用SQLiteAdapter._map_cols跳过该列,1:1 列透传的适配器(hermes)不再把它读进 canonical 载荷。 - 客户端:新增
_strip_local_only()。推送侧在推送指纹计算之前剥离model及消息级副本——切模型既不进服务端、也不再算"内容变更"(不会触发重推);拉取侧在交给适配器写库前剥离——旧服务端/旧对端即使带着它,也盖不掉本机选择。 - 适配器:opencode 不再读
session.model与消息model、不再写model列(删除_model_db;本机已有值原样保留,新建外来会话该列留空);workbuddy 不再读写model列(_upsert_session去掉 model 参数);openclaw 索引不再读写 model,新建 transcript 的必需model_change事件写unknown占位;dsh 的 assistantsource戳改为unknown/unknown(删除_model_parts);omp 不再写model_change条目(读时仍容忍真实 omp 文件的该条目);scripts/import_doubao.py删除--model。 - 服务端:
push_sync丢弃载荷中的model(INSERT/UPDATE 均不写该列),pull_sync在返回前剥掉。既有行里的历史值保留不动(Web/API 仍可读到,只是不再更新)。 - 版本:
CLIENT_VERSION2026.10.02.1 → 2026.10.06.1(mcp/updater.py、server/client_update.py)。
测试与验证
- 客户端
mcp套件 212 项 OK;服务端server套件 292 项 OK / 2 skipped。 - 新增回归:客户端剥离与指纹隔离、pull 不写回;opencode / workbuddy 本地 model 保全;服务端 push 不写列(INSERT 与 UPDATE 两条路径)与 pull 不下发。
- 真机冒烟(真实
mcp/server.py+ stub HTTP 服务 + hermes 形态 SQLite 库):push 载荷键为id/agent_type/cwd/started_at/title/message_count/messages/field_meta(无model),pull 落库新消息后本地sessions.model仍是本地值。 - 236 部署核对 8/8 PASS:manifest(agent=omp&v=2026.10.02.1) → 2026.10.06.1 且
update_available=true、包内manifest.json版本一致、包内mcp/server.py含剥离代码且 sha256 与清单相符、adapters/base.py含LOCAL_ONLY_SESSION_FIELDS、adapters/opencode.py已无_model_db。 - 236 线上功能核对:
/pull不下发 model —— DB 中 631/1012 个会话仍持有 model 值,线上拉一页 15 条(其中 3 条 DB 里确有值),返回载荷中带model键的为 0 条;/push不落 model —— 把一个真实会话按客户端方式重推并注入model="PROBE-should-not-be-stored",列值仍为原deepseek-v4.1-flash(209 条消息全部去重、updated=1)。
兼容性
- 已装客户端会在下一次更新检查(每小时一次,或 Agent 重启后 60 秒)自动拉取新版本,重启 Agent 后新逻辑生效。
- 升级前的老客户端仍会推
model:服务端已忽略,不会写入;老客户端拉取时服务端已不下发,因此不会再覆盖本机模型。 - 服务端库里 631 个历史
model值保持可读(Web/API),仅不再更新。
生产状态:236 已部署(代码提交 f5be193,记录提交 bee8d78,tag v2026.10.06.1)。
v2026.10.02.1
v2026.10.02.1 — 后台同步主备改为持久租约 · 备副本自动接管
纯客户端发布:
CLIENT_VERSION2026.09.21.3 → 2026.10.02.1(mcp/server.py、mcp/updater.py、mcp/auto-sync.py、server/client_update.py),各端经/api/client/manifest自动更新、Agent 重启后生效。无 schema 变更,无服务端 API / Web UI 改动。触发场景:同一台机器开多个 Agent 窗口(每个窗口各 spawn 一个 MCP 客户端),实测只有一份在跑后台同步,而角色由"启动抢锁"一次性决定——关掉拥有后台循环的那个窗口后,其余窗口的后台同步会静默停摆。
Fixed(后台同步的角色与并发)
- 主/备角色改为持久租约,备副本会自我提升:原先角色只在启动时决一次(O_EXCL 锁每周期释放 ⇒ 后启动的副本总能赢下已释放的锁并自认主,副本越多越像多主;而抢锁输家则终身 standby——omp 这类不重拉 MCP 进程的宿主上等于永久停摆)。改为
hermes-sync-<agent>.primary.json租约(属主 pid + 心跳 + 上次成功同步时间,原子写、每周期刷新):启动时属主仍活着就做备(同时启动仍由 O_EXCL 锁选出唯一主),备每周期复查租约并在属主失联后接管继续跑后台循环。「失联」= 心跳已过一个完整HERMES_SYNC_INTERVAL且 pid 探测说死,或心跳超过HERMES_SYNC_LEASE_STALE_S(默认INTERVAL+120,兼作 pid 复用兜底);心跳优先,避免误判造出第二个主,代价是失联后最多晚一个周期接管(与同步间隔同量级)。顺带消除"每开一个窗口就重复一次启动全量同步"。 - Windows 进程判活不再失真:
os.kill(pid, 0)实测三种问题——对pid 4抛SystemError(不是OSError,原实现接不住,启动任务直接崩、角色卡在starting);对部分活进程报WinError 87(被当成死的 ⇒ 会窃取活写者的锁);对刚退出但父进程仍持有句柄的进程报"活"。改用OpenProcess(SYNCHRONIZE|QUERY_LIMITED_INFORMATION)+ 零超时WaitForSingleObject,不可判定(access denied)按"活"处理;POSIX 分支不变。 - OpenClaw 常驻同步进程真的持锁了:
mcp/auto-sync.py的文档一直写"与 MCP server 共用同一把锁,绝不并发",但循环体从未取锁(会与 MCP server 的后台周期并发写同一本地库)。现在每轮经run_once()取同一把跨进程锁,拿不到就跳过本轮、下一 tick 重试。
Added(省事与可观测)
- 启动同步新鲜度:上次成功同步的时间记在租约里(同机多窗口共享),距上次成功不足半个同步间隔就跳过启动拉取(另一个窗口刚同步过);手动
sync_full/sync_pull/sync_push成功后同样刷新。 sync_status增加instance段:pid、role、background_sync、lock_holder_pid、lease/lease_owner_pid/lease_owner_alive、log_file、client_version—— "谁在同步、有没有重复"不必再猜。- 日志落盘:
hermes-sync-<agent>.log(与锁同目录,单代轮转为.log.1,HERMES_SYNC_LOG_MAX_BYTES默认 512 KiB)。宿主通常丢弃 MCP stderr(omp 全平台不留存),此前客户端进程崩溃没有留下任何可查线索;stderr 同时改为以 UTF-8 字节写出,避免中文 locale 下 GBK 编码让对方(含 MCP SDK 自身的 stdio 客户端)reader 线程解码失败。 - 新环境变量:
HERMES_SYNC_LEASE_FILE、HERMES_SYNC_LEASE_STALE_S、HERMES_SYNC_STARTUP_DELAY_S、HERMES_SYNC_LOG_FILE、HERMES_SYNC_LOG_MAX_BYTES。
测试与验证
- 客户端 208 项 OK(194 → 208:新增租约存活判定、备按周期延后与自我提升、启动选举与让位、启动新鲜度、
sync_status实例段、日志落盘与轮转、auto-sync持锁;删除把旧行为钉死的 standby 测试)。 - 真机冒烟 13/13(两个真实
mcp/server.py进程 + 私有锁/租约/日志目录 + server 指向不可达端口,避免触碰本地库):A 选举为主 → B 判为备且不写库 → 杀掉 A 后一个周期内 B 自我提升 → 新副本立刻让位给活属主,sync_status全程如实回报角色与属主。 - 236 部署:4 个文件对上一版提交
b8a55ae逐文件预检通过、上传后回读 sha256 一致、py_compile通过;重启后 active、journal 无 error、回环/health=200。线上核对 9/9 PASS:/api/client/manifest?agent=omp&v=2026.09.21.3返回 2026.10.02.1 +update_available=true、包内manifest.json版本一致且mcp/server.py的 sha256 与清单相符、包内含租约代码且不再含旧的 "standby 终身" 文案、含新存活探测、mcp/updater.py为新版本号、mcp/auto-sync.py持锁。
生产状态:236 已部署(代码提交 b07eb4d,记录提交 86a5ce5,tag v2026.10.02.1)。已装客户端会在下一次更新检查(每小时一次,或 Agent 重启后 60 秒)自动拉取新版本,重启 Agent 后新逻辑生效(正在运行的 MCP server 进程仍持旧代码)。
v2026.09.21.3
v2026.09.21.3 — 文档全面校准 · CI 修复 · 客户端文案纠正
服务端 + 客户端发布:
CLIENT_VERSION2026.09.21.1 → 2026.09.21.3(客户端包有改动:sync_full的工具描述与占位 Key 前缀),各端经/api/client/manifest自动更新、Agent 重启后生效。无 schema 变更。本版汇总自v2026.09.21.1以来的两版(2026.09.21.2 / .3)。
Fixed(文档与代码里的不一致)
sync_full的工具描述写反:描述是"先推后拉",实现与契约都是先拉后推(拉取先锚定本机每字段基线,再只推脏字段,避免覆盖同侪的较新元数据)。工具描述与 README(中英)同步更正——这是 Agent 在工具列表里直接看到的文案。- 占位 Key 前缀:客户端
HERMES_SYNC_API_KEY的默认占位串hsk_placeholder→ws_placeholder;scripts/migrate-local-to-server.py的提示hsk_→ws_(generate_api_key一直是ws_)。 - CI 两个 job 长期为红,本版修好:
- 客户端 job 未装依赖却要 import
mcp/server.py(该模块在模块级 import MCP SDK),dsh 适配器还需要zstandard→ 补pip install mcp zstandard; - 服务端 job 的 import 冒烟测试用
[r.path for r in app.routes],新版 FastAPI/Starlette 把 include 的 router 包成_IncludedRouter(没有.path)→ 改走 OpenAPI schema 枚举路径。 - 按 CI 的两条命令在干净 venv 复跑:客户端 194 项 OK、服务端 285 项 OK(2 skip)。
- 客户端 job 未装依赖却要 import
scripts/deploy-remote.py的上传清单硬编码、含已删除的feedback.py且漏掉后来新增的模块 → 改为按server/*.py实际文件上传。
Changed(文档按代码事实全面校准)
- README(中英)新增「What you get / 功能总览」:服务端八个模块面(同步 API、项目 API、Web UI、账号与访问、租户与管理员、配额与套餐、客户端分发、运维)、MCP 客户端全貌(7 个适配器、收敛规则、Primary/Standby 并发、自动更新)、侧车与环境变量指引;快速开始的上传清单补
mcp/(deploy-server.sh需要它,照抄旧命令会漏)。 docs/ARCHITECTURE.md:模块表补齐ratelimit.py/captcha.py/mailer.py/emailverify.py/jsonbody.py/announcements.py/_run_local.py;schema 由 11 张表补到 16 张(新增user_verification_tokens、mail_stats、access_stats、access_device、announcement_dismissals);新增「账号、访问与内容治理」决策节(600k 轮 PBKDF2 + 惰性升级、用户名/已验证邮箱登录、账户状态机与"只拦新建工作空间"的门禁、邮箱令牌一次性、自托管验证码、8 个限流 scope 阈值表、发信日预算、访问统计与src=真实性、公告、全局搜索、i18n 解析顺序、单 worker + 回环部署约束);路由表补 forgot/reset/security/公告/部署脚本下载;scripts/表补齐;组件说明纠正"pgvector"(实际只需标准版自带的pg_trgm)与侧车命名(按 agent 前缀)。docs/CONFIGURATION.md重写:服务端 12 个变量(补PUBLIC_URL/ANNOUNCEMENTS_URL/SMTP_*/MAIL_DAILY_CAP及缺省行为)、客户端 10 个(补omp与锁文件覆盖项)、侧车文件清单与"删掉会怎样"。CONTRIBUTING.md:启动命令server/server.py→server/main.py;「服务端还没有单测」→ 两套 unittest 的真实命令、数量与 CI 位置。SECURITY.md:PBKDF2 迭代数 100,000 → 600,000(并说明每份哈希自带迭代数、登录时惰性升级);「已知安全设计」补齐租户隔离、限流阈值、自托管验证码、令牌一次性与审计。server/.env.example:补齐可选变量并标注必填项。
Fixed(Web UI)
- 注册页的密码字段改为纵向排列:
/web/register的「密码 / 确认密码」原本在grid grid-cols-2里左右并排(窄卡片里每个仅约 194px),改为各占整行;用户名 / 显示名仍并排(非密码字段)。修正后全站密码字段均为纵向(登录、重置、安全中心修改密码弹窗、首次登录强制改密页、更换邮箱、管理员新建/编辑用户均已核对)。
测试与验证
- 客户端 194 项、服务端 285 项(按 CI 命令在干净 venv 复跑,全绿);线上(236)65 个上机文件与仓库逐字节一致,
/api/client/manifest返回 2026.09.21.3 +update_available=true,下载包内manifest.json版本一致且mcp/server.py的 sha256 与清单相符。
生产状态:236 已部署(提交 b8a55ae)—— 预检/回读/py_compile/重启全过,systemctl is-active = active、journal 无 error;server/ 与 mcp/ 共 65 个上机文件对仓库 b8a55ae 零差异;线上 7 项核对(manifest 版本、update_available、包内 sha256、包内文案已为"先拉后推"、占位 Key ws_)全 PASS。v2026.09.21.1 的「按会话暂停/恢复同步」已包含在同一部署中。
v2026.09.21.1
v2026.09.21.1 — 按会话暂停 / 恢复同步(Web 多选 · 服务端冻结 · 客户端补推)
服务端 + 客户端发布:
CLIENT_VERSION2026.09.13.4 → 2026.09.21.1(mcp/updater.py与server/client_update.py同步),各端经/api/client/manifest自动更新、Agent 重启后生效。schema 变更:sessions新增sync_paused/sync_paused_at两列,由启动时的init_db()幂等ALTER TABLE ... ADD COLUMN IF NOT EXISTS创建,无需人工迁移。
Added(Web 端可多选:暂停 / 恢复会话同步)
- 选定会话暂停 / 恢复同步:工作空间详情页与「全部会话」页每行带勾选框,批量栏一次暂停或恢复所选会话;每行另有单行按钮,会话查看器页也有同一按钮。暂停的会话在列表里带「已暂停同步」徽标。
- 语义是服务端冻结上行,不是把会话从共享池里摘掉:
/push(以及 Web 导入)对这些会话零写入 —— 会话元数据、消息、rev/field_rev、last_synced_at全部不动。闸门放在push_sync最前面,所以配额闸门、消息去重快照与 rev 记账都看不到被暂停的会话:整会话冻结,不是部分合并。/pull照旧下发冻结版本。否则其它设备会认为"服务端没有这个会话",触发「本地删除不是删除信号」的补回逻辑,或让用户以为会话被删。暂停只断上行。
/push新增响应字段paused_ids:本次被冻结跳过的会话 id。客户端据此不记录 push 指纹 —— 指纹的含义是"服务端已持有这份内容",记了它,暂停期间的内容在恢复后将永远补不上去(本地无变化 → 指纹命中 → 跳过)。不记指纹 = 每轮重发,恢复后下一轮自动补齐。- 路由:
POST /web/sync/pause/POST /web/sync/resume,表单字段sel可重复,值"<ws_id>:<session_id>"(会话 id 自身可含冒号,只按第一个冒号切分);只更新调用者自己的 workspace(workspaces.user_id校验),返回路径next限同源。行内已有各自的删除表单,所以每行勾选框用form="sync-bulk"关联到行外表单(HTML 不允许嵌套 form),两个提交按钮用formaction区分。 - Web 导入(
/web/workspace/{id}/import)跳过暂停会话并在 flash 中报数(与/push同规则,不静默丢弃)。
Changed(客户端与文档)
mcp/server.py::push_sessions识别paused_ids:不计入processed、不写指纹,日志与返回体给出paused/paused_ids计数(多 chunk 合并计数)。- 新增词条
sync_pause_*/sync_bulk_*/ws_import_paused(zh-CN + en);_macros.html新增sync_paused_chip()。 - 文档:
docs/ARCHITECTURE.md新增「暂停/恢复同步」决策记录(含混合版本窗口与回归防线)、sessions 表两列、路由表、pull 过滤段说明;docs/server-deployment.md路由表 //push响应字段 / sessions 列表同步;docs/OPERATIONS.md数据保留章节补条目;README(中英)功能要点同步。
混合版本窗口(实测确认)
- 未更新的客户端照常自动同步:旧客户端(2026.09.13.4)对新服务端实测 —— 普通会话推送行为一字未变;推送被暂停会话时返回计数 0、无 error,同步循环不中断,同批次其它会话照常入库。
- 代价:旧客户端会把那次"被跳过"的推送当作成功并写入指纹,于是暂停期间的内容要等该会话本地内容再变化一次、或清一次指纹 sidecar(
<mcp目录>/.hermes-sync-field-meta-push-fingerprint.json)才会补上。实测清掉 sidecar 后下一轮即补齐。客户端自动更新后该窗口自然消失。
测试与验证
- 单元测试:
server/tests/test_sync.py(PushTest新增 sync pause 用例:冻结零写入、同批次其它会话照常落库、响应带paused_ids、暂停会话不再进入配额闸门)、server/tests/test_sync_pause_ui.py(sel解析含冒号 id、端点、多选、属主校验、空选择提示、next同源校验、导入跳过并计数、zh/en 词条齐备)、mcp/tests/test_push_pause.py(不写指纹、下一轮重发、混合版本窗口、多 chunk 合并计数)。全量:server + mcp 477 passed / 2 skipped。 - 线上(236)20 项检查全 PASS:两列由
init_db()建出;匿名POST /web/sync/pause→ 307/web/login;Web 暂停后/push返回paused_ids且该会话标题/消息数原封不动、/pull仍下发冻结版本;列表页渲染徽标与批量栏;恢复后同一次推送补齐暂停期间的 2 条消息;/api/client/manifest?agent=hermes&v=2026.09.13.4返回 version 2026.09.21.1 +update_available=true,下载包内manifest.json版本一致、mcp/server.py的 sha256 与清单相符且含paused_ids。
生产状态:236 已部署(提交 9531474)—— 11 个文件(6 个 .py + 4 个模板 + mcp/ 客户端 2 个)对上次部署提交 cab5a8a 逐文件 LF 归一化 sha256 预检通过、上传后回读一致、py_compile 通过,重启后 systemctl is-active = active、journal 无 error;check_drift_236.py / check_drift_mcp_236.py 复核 server/ 50 个文件、mcp/ 15 个文件 drift=0。线上 /api/client/manifest 已服务 2026.09.21.1。
仓库说明:v2026.09.19.2 的历史重写(清除部署细节 + 提交身份统一为 noreply)保持不变;本次发布起,新提交与 tag 使用维护者身份 道荣 <westsource@126.com>。
v2026.09.19.2
v2026.09.19.2 — 服务端加固:发信防滥用 · 登录支持已验证邮箱 · 仅回环监听
服务端专用发布:
CLIENT_VERSION保持 2026.09.13.4 不变 —— 客户端包零改动,/api/client/manifest不触发自动更新,各端无需重启。本版汇总自v2026.09.13.4以来的 14 次服务端发布(2026.09.14.1 → 2026.09.19.2)。无破坏性 schema 变更:新增mail_stats、announcement_dismissals两张表与access_device.user_id一列,全部由启动时的init_db()幂等创建。
Added(发信防滥用:收件人侧限流 + 全站日预算 + 验证码)
- 收件人侧限流(
ratelimit.py新 scope,key 是收件地址/账号而非来源 IP —— 换 IP 不能放大):mail_recipient1 封/分钟 + 5 封/天(已解析的已验证邮箱)、mail_account10 封/小时(bind/resend/ 安全中心,按user_id)、mail_address1 封/10 分钟(调用方自填地址)。此前唯一上限是每 IP 5 封/10 分钟 = 单邮箱 720 封/天/IP。 - 全站每日发信预算:
HERMES_SYNC_MAIL_DAILY_CAP(默认 200),计数落mail_stats(sent/rejected/failed),DB 计数所以重启不清零;超限拒绝发送并记rejected(可作滥用告警信号)。动机:发信账号有服务商日额度,被刷爆后所有用户收不到激活/重置邮件,而/web/forgot是静默失败,用户只会觉得"没收到"。 /web/forgot挂自托管验证码(复用captcha,图形为纯矢量线条、答案只存服务端):闸门在任何账号查询之前,失败只回表单页且保留已填标识符,不泄露标识符是否存在。- 登录页明确支持"已验证邮箱":标识符可用用户名或已验证邮箱(用户名优先匹配,大小写不敏感;未验证邮箱不可用),标签与提示随邮件功能开关显示;新增
server/tests/test_login.py8 例覆盖解析规则与"两种标识符失败响应逐字节相同"。
Changed(发信路径与监听面)
- 验证码改为纯矢量渲染:原先把算式写成 SVG
<text>节点,一个正则即可读出算式;现在只用<path>(7 段式折线 + 随机旋转/抖动/线宽)与装饰线点,标记里不含任何字符数据。这是成本门槛,不是人性证明 —— 真正的天花板是上面的收件人桶与日预算。 - 发信移出事件循环:六个发信点全部改走
asyncio.to_thread(对齐render/sync的做法)。此前同步 smtplib(timeout=20、每封新建连接)直接在 async 处理器里调用,单进程 uvicorn 下每个发信请求都会卡住登录与同步。 - 限流计数表满时改为失败关闭:原先超过 20000 个桶就整表
clear(),等于让刷不同 key 的调用方把所有人的预算(含 login 桶)一起清零;现在先淘汰过期条目,仍满则拒绝该请求并打印告警。 - 服务端只监听
127.0.0.1:8765(原0.0.0.0):反向代理成为唯一对外入口,明文端口不再出现在任何非回环接口上;request.client.host只可能来自代理,按 IP 限流与来源审计的前提由"依赖防火墙/安全组"变为"进程自身保证"。自托管部署必须前置反向代理才能从其他机器访问(README / SECURITY / 部署文档已同步)。 - 侧边栏「官网」入口 +
?landing=1(已登录用户可显式请求落地页),文案「官网」→「官方首页」;「提交反馈」改为 GitHub Issues 外链,删除站内反馈整套死代码。
Fixed
REQ src=不再取X-Forwarded-For最左值(那是调用方可控的):改用 uvicorn 解析出的真实对端 IP,原始链另存xff=字段。限流一直是按真实 IP 记的(伪造 XFF 绕不过),改的是审计与取证的可信度。- 邮箱注册的首次激活必 500:
RealDictCursor上按位置取RETURNING id,改为按列名取值。 - 邮件正文改为可点击链接(
multipart/alternative,HTML 里锚文本即完整 URL);链接有效期统一为 3 小时并由常量派生(激活链接从 30 分钟 → 12 小时 → 3 小时,重置保持 30 分钟),邮件文案与等待页文案不再各写一份。 - 落地页「支持的 Agent」计数不再写死(由
PUBLIC_AGENTS派生,并补齐枚举漏项)。
Added(运营与可见性)
- 公告横幅(可选,默认关闭):
HERMES_SYNC_ANNOUNCEMENTS_URL指向公开 feed 时,登录用户看到顶部横幅;服务端不出网,由浏览器直取 feed;announcement_dismissals表 +POST /web/announcement/dismiss记录已读。 - 设备访问归属到用户:
access_device.user_id记录 API key 对应的归属,管理页设备明细可按用户/Agent 展开;回填脚本scripts/backfill-access-device-user.py。
生产状态:236 已部署(提交 cab5a8a)—— server/ 与 mcp/ 逐文件 sha256 与仓库一致、重启后 systemctl is-active = active、/health 200、journal 无 error;线上实测本次加固项(验证码为矢量且无字符数据、错答被拒、正答走统一响应页、REQ src 为真实对端、mail_stats 计数与超预算拒绝)与登录页新文案(中英)。CLIENT_VERSION 保持 2026.09.13.4,线上 /api/client/manifest 仍服务该版本。
仓库说明:同日为清除历史中的部署细节并统一提交身份为 GitHub noreply,对全部历史做过一次重写(含 tag 重指),历史哈希已变化;旧→新映射保存在本地运维手册(不入库)。
v2026.09.13.4
v2026.09.13.4 — 补齐 opencode 的「最后更新时间」落点(13.3 漏判)
客户端发布:
CLIENT_VERSION2026.09.13.3 → 2026.09.13.4(客户端包有改动,各端经/api/client/manifest自动更新,Agent 重启后生效)。服务端仅版本常量,无 schema 变更、无数据迁移(13.3 已建的sessions.last_activity_at继续用)。
Fixed(opencode 的 session.time_updated 也要用真实活动时间)
- 漏判:13.3 只改了 workbuddy / omp / openclaw(外加共享的
adapters/base.py),把 opencode 记为"已用ended_at,不改"——但它与另外三个是同一类落点:session.time_updated是 opencode 桌面端的排序键,写入用的是ended_at(缺失才回退now),而ended_at各 agent 语义不一致(13.3 实测 40 条拉取会话:5 条缺失、只有 3 条等于最新消息时间),所以这类会话在 opencode 列表里仍可能显示成"刚刚"或陈旧时间。 - 修复:
mcp/adapters/opencode.py的 INSERT / UPDATE 两条路径都改用共享助手session_last_activity()(服务端last_activity_at→ 本次载荷最新消息 →ended_at),保留>= time_created下界,now仅在该会话没有任何可用时间戳时兜底。 - 7 个注册 adapter 全量复核(
hermes/opencode/reasonix/openclaw/workbuddy/omp/dsh,另作者工作区未提交的chatgpt只读):改代码 4 个 —— 13.3 的 workbuddy / omp / openclaw + 本次 opencode;hermes 零代码改动(state.db本就有last_activity_at列,进 canonical 后 1:1 映射自动读写);dsh / reasonix 无"最后更新"元数据(dsh 投影缓存只有createdAt,列表顺序由日志/消息决定);chatgpt 只读上传不写本地。 - 测试:
mcp/tests/test_opencode.py+1(写入与更新路径的time_updated= 最新消息时间、不早于time_created)。mcp 套件 196 项、server 套件 196 项通过(仅 3 项改动前既有的环境失败)。文档:ARCHITECTURE 决策记录 2026.09.13.3 的 adapter 映射表与回归防线同步修正(opencode 由"不改"改为"已补齐")。
生产状态:236 已部署 2026.09.13.4(3 文件 sha256 与工作区一致、systemctl restart 后 health 200),线上 /api/client/manifest 服务该版本。
v2026.09.13.3
v2026.09.13.3 — 会话最后活动时间由服务端按消息派生
客户端发布:
CLIENT_VERSION2026.09.13.2 → 2026.09.13.3(客户端包有改动,各端经/api/client/manifest自动更新,Agent 重启后生效)。服务端有 schema 变更:新增sessions.last_activity_at(可空,启动时幂等 ALTER 建列),存量行由scripts/backfill-last-activity.py一次性回填(生产已执行 691 行)。
Fixed(拉取到本机的会话"最后更新时间"是同步时刻,不是最新消息时间)
- 现象:从远端同步下来的会话在本地一律显示成"刚刚"。WorkBuddy 列表按
COALESCE(updated_at, created_at)排序 → 一次同步把被触碰的会话全部塌到同一个时间戳并顶到最前(实测本机workbuddy.db:6 行最近被触碰的会话updated_at全等于同步时刻,而其最新消息分别在 08-21 / 08-29 / 09-03);Hermes 桌面端的last_activity_at对同步创建的会话是 NULL;omp 的 title slotupdatedAt、OpenClaw 索引的updatedAt/lastInteractionAt/lastActivityAt都恒写now。 - 根因:
last_activity_at不在 canonical 模型里,各 adapter 只能拿ended_at,而它各 agent 语义不一致(2026-09-13 实测 40 条拉取会话:5 条缺失、只有 3 条等于最新消息时间);workbuddy 的写入路径还把值max(..., now)钳到同步时刻(来自最初的 adapter 提交,无注释、无测试守住)。 - 修复(服务端派生 + 客户端消费):
sessions.last_activity_at= 最新一条可见消息的timestamp,由server/sync.py::push从本次载荷的消息派生(客户端同名字段被覆盖;无消息的元数据型 push 透传客户端值,不清空)。mcp/adapters/base.py新增共享助手session_last_activity()(服务端值 → 最新消息 →ended_at→now),落地点:workbuddyupdated_at+last_activity_at(去掉 now 钳制,仅保留 ≥ created_at 下界)、omp title slotupdatedAt、openclaw 索引三字段(并读回lastActivityAt)、hermes 零代码改动(state.db本就有该列,进 canonical 后 1:1 映射)。opencode(已用ended_at)/dsh/reasonix(无该元数据)/chatgpt(只读)不改。 - 存量回填:
scripts/backfill-last-activity.py(dry-run 默认 /--apply/ 幂等 / 只看可见消息)。 - 验证:本机真实 store 重新落地一条 08-21 的会话 →
updated_at/last_activity_at= 08-21 22:44:27(其最新消息时间),同步发生在 09-13 19:01;生产回填 691 行、复跑 0 改动;线上/pull抽样 25 条:24 条与MAX(m.timestamp)完全相等、0 条不符、1 条无可见消息(NULL,按设计)。mcp 套件 195 项、server 套件 196 项通过(仅 3 项改动前既有的环境失败)。
生产状态:236 已部署 2026.09.13.3(9 文件 sha256 与工作区一致、systemctl restart 后 health 200),线上 /api/client/manifest 服务该版本,发货 zip 含新代码。决策记录:ARCHITECTURE「会话最后活动时间(last_activity_at)」;运维说明:OPERATIONS「Session Last Activity」。
v2026.09.13.2
v2026.09.13.2 — workbuddy 项目清单接入共享项目池 + 根目录不入池
客户端发布:
CLIENT_VERSION2026.09.13.1 → 2026.09.13.2(客户端包有改动,各端经/api/client/manifest自动更新,Agent 重启后生效)。服务端:仅client_update.py的版本常量,无 schema 变更、无数据迁移。
Added(workbuddy 项目清单接入共享项目池:本机项目列表与服务端项目卡片对齐)
- 背景:项目是工作空间级共享池(
/api/projects/pull返回全部可见项目,与agent无关),此前只有 hermes 在推。WorkBuddy 桌面端的项目清单(workbuddy.db的workspaces表:path+last_opened_at)完全没有上行——服务端 10 个项目全是 hermes 的;网站项目卡片按cwd前缀关联会话,因此本机 286 条 WorkBuddy 会话里 191 条落在项目之外。 - 实现(
mcp/adapters/workbuddy.py,supports_projects = True):read_projects每条 workspace 路径一个项目——服务端已知路径原样使用服务端的 id/slug/name/folders(身份存于.workbuddy-sync-projects.json侧车;改用目录名派生会被判"本地脏"并每轮覆盖对端项目名),未知路径铸确定性wb_<sha1(_path_key)>+ 整路径压平的 slug(逐路径唯一,不会触发服务端同名合并)。write_projects按本次 pull 全量重建侧车,并为每个项目路径补workspaces行(建目录);已有行只保留、不再定日期,本地行永不删除。 - 能力位:新增
supports_projects(默认 False)——无本地项目库的适配器在周期同步里跳过项目阶段,工具面返回Agent X has no local project store …,此前每轮记一条 AttributeError 日志。 - 线上验证:pull 落地 8 条 workspace、采纳 9 个服务端项目身份;push 服务端 10 → 13 且既有 10 个项目零字段改动(
imported:3, updated:9, merged:0);二次 push 幂等。
Added(根目录不入共享项目池:识别根形状路径并排除,退役已上行的根卡)
- 背景:
D:\、C:\Users\rong这类"根"条目会让项目卡变成兜底桶(实测rong吞 99 条、D:吞 27 条 = 110/286,其中 62 条是~/hermes-sync-foreign合成目录);而服务端project_folders只并集、无删除 API,项目也没有 Web 隐藏/删除入口 ⇒ 一旦上行即永久。故"各 agent 拉到本机后把本机 home 并进根项目"的方案被否决(四条理由与被保留的"服务端可回退 alias"备选见 ARCHITECTURE 决策记录 2026.09.13.2)。 - 实现:
base.is_root_project_path/strip_root_project_paths(盘符/filesystem 根、/root、/home·C:/Users及其子项);push/pull 边界统一过滤——纯根项目整条不上行/不落地,根primary_path只置None、绝不改写(它是 per-field LWW 字段,改写会与对端逐周期互推覆盖);workbuddy 适配器另清理身份侧车。本地路径全保留(D:\仍是 WorkBuddy 的 workspace,hermesprojects.db不动)。 - 退役:
scripts/hide-root-projects.py(dry-run 默认 /--apply/--undo)置hidden=1——/api/projects/pull与 Web 项目列表都过滤hidden,且 push 的 UPDATE 分支从不写hidden⇒ 客户端推不活(已用旧客户端语义复推实测)。 - 实测影响:服务端项目卡 13 → 11;本机参与同步的 workspace 键 15 → 13;本机会话归组 207/286 → 97/286(诚实结论:项目同步对本机会话分组的净收益≈0,真实收益是"项目清单对齐 + 其他机器/agent 的会话能归入这些项目")。
生产状态:236 已部署 2026.09.13.2(6 文件 sha256 与工作区一致、systemctl restart 后 health 200),线上 /api/client/manifest 实测 served 2026.09.13.2,发货 zip 逐文件 sha256 校验通过;两张根卡已在生产退役(hidden=1,dry-run 幂等、复推不复活)。测试:mcp 套件 185 项(仅 3 项改动前既有的环境失败:zstandard 未装 ×2、SyncLockAndRoleTest ×1)。
v2026.09.12.3
v2026.09.12.3 — dsh 会话世代选择与写入目标修正
客户端发布:
CLIENT_VERSION2026.09.12.2 → 2026.09.12.3(客户端包有改动,各端经/api/client/manifest自动更新)。服务端无 schema 变更。
Fixed(dsh 世代选择方向反了 —— 迁移过的会话读到冻结的旧日志)
- 根因:dsh 的会话日志有世代——
session.jsonl[.zstd]= v0,session.vN.jsonl[.zstd]= vN(当前 v3)。dsh-session-persistence-jsonl明确规定:运行期读与写都取数值最高的世代;写入发布后继代并保持源文件逐字节不变(迁移过的 v0 从此冻结,只有 v3 继续增长),且保留的前代不提供降级读取。 - 旧
_log_file先试session.jsonl/session.jsonl.zstd、找不到才回退 vN —— 方向正好相反:对迁移过的会话会永久读到冻结的 v0(丢失后续轮次与标题),写入则落进 v0 —— 一个 dsh 永不读取的文件(写入实际无效,还在存储里留下误导性副本)。 - 修复:
_log_file镜像 dsh 的规则(按(世代, 压缩优先, mtime)取最高世代);_write_log保持会话既有的世代与编码(_log_filename(gen, zstd)),不再在 vN 会话旁写 v0;并原样保留既有 header(isSeeded/agentPreset等世代专有字段据此存活),物理 header 的version恒等于文件名世代;对合成的 vN header 兜底补isSeeded(dsh 的 v2→v3 header 校验要求该字段)。读取侧无需改动:_event_message本就同时支持 v3 行形态(data.{role,content}与data.message.{role,content})。 - 验证用 dsh 自己的编解码器(非自证):在真实 v3 会话上写入一次后,
@deepseek-ai/dsh-session-format的readHeader返回current(stored 3 → target 3),encodeCurrentEvent逐行接受全部 9 个事件;写入后目录中只有 v3 文件被改写(冻结的 v0 逐字节不变)。命名侧与 dsh 的parseSessionFormatLogFilename逐例一致(去掉.zstd后 0/1/3/12 全等,垃圾名同样拒绝)。 - 测试:
mcp/tests/test_dsh.py::LogGenerationTest6 例;mcp 套件 146 项、server 套件 189 项通过。