dsh-openwrite 0.2.12 → 0.2.14

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,15 @@ dsh web
66
66
 
67
67
  ### dsh 0.2 桌面端升级
68
68
 
69
+ `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,使桥接服务端加载新代码,再刷新浏览器页面。
70
+
71
+ `0.2.13` 修复 [#57](https://github.com/LiPu-jpg/Openwrite/issues/57) 的创作页刷新循环:批注与版本读取不再作为写操作通知刷新,Core 的只读请求也不再增加工作区变更序号。正式可下载版本以 npm `latest` 和 GitHub Release 为准。升级后需停止并重新启动 dsh,使桥接服务端加载新代码,再刷新浏览器页面。
72
+
69
73
  `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
74
 
71
75
  `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
76
 
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 恢复。
77
+ **`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
78
 
75
79
  若已发生恢复,先关闭桌面端并备份当前 profile,再从自己的 `cordis.patch.yml.bak-*` 备份恢复 patch,同时恢复原 `package.json` 的 `dsh.profile.bundles` 列表。安装包和作品目录通常仍在;不要用出厂模板覆盖其他插件配置。详细排查见 [安装指南](docs/INSTALL.md#dsh-02-客户端兼容排查)。
76
80
 
@@ -6,13 +6,31 @@
6
6
 
7
7
  新建作品的验收请求最多等待 60 秒(原为 15 秒),保留成功耗时和失败诊断;不重试写请求,超时仍失败。此调整容纳慢速运行器,不代表初始化耗时问题已被修复。
8
8
 
9
- ## 0.2.12(待发布)
9
+ ## 0.2.14 候选包
10
+
11
+ 修复 #60 系统代理场景下 `NO_PROXY` 含 `[::1]` 导致所有模型连接测试误报"服务商拒绝了请求":桥接层 `buildChildEnv()` 转发前剥掉带方括号的 IPv6 字面量,Core 5.8.5 把 httpx 客户端构造期异常归类为本地配置错误(HTTP 412),`Studio request failed` 附带 `error.cause`。
12
+
13
+ 修复 #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)。
14
+
15
+ 新增回归覆盖 no_proxy 消毒矩阵、僵尸连接探活恢复、stderr 落盘、错误分类矩阵与探测硬超时;wheel 重打包后全量 RECORD 哈希校验(498 文件)与 manifest 一致性检查通过。完整发布验收正在运行,正式发布将使用通过门禁的同一份 CI 产物。Windows 上的依赖预热与僵尸恢复链路本机未复测,保留 0.2.13 的同一限制。
16
+
17
+ ## 0.2.13
18
+
19
+ 修复 #57 的只读 POST 失效循环。Core 5.8.4 在 HTTP action 契约声明是否改变公开状态,通过 `X-OpenWrite-Mutated` 统一驱动浏览器代理与 Agent 客户端;旧 Core 的正文版本和批注查询保留兼容处理。保存、批注写入及恢复版本按 manuscript 资源通知,批注读取不再监听无关的 workspace 失效。
20
+
21
+ 新增回归覆盖连续只读请求不增加 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。
22
+
23
+ #50、#57 已随 [PR #59](https://github.com/LiPu-jpg/Openwrite/pull/59) 合并关闭。#50 报告的预设注册、主视图挂载、会话导航及托管模型配置问题已由 0.2.12 修复,并经三代精确 Web 宿主和原生 Windows 安装检查验证。Windows Electron 的实机启动/恢复尚未复测,该限制继续保留;本版不将其计为已通过的验收。
24
+
25
+ ## 0.2.12
26
+
27
+ [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
28
 
11
29
  本版修复 #50–#52 的 0.2 宿主兼容问题及 #54 阅读模式刷新闪烁,纳入 #53 的导航和会话作用域改动,并保留旧宿主的预设插件。
12
30
 
13
31
  - Core 5.8.3:模型连接测试使用所选配置的输出预算;108 项 Core 回归通过。
14
32
  - 本地 297 项组件测试、50 项 DoG 测试通过。三代精确宿主的浏览器流程纳入正式发布门禁:选择目录、预设选择、会话导航、工作台标签、点击创作后实际渲染、DoG 面板与插件共存。该客户端检查使用后端就绪及未初始化响应替身;原生后端安装和作品初始化由独立平台任务验证。
15
- - 完整 CI 及唯一产物校验通过后才发布。Windows Electron 桌面端尚未实机复测,真实模型验证未运行。
33
+ - 完整 CI 和唯一产物校验已通过;合并提交 60949df412f10be004fe6bb5b4fb992dd65c3c60 的 tree 与 CI 来源一致。Windows Electron 桌面端尚未实机复测,真实模型验证未运行。
16
34
 
17
35
  ## 0.2.11
18
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-openwrite",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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
  /**
@@ -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;