dsh-openwrite 0.2.12 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,14 +29,14 @@
29
29
 
30
30
  ## 快速安装
31
31
 
32
- **0.2.12 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2**。0.2.0-rc.2 已完成 Web 客户端启动验证,Windows Electron 桌面端恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。默认使用 npm `latest` 渠道;商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
32
+ **0.2.13 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2**。0.2.0-rc.2 已完成 Web 工作台渲染与 DoG 数据读取验证,Windows Electron 桌面端启动/恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。默认使用 npm `latest` 渠道;商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
33
33
 
34
34
  npm 包和对应 GitHub Release 使用同一份验收产物。也可安装精确版本的 Release 包:
35
35
 
36
- **0.2.12 正在进行发布验收,npm `latest` 当前仍为 0.2.11。** 0.2.11 不包含 #50–#54 的进入工作台和连续审读修复;请以已发布 Release 的版本和附件为准。
36
+ **0.2.13 的 GitHub 安装包已发布,npm `latest` 暂时仍为 0.2.12,等待本次发布的 2FA 验证后同步发布。** 0.2.12 不包含 #57 的创作页刷新循环修复;请使用下方已发布的 0.2.13 Release 包。
37
37
 
38
38
  ```sh
39
- dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.11/dsh-openwrite-0.2.11.tgz
39
+ dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.13/dsh-openwrite-0.2.13.tgz
40
40
  dsh web
41
41
  ```
42
42
 
@@ -66,11 +66,17 @@ dsh web
66
66
 
67
67
  ### dsh 0.2 桌面端升级
68
68
 
69
+ `0.2.15` 候选包修复 [#63](https://github.com/LiPu-jpg/Openwrite/issues/63)/[#66](https://github.com/LiPu-jpg/Openwrite/issues/66)(Windows 残留 `project.lock` 永久阻断写作,报错 `[WinError 87]`;Core 5.8.6 自动清理死进程残留锁并带上下文报错)、[#64](https://github.com/LiPu-jpg/Openwrite/issues/64)/[#67](https://github.com/LiPu-jpg/Openwrite/issues/67)(`continuous_write` 单章失败即整队终止、`max_failures` 失效、字数超限丢弃初稿;现在按容错上限续跑并保留超长稿)、[#68](https://github.com/LiPu-jpg/Openwrite/issues/68)(横评进行中详情接口恒 400)和 [#69](https://github.com/LiPu-jpg/Openwrite/issues/69)(推理模型评审输出预算被锁死 4096 导致评审全灭)。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
70
+
71
+ `0.2.14` 候选包修复 [#60](https://github.com/LiPu-jpg/Openwrite/issues/60) 和 [#61](https://github.com/LiPu-jpg/Openwrite/issues/61):系统代理下 `NO_PROXY` 含 `[::1]` 不再让所有模型连接测试误报"服务商拒绝了请求"(桥接层消毒 + Core 5.8.5 归类为本地配置错误);Embedding 连接测试的依赖预热、硬超时与收尾兜底,加上桥接层的健康探活与 stderr 落盘,不再出现"后端活着但不服务、只能重启 dsh"的僵尸态。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
72
+
73
+ `0.2.13` 修复 [#57](https://github.com/LiPu-jpg/Openwrite/issues/57) 的创作页刷新循环:批注与版本读取不再作为写操作通知刷新,Core 的只读请求也不再增加工作区变更序号。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
74
+
69
75
  `0.2.12` 进一步修复 [#50](https://github.com/LiPu-jpg/Openwrite/issues/50)、[#51](https://github.com/LiPu-jpg/Openwrite/issues/51)、[#52](https://github.com/LiPu-jpg/Openwrite/issues/52) 的预设注册、导航、工作台挂载和 DoG 接口。连续审读按内容修订刷新,避免轮询闪烁([#54](https://github.com/LiPu-jpg/Openwrite/issues/54))。
70
76
 
71
77
  `0.2.11` 修复 [#45](https://github.com/LiPu-jpg/Openwrite/issues/45),适配精确宿主版本 `0.2.0-rc.2`:工作台兼容新的 turn-tail list 槽位,DoG 调试面板使用新的 `sessionStatus`;根包和所有子包的兼容声明同步更新。已验证 0.2.0-rc.2 的 Web 客户端启动、OpenWrite 入口、DoG 面板及其他插件共存;Windows Electron 桌面端恢复链路尚未实机复测。
72
78
 
73
- **`dsh-openwrite@0.2.10` 不包含这项修复,请升级到 `0.2.11`。** 不要通过给旧子包授权来代替升级:`dsh-openwrite` 的版本豁免不会覆盖 `@dsh-novel/studio-panel@<ver>` 和 `@dsh-external/dsh-dog@<ver>`,而旧客户端在 0.2.0-rc.2 上激活失败可能触发桌面端的 profile 恢复。
79
+ **`dsh-openwrite@0.2.10` 不包含这项修复,请升级到 `0.2.13` 的 GitHub Release 包。** 不要通过给旧子包授权来代替升级:`dsh-openwrite` 的版本豁免不会覆盖 `@dsh-novel/studio-panel@<ver>` 和 `@dsh-external/dsh-dog@<ver>`,而旧客户端在 0.2.0-rc.2 上激活失败可能触发桌面端的 profile 恢复。
74
80
 
75
81
  若已发生恢复,先关闭桌面端并备份当前 profile,再从自己的 `cordis.patch.yml.bak-*` 备份恢复 patch,同时恢复原 `package.json` 的 `dsh.profile.bundles` 列表。安装包和作品目录通常仍在;不要用出厂模板覆盖其他插件配置。详细排查见 [安装指南](docs/INSTALL.md#dsh-02-客户端兼容排查)。
76
82
 
@@ -89,6 +95,8 @@ dsh web
89
95
 
90
96
  已有会话不会自动切换预设。详细操作与工作区规则见 [使用流程](docs/WORKFLOWS.md)。
91
97
 
98
+ **界面语言**:面板已内置中文与英文词典,跟随 dsh 语言设置。切换方法:Settings → General → Language → English(选择后存为 `locale.preference`)。注意 Agent 的 persona、技能说明与部分后端状态/报错信息仍以中文为主;Agent 会用你在对话中使用的语言回复。
99
+
92
100
  ## 配置模型
93
101
 
94
102
  安装和环境健康检查不需要模型 API Key,也不会调用写作模型。与 Agent 对话、生成章节和审稿需要可用的模型配置;使用收费服务商时会产生 API 费用。
@@ -0,0 +1,45 @@
1
+ # 引擎源码唯一真身(native-core)
2
+
3
+ ## 规则
4
+
5
+ 1. `native-core` 分支是 OpenWrite 引擎(Python Core:`craft/`、`contracts/`、`models/`、`tools/`、`integrations/`)的**唯一源码真身**。`main` 不携带引擎源码,只通过 `release/openwrite-*.whl` 制品依赖引擎。
6
+ 2. 引擎的任何修改必须先作为提交落在 `native-core` 并推送到远端,然后才能从该提交构建 wheel 进入 `release/`。**禁止从带未提交改动的工作树构建 wheel;禁止"只改 wheel、不回补源码"。**
7
+ 3. `release/runtime-manifest.json` 的 `sources.core.commit` 必须满足:指向 `native-core` 分支上的提交、已推送远端、且该提交的工作树与 wheel 内容完全一致(用 `git status` 确认干净,`git diff <commit> 工作树` 为空)。
8
+ 4. 当前两条消费线都以 `native-core` 为真身,形态不同:
9
+ - **dsh 插件线(本分支)**:制品依赖,`openwrite-bridge` 的 managed runtime 拉起 `release/` 里的 wheel。
10
+ - **JiuwenSwarm 迁移线(`jiuwenswarm-swarm` 分支)**:源码级依赖,`openwrite-mcp` 通过 `OPENWRITE_CORE` 直接挂载 native-core 工作树,引擎升级即时生效、无需 repack。
11
+
12
+ ## 构建 / 升级 wheel 流程
13
+
14
+ 1. 切到 native-core 的干净工作树:`git worktree add .wt-native-core native-core`。
15
+ 2. 应用引擎改动、提交、推送;**先在 native-core 上跑通引擎测试**(至少 `tests/test_managed_runtime.py`、`tests/test_embedding_runtime.py`、`tests/test_model_connection_test.py`)。
16
+ 3. 构建 wheel(纯 Python 包):`python -m pip wheel --no-deps -w dist .`,更新 `release/openwrite-<ver>-py3-none-any.whl`。
17
+ 4. 同步更新 `release/runtime-manifest.json`:`core_version`、`wheel.sha256`、`sources.core.commit`(= 本次构建的 native-core 提交)。
18
+ 5. 若依赖变化,从 wheel METADATA 重导 `release/core-requirements.in` 并用固定 uv 重建 `release/requirements.lock`。
19
+ 6. 提交 PR 走完整验收(见 [PLUGIN_MAINTENANCE](PLUGIN_MAINTENANCE.md) / [RELEASE_ACCEPTANCE](RELEASE_ACCEPTANCE.md))。
20
+
21
+ ## 事故记录:5.8.5 源码漂移(2026-10-09 修复)
22
+
23
+ **现象**:`release/openwrite-5.8.5-py3-none-any.whl` 内含 embedding 依赖预载、探测硬超时、有界收尾、httpx 构造期错误分类等源码改动(修复 #60/#61),但 `native-core` 分支上没有任何对应提交——wheel 是从带未提交改动的工作树打的,`runtime-manifest.json` 记录的 `sources.core.commit = 3a5da97` 无法复现 wheel 内容。
24
+
25
+ **后果**:
26
+
27
+ 1. 引擎源码线停留在 5.8.4,与制品线漂移;依赖源码的 JiuwenSwarm 迁移线(`OPENWRITE_CORE`)吃不到 #60/#61 修复。
28
+ 2. wheel 中 `_local_client_construction_error` 直接引用 `httpx.InvalidProxy`,而 `requirements.lock` 锁定的 `httpx==0.28.1` 并无该符号——`AttributeError` 会击穿整条连接错误分类路径。
29
+
30
+ **处理**(均已推送到 `native-core`):
31
+
32
+ | 提交 | 内容 |
33
+ |---|---|
34
+ | `e34ccdd` | 从 wheel 反解 5.8.4→5.8.5 源码差异并回流(4 文件) |
35
+ | `7a08855` | `httpx.InvalidProxy` 逐个 `getattr` 容错,兼容 0.28.1 及更早版本 |
36
+ | `d8e741c` | managed-runtime 健康检查断言改用 `tools.version.__version__`,不再硬编码 5.8.1 |
37
+
38
+ 回归:引擎三件套 33/33,蜂群桩件 18/18,MCP 写路径 6/6 全绿。
39
+
40
+ **教训**:wheel 是源码的投影,不是源码的替代品。manifest 里的 commit 必须能被 `git checkout <commit>` 后重新打出同一个 wheel。
41
+
42
+ ## 当前状态
43
+
44
+ - `native-core` HEAD:`d8e741c`(Core 5.8.5 + httpx 容错 + 测试修复)。
45
+ - `release/` 中的 wheel 仍为修复前版本;下次 repack 必须基于 `native-core` 上 ≥ `d8e741c` 的提交,并同步更新 manifest。
@@ -6,13 +6,45 @@
6
6
 
7
7
  新建作品的验收请求最多等待 60 秒(原为 15 秒),保留成功耗时和失败诊断;不重试写请求,超时仍失败。此调整容纳慢速运行器,不代表初始化耗时问题已被修复。
8
8
 
9
- ## 0.2.12(待发布)
9
+ ## 0.2.15
10
+
11
+ 修复 #63/#66 Windows 残留 `project.lock` 永久阻断写章/审稿:Core 5.8.6 用 Windows 正确的进程存活探测(`OpenProcess`/`GetExitCodeProcess`)替换 POSIX 语义的 `os.kill(pid, 0)`——后者在 Windows 上被解释为发送 `CTRL_C_EVENT`(值为 0),对死进程抛 `OSError [WinError 87]` 且不被现有捕获分支覆盖,导致残留锁 100% 无法自愈。死进程持有的锁现在自动判陈旧并清理;存活探测本身失败时按"初始化宽限后视为陈旧"兜底,不再卡死写作。锁占用报错携带 operation、pid、起始时间与锁文件路径;写章/审稿不再预置 `model` 阶段标签,文件锁失败不会被误报为"模型生成失败"(阶段由流水线在真正生成时上报)。
12
+
13
+ 修复 #64/#67 `continuous_write` 单章失败即整队终止:`max_failures` 此前只在值为 1 时生效(首次失败 `1 < 2` 直接 `raise`),现在未达上限记录该章失败并继续下一章,达到上限优雅返回 `max_failures_reached`,结果附带 `failed_chapters`;`TaskCancelled` 与取消检查仍会立即中止队列。字数精简重试耗尽后不再丢弃初稿:最后一版正文标记 `length_out_of_range`/`length_warning` 保留为草稿并产生一条字数告警 issue。
14
+
15
+ 修复 #68 横评进行中详情接口恒 400:产物契约 `model_benchmark_v1` 的 `status` 枚举补齐 `running`/`cancelling`/`cancelled`,实时快照与取消终态均可通过校验。
16
+
17
+ 修复 #69 推理模型评审输出预算锁死 4096:观察到 usage 含 reasoning tokens 后评审与安全门的输出预算地板抬到 16384(仍受模型配置与上下文窗上限约束);截断后先按翻倍预算原批重试一次,仍失败才二分,且二分时不再沿用旧地板。思维链按调用计费,旧逻辑下拆分救不回来。
18
+
19
+ #65 部分落地:README 记录界面语言切换(Settings → General → Language),persona 增加语言跟随约定。英文 persona/skills 全集与后端信息英文化留待后续。
20
+
21
+ ## 0.2.14
22
+
23
+ [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.14) 已发布,直接使用[完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37679578150)验收的同一份包。来源提交 `f62f268bd2c74badc8af56ad4896164c5403def2`,SHA-256 为 `b61b2d98bac0235cb23361b2aa593e287bb47a2db24669c9893ef8e27e173549`。六组原生平台/Node、源码安装、alpha 兼容及三代宿主客户端共 11 份报告全部通过;Release 四份附件的校验值已与 CI `validated-release` 产物逐一核对。合并来源 [PR #62](https://github.com/LiPu-jpg/Openwrite/pull/62),#60、#61 随之关闭。npm `dsh-openwrite@0.2.14` 已发布,`latest` 已更新到 0.2.14;从公共 npm registry 无认证重新下载后,SHA-256 与上述 CI / Release 包一致。
24
+
25
+ 修复 #60 系统代理场景下 `NO_PROXY` 含 `[::1]` 导致所有模型连接测试误报"服务商拒绝了请求":桥接层 `buildChildEnv()` 转发前剥掉带方括号的 IPv6 字面量,Core 5.8.5 把 httpx 客户端构造期异常归类为本地配置错误(HTTP 412),`Studio request failed` 附带 `error.cause`。
26
+
27
+ 修复 #61 Embedding 连接测试在请求线程里懒加载 numpy 把后端变成"活着但不服务"的僵尸:Core 5.8.5 启动主线程预热 numpy、后台线程预热 fastembed,`run_embedding_probe()` 硬超时 `timeout_seconds + 30 s`,收尾限时 10 秒超时强制退出;桥接层复用就绪连接前做带超时的 `/api/health` 探活,探活失败的僵尸进程强制终止并自动恢复,子进程 stderr 落盘 `state/logs/backend.log`(末尾 64 KiB)。
28
+
29
+ 新增回归覆盖 no_proxy 消毒矩阵、僵尸连接探活恢复、stderr 落盘、错误分类矩阵与探测硬超时;wheel 重打包后全量 RECORD 哈希校验(498 文件)与 manifest 一致性检查通过。CI 另修复 apt 镜像加固(pin 到 https archive.ubuntu.com + 连接超时),消除 ubuntu 运行器镜像故障导致的矩阵腿卡死。Windows 上的依赖预热与僵尸恢复链路本机未复测,保留 0.2.13 的同一限制;DSH 核心 `dsh-http-proxy` 向 `NO_PROXY` 注入 `[::1]` 的治本修复仍需向 DeepSeek Harness Desktop 团队转达。
30
+
31
+ ## 0.2.13
32
+
33
+ 修复 #57 的只读 POST 失效循环。Core 5.8.4 在 HTTP action 契约声明是否改变公开状态,通过 `X-OpenWrite-Mutated` 统一驱动浏览器代理与 Agent 客户端;旧 Core 的正文版本和批注查询保留兼容处理。保存、批注写入及恢复版本按 manuscript 资源通知,批注读取不再监听无关的 workspace 失效。
34
+
35
+ 新增回归覆盖连续只读请求不增加 invalidation revision、SSE 和 Core context_epoch;真实写入仍增加序号。创作页验证重复 workspace 事件不会反复请求批注,实际编辑会刷新,切换作品清空旧批注并忽略迟到响应。[完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37483398522) 全部通过,六组原生平台/Node、源码安装、alpha 兼容及三代宿主客户端共 11 份报告均绑定同一产物。来源提交 `72d19c1a1494da13b5b89d652746e13f13e90e1a`,SHA-256 为 `091dcb7201a88240d8282ef31526d7d88f6d63a390d0813a39f580f9be990a58`。[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.13) 已发布并核对四份附件的校验值;npm 本次发布认证已过期(404),等待重新验证,`latest` 暂时仍为 0.2.12。
36
+
37
+ #50、#57 已随 [PR #59](https://github.com/LiPu-jpg/Openwrite/pull/59) 合并关闭。#50 报告的预设注册、主视图挂载、会话导航及托管模型配置问题已由 0.2.12 修复,并经三代精确 Web 宿主和原生 Windows 安装检查验证。Windows Electron 的实机启动/恢复尚未复测,该限制继续保留;本版不将其计为已通过的验收。
38
+
39
+ ## 0.2.12
40
+
41
+ [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.12) 已发布,直接使用[完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37389772004)验收的同一份包。来源提交 a349ea964e43d75f9fc98d1f83354a237bc2ff51,SHA-256 为 2545031292d80689f80d4c0d8c99e007c18ff0c477d662e2813cf67bc876246c。六组原生平台/Node、源码安装、alpha 兼容及三代宿主客户端共 11 份报告全部通过;详见 Release 的 release-acceptance.json。npm `dsh-openwrite@0.2.12` 和 GitHub Release 均已发布,`latest` 已更新到 0.2.12;从公共 npm registry 无认证重新下载后,SHA-256 与上述 CI / Release 包一致。合并后的[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/37390503895)也已通过。
10
42
 
11
43
  本版修复 #50–#52 的 0.2 宿主兼容问题及 #54 阅读模式刷新闪烁,纳入 #53 的导航和会话作用域改动,并保留旧宿主的预设插件。
12
44
 
13
45
  - Core 5.8.3:模型连接测试使用所选配置的输出预算;108 项 Core 回归通过。
14
46
  - 本地 297 项组件测试、50 项 DoG 测试通过。三代精确宿主的浏览器流程纳入正式发布门禁:选择目录、预设选择、会话导航、工作台标签、点击创作后实际渲染、DoG 面板与插件共存。该客户端检查使用后端就绪及未初始化响应替身;原生后端安装和作品初始化由独立平台任务验证。
15
- - 完整 CI 及唯一产物校验通过后才发布。Windows Electron 桌面端尚未实机复测,真实模型验证未运行。
47
+ - 完整 CI 和唯一产物校验已通过;合并提交 60949df412f10be004fe6bb5b4fb992dd65c3c60 的 tree 与 CI 来源一致。Windows Electron 桌面端尚未实机复测,真实模型验证未运行。
16
48
 
17
49
  ## 0.2.11
18
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-openwrite",
3
- "version": "0.2.12",
3
+ "version": "0.2.15",
4
4
  "description": "OpenWrite 长篇小说领域后端接入 DeepSeek Harness:统一创作 Agent 预设 + 原生创作工作台",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -60,6 +60,8 @@ export interface StudioDownload {
60
60
  }
61
61
  /** Query parameters; array values become repeated `key=v1&key=v2` entries. */
62
62
  export type QueryParams = Record<string, string | string[]>;
63
+ /** Legacy Core fallback; newer servers declare mutation semantics in a response header. */
64
+ export declare function mutationAffectsState(path: string, body: unknown, headers?: Headers): boolean;
63
65
  export declare class StudioClient {
64
66
  private readonly connect;
65
67
  private readonly baseUrl;
@@ -55,6 +55,30 @@ function applyParams(url, params) {
55
55
  }
56
56
  }
57
57
  }
58
+ /** Legacy Core fallback; newer servers declare mutation semantics in a response header. */
59
+ export function mutationAffectsState(path, body, headers) {
60
+ const declared = headers?.get('X-OpenWrite-Mutated');
61
+ if (declared === '0' || declared === '1')
62
+ return declared === '1';
63
+ const endpoint = path.split('?')[0]?.replace(/^\/+/, '').replace(/^api\//, '');
64
+ if (endpoint !== 'manuscript-editing')
65
+ return true;
66
+ let payload = body;
67
+ if (Buffer.isBuffer(payload))
68
+ payload = payload.toString('utf8');
69
+ if (typeof payload === 'string') {
70
+ try {
71
+ payload = JSON.parse(payload);
72
+ }
73
+ catch {
74
+ return true;
75
+ }
76
+ }
77
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
78
+ return true;
79
+ const action = payload['action'];
80
+ return typeof action !== 'string' || !['versions', 'version', 'compare', 'annotations'].includes(action);
81
+ }
58
82
  export class StudioClient {
59
83
  connect;
60
84
  baseUrl;
@@ -112,7 +136,8 @@ export class StudioClient {
112
136
  body: JSON.stringify(body),
113
137
  }, signal);
114
138
  const result = await this.readJson(response);
115
- this.onMutation?.(path, this.context);
139
+ if (mutationAffectsState(path, body, response.headers))
140
+ this.onMutation?.(path, this.context);
116
141
  return result;
117
142
  }
118
143
  /** PUT a JSON object with the Studio write protocol header. */
@@ -123,7 +148,8 @@ export class StudioClient {
123
148
  body: JSON.stringify(body),
124
149
  }, signal);
125
150
  const result = await this.readJson(response);
126
- this.onMutation?.(path, this.context);
151
+ if (mutationAffectsState(path, body, response.headers))
152
+ this.onMutation?.(path, this.context);
127
153
  return result;
128
154
  }
129
155
  /** GET a file-download endpoint (export), returning the raw bytes and filename. */
@@ -156,7 +182,9 @@ export class StudioClient {
156
182
  catch (error) {
157
183
  if (signal?.aborted)
158
184
  throw error;
159
- throw new StudioError(`Studio request failed: ${error instanceof Error ? error.message : String(error)}`, 0, 'NETWORK_ERROR');
185
+ const cause = error instanceof Error ? error.cause : undefined;
186
+ const detail = cause instanceof Error ? ` (${cause.message})` : cause === undefined ? '' : ` (${String(cause)})`;
187
+ throw new StudioError(`Studio request failed: ${error instanceof Error ? error.message : String(error)}${detail}`, 0, 'NETWORK_ERROR');
160
188
  }
161
189
  }
162
190
  /**
@@ -157,7 +157,7 @@ export interface ModelBenchmarkV1Summary {
157
157
  export interface ModelBenchmarkV1 {
158
158
  schema_version: "openwrite.model-benchmark.v1";
159
159
  run_id: string;
160
- status: "completed" | "partial" | "failed";
160
+ status: "completed" | "partial" | "failed" | "running" | "cancelling" | "cancelled";
161
161
  context_hash: string;
162
162
  task_id?: string;
163
163
  started_at?: string;
@@ -605,7 +605,10 @@ const SCHEMAS = {
605
605
  "enum": [
606
606
  "completed",
607
607
  "partial",
608
- "failed"
608
+ "failed",
609
+ "running",
610
+ "cancelling",
611
+ "cancelled"
609
612
  ]
610
613
  },
611
614
  "summary": {
@@ -1,5 +1,5 @@
1
1
  import { Service } from '@deepseek-ai/cordis';
2
- import { StudioClient, workspaceRootHeaders } from './client.js';
2
+ import { StudioClient, workspaceRootHeaders, mutationAffectsState } from './client.js';
3
3
  import { workspaceContextFromExec } from './tools.js';
4
4
  export const CONFIG_ROUTE = '/studio-panel/config.json';
5
5
  export const API_PROXY_ROUTE = '/studio-panel/api';
@@ -98,6 +98,8 @@ function resourceForPath(path) {
98
98
  return 'models';
99
99
  if (path.startsWith('/api/research'))
100
100
  return 'research';
101
+ if (path.startsWith('/api/manuscript-editing'))
102
+ return 'manuscript';
101
103
  if (path.startsWith('/api/revisions'))
102
104
  return 'revisions';
103
105
  return 'workspace';
@@ -481,7 +483,7 @@ export class NovelDomainService extends Service {
481
483
  responseHeaders['content-disposition'] = disposition;
482
484
  res.writeHead(upstream.status, responseHeaders);
483
485
  res.end(bytes);
484
- if (isWrite && upstream.ok) {
486
+ if (isWrite && upstream.ok && mutationAffectsState(pathPart, body, upstream.headers)) {
485
487
  this.notifyMutation(`/api/${pathPart}`, { workspaceRoot: resolved.root, workspaceId: resolved.id });
486
488
  }
487
489
  }
@@ -52,6 +52,14 @@ export declare const DEFAULT_BACKOFF_MS: number[];
52
52
  export declare function sha256(file: string): Promise<string>;
53
53
  /** Diagnostics must not echo provider credentials or authenticated download URLs. */
54
54
  export declare function sanitizeDiagnostic(message: string): string;
55
+ /**
56
+ * Drop bracketed IPv6 literals (e.g. `[::1]`) from a no_proxy value. DSH's
57
+ * loopback bypass list is written for undici as well as generic consumers, but
58
+ * httpx parses each entry as host:port and fails at client construction with
59
+ * `Invalid port: ':1]'`, so the request never leaves the process. Plain
60
+ * entries such as `::1` stay — httpx handles them fine.
61
+ */
62
+ export declare function sanitizeNoProxy(value: string): string;
55
63
  /** Windows needs SystemRoot/PATH to start; proxy and CA vars stay when set. Credential names never copy. */
56
64
  export declare function buildChildEnv(source: NodeJS.ProcessEnv, extra?: Record<string, string | undefined>): NodeJS.ProcessEnv;
57
65
  /** Stop only a child we spawned. POSIX groups and Windows trees include grandchildren. */
@@ -78,6 +86,16 @@ export declare class ManagedRuntime {
78
86
  status(): RuntimeStatus;
79
87
  private update;
80
88
  ensure(): Promise<BackendConnection>;
89
+ /**
90
+ * The child process can outlive its listening socket (a stuck request thread
91
+ * during shutdown leaves it alive but refusing connections), and no exit is
92
+ * ever observed in that state, so the auto-restart logic never fires. Probe
93
+ * the cached connection before handing it out; a dead backend is torn down
94
+ * forcefully and pushed through the normal recovery path.
95
+ */
96
+ private verifyConnection;
97
+ /** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
98
+ private probeHealth;
81
99
  cancel(): Promise<void>;
82
100
  dispose(): Promise<void>;
83
101
  private exitDiagnostic;
@@ -10,6 +10,47 @@ import { unzipSync } from 'fflate';
10
10
  import { lock as acquireLock } from 'proper-lockfile';
11
11
  export const DEFAULT_RESTART_LIMIT = 5;
12
12
  export const DEFAULT_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
13
+ /** Budget for the per-request liveness probe of a previously-ready backend. */
14
+ const HEALTH_PROBE_TIMEOUT_MS = 1_500;
15
+ /** The backend stderr log on disk keeps only the newest chunk. */
16
+ const STDERR_LOG_LIMIT = 64 * 1024;
17
+ /**
18
+ * Keep the managed backend's stderr on disk (last 64 KiB) so a hung or zombie
19
+ * process can be diagnosed after the fact; the in-memory tail alone is lost
20
+ * with the host. Writes are debounced and serialized, and never throw —
21
+ * diagnostics must not break the backend lifecycle.
22
+ */
23
+ class BackendStderrLog {
24
+ path;
25
+ tail = '';
26
+ timer;
27
+ pending = Promise.resolve();
28
+ constructor(path) {
29
+ this.path = path;
30
+ }
31
+ write(text) {
32
+ this.tail = (this.tail + text).slice(-STDERR_LOG_LIMIT);
33
+ if (this.timer !== undefined)
34
+ return;
35
+ this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, 200);
36
+ }
37
+ tailText() { return this.tail; }
38
+ flush() {
39
+ if (this.timer !== undefined) {
40
+ clearTimeout(this.timer);
41
+ this.timer = undefined;
42
+ }
43
+ const tail = this.tail;
44
+ this.pending = this.pending.then(async () => {
45
+ try {
46
+ await mkdir(dirname(this.path), { recursive: true });
47
+ await writeFile(this.path, tail);
48
+ }
49
+ catch { /* best-effort diagnostics */ }
50
+ });
51
+ return this.pending;
52
+ }
53
+ }
13
54
  const CREDENTIAL_ENV = /(?:API[_-]?KEY|ACCESS[_-]?TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTHORIZATION)/i;
14
55
  const ALLOWED_ENV = new Set([
15
56
  'PATH', 'Path', 'PATHEXT',
@@ -36,6 +77,17 @@ export function sanitizeDiagnostic(message) {
36
77
  .replace(/((?:api[_-]?key|authorization|password|secret|token)\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]')
37
78
  .replace(/\b[a-f0-9]{32,}\b/gi, '[redacted]');
38
79
  }
80
+ /**
81
+ * Drop bracketed IPv6 literals (e.g. `[::1]`) from a no_proxy value. DSH's
82
+ * loopback bypass list is written for undici as well as generic consumers, but
83
+ * httpx parses each entry as host:port and fails at client construction with
84
+ * `Invalid port: ':1]'`, so the request never leaves the process. Plain
85
+ * entries such as `::1` stay — httpx handles them fine.
86
+ */
87
+ export function sanitizeNoProxy(value) {
88
+ return value.split(',').map(entry => entry.trim())
89
+ .filter(entry => entry !== '' && !/^\[.*\]$/.test(entry)).join(',');
90
+ }
39
91
  /** Windows needs SystemRoot/PATH to start; proxy and CA vars stay when set. Credential names never copy. */
40
92
  export function buildChildEnv(source, extra = {}) {
41
93
  const env = {};
@@ -52,6 +104,12 @@ export function buildChildEnv(source, extra = {}) {
52
104
  continue;
53
105
  env[key] = value;
54
106
  }
107
+ // The Python backend reads NO_PROXY through httpx; bracketed IPv6 literals
108
+ // break client construction there even though Node's undici accepts them.
109
+ for (const key of Object.keys(env)) {
110
+ if (key.toLowerCase() === 'no_proxy' && typeof env[key] === 'string')
111
+ env[key] = sanitizeNoProxy(env[key]);
112
+ }
55
113
  return env;
56
114
  }
57
115
  /** Stop only a child we spawned. POSIX groups and Windows trees include grandchildren. */
@@ -119,7 +177,7 @@ export class ManagedRuntime {
119
177
  if (this.closed)
120
178
  return Promise.reject(new Error('OpenWrite 已卸载'));
121
179
  if (this.connection)
122
- return Promise.resolve(this.connection);
180
+ return this.verifyConnection(this.connection);
123
181
  if (this.pending)
124
182
  return this.pending;
125
183
  this.allowRevive = true;
@@ -137,6 +195,52 @@ export class ManagedRuntime {
137
195
  }).finally(() => { this.pending = undefined; this.controller = undefined; });
138
196
  return this.pending;
139
197
  }
198
+ /**
199
+ * The child process can outlive its listening socket (a stuck request thread
200
+ * during shutdown leaves it alive but refusing connections), and no exit is
201
+ * ever observed in that state, so the auto-restart logic never fires. Probe
202
+ * the cached connection before handing it out; a dead backend is torn down
203
+ * forcefully and pushed through the normal recovery path.
204
+ */
205
+ async verifyConnection(connection) {
206
+ if (await this.probeHealth(connection)) {
207
+ if (this.closed)
208
+ throw new Error('OpenWrite 已卸载');
209
+ const current = this.connection;
210
+ if (current)
211
+ return current;
212
+ return this.queueRestart();
213
+ }
214
+ if (this.connection !== connection)
215
+ return this.queueRestart();
216
+ this.connection = undefined;
217
+ const child = this.child;
218
+ this.child = undefined;
219
+ if (child)
220
+ await stopOwnedProcess(child).catch(() => { });
221
+ if (this.closed)
222
+ throw new Error('OpenWrite 已卸载');
223
+ if (!this.allowRevive)
224
+ throw new Error('准备已取消,可重试');
225
+ this.update('recovering', '写作后端无响应,正在恢复');
226
+ return this.queueRestart();
227
+ }
228
+ /** Short-timeout liveness probe; false covers exit, refusal, hang, and auth drift. */
229
+ async probeHealth(connection) {
230
+ const child = this.child;
231
+ if (!child || child.exitCode !== null || child.signalCode !== null)
232
+ return false;
233
+ try {
234
+ const response = await fetch(connection.baseUrl + '/api/health', {
235
+ headers: { Authorization: `Bearer ${connection.token}` },
236
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS),
237
+ });
238
+ return response.ok;
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
140
244
  async cancel() {
141
245
  this.allowRevive = false;
142
246
  this.controller?.abort();
@@ -434,8 +538,16 @@ export class ManagedRuntime {
434
538
  env: buildChildEnv(process.env, { PYTHONNOUSERSITE: '1', PYTHONUTF8: '1' }),
435
539
  });
436
540
  this.child = child;
541
+ // Mirror stderr to state/logs/backend.log (last 64 KiB): a stuck backend
542
+ // prints nothing and its in-memory tail dies with the host, leaving no way
543
+ // to diagnose "alive but not serving" failures after the fact.
544
+ const stderrLog = new BackendStderrLog(join(this.root, 'state', 'logs', 'backend.log'));
437
545
  let stderrTail = '';
438
- child.stderr?.on('data', bytes => { stderrTail = (stderrTail + String(bytes)).slice(-500); });
546
+ child.stderr?.on('data', bytes => {
547
+ stderrLog.write(String(bytes));
548
+ stderrTail = stderrLog.tailText().slice(-500);
549
+ });
550
+ child.once('exit', () => { void stderrLog.flush(); });
439
551
  const lines = createInterface({ input: child.stdout });
440
552
  let timer;
441
553
  let started = false;