From 6b6fb34ad1110c9bb2d7f091f90bce20073a4c34 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 16:24:26 -0500 Subject: [PATCH 1/5] =?UTF-8?q?fix(webui):=20Windows=20=E9=80=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=84=9A=E6=9C=AC=E6=94=B9=E5=9B=9E=20[Console],?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E7=9A=84=E8=B7=AF=E5=BE=84=E8=83=BD=E4=BC=A0?= =?UTF-8?q?=E5=9B=9E=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:终端/控制台改名时脚本里的 [Console]:: 一起被换成 [WebUI]::。PowerShell 找不到类型只报语句级错误,对话框照弹,路径没写到 stdout,退出码 0,前端当成取消 - 已在本机 PowerShell 确认 [WebUI]:: 报 TypeNotFound 后脚本继续执行 Co-Authored-By: Claude Opus 5.5 --- src/web/path-picker.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/path-picker.ts b/src/web/path-picker.ts index 8ade56d2..1cec0cd7 100644 --- a/src/web/path-picker.ts +++ b/src/web/path-picker.ts @@ -157,7 +157,7 @@ function titleOf(options: PathPickerOptions, t: Text): string { } const WINDOWS_SCRIPT = String.raw` -[WebUI]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) Add-Type -AssemblyName System.Windows.Forms $title = $env:CORTICO_PICKER_TITLE $initial = $env:CORTICO_PICKER_INITIAL @@ -179,9 +179,9 @@ if ($env:CORTICO_PICKER_KIND -eq 'directory') { } if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($env:CORTICO_PICKER_KIND -eq 'directory') { - [WebUI]::Out.Write($dialog.SelectedPath) + [Console]::Out.Write($dialog.SelectedPath) } else { - [WebUI]::Out.Write($dialog.FileName) + [Console]::Out.Write($dialog.FileName) } } $dialog.Dispose() From 953a2984b8599ef1e37eefbaa27ca5b87504ba9a Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 16:24:27 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(core):=20=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=99=A8=E8=90=BD=E7=9B=98=E9=81=87=E5=88=B0?= =?UTF-8?q?=20Windows=20=E6=96=87=E4=BB=B6=E5=8D=A0=E7=94=A8=E6=97=B6?= =?UTF-8?q?=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:杀毒软件、索引器、同步盘在文件刚写完时短暂独占它,rename 报 EBUSY,错误冒到主循环把它停掉 - retryBusy 只在 Windows 上对 EBUSY/EPERM/EACCES 重试,同步调用总共等约半秒,仍失败照原样抛出;同 graceful-fs 的做法 - 测试覆盖:占用两次后放开时 save 落盘,一直占用时抛出 Co-Authored-By: Claude Opus 5.5 --- src/core/fs-busy.ts | 20 ++++++++++++++++++++ src/core/state.ts | 3 ++- src/core/timers.ts | 3 ++- tests/core/state.test.ts | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/core/fs-busy.ts diff --git a/src/core/fs-busy.ts b/src/core/fs-busy.ts new file mode 100644 index 00000000..be8cc4b9 --- /dev/null +++ b/src/core/fs-busy.ts @@ -0,0 +1,20 @@ +/** + * Windows 上杀毒软件、索引器、同步盘会在文件刚写完时短暂独占打开它,这时 rename 与 open + * 报 EBUSY、EPERM 或 EACCES。做法同 graceful-fs:只在 Windows 上对这三种错误退避重试, + * 这里是同步调用,总共等约半秒;仍失败就照原样抛出。其他平台与其他错误立即抛出。 + */ +const BUSY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); +const BACKOFF_MS = [10, 30, 60, 120, 250]; +const sleeper = new Int32Array(new SharedArrayBuffer(4)); + +export function retryBusy(op: () => T): T { + for (const ms of BACKOFF_MS) { + try { + return op(); + } catch (e) { + if (process.platform !== 'win32' || !BUSY_CODES.has((e as NodeJS.ErrnoException).code ?? '')) throw e; + Atomics.wait(sleeper, 0, 0, ms); + } + } + return op(); +} diff --git a/src/core/state.ts b/src/core/state.ts index c105c564..005eef69 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -1,6 +1,7 @@ /** Core 机械状态,持久化到 `/core-state.json`。 */ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { retryBusy } from './fs-busy.ts'; export interface CoreStateData { /** 上次截断时刻ISO */ @@ -84,7 +85,7 @@ export class CoreState { const tmp = this.file + '.tmp'; writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8'); // 通过 rename 原子覆盖状态文件,不先删除,避免崩溃后状态缺失。Windows 的 renameSync 使用 MoveFileExW 与 MOVEFILE_REPLACE_EXISTING 支持覆盖。 - renameSync(tmp, this.file); + retryBusy(() => renameSync(tmp, this.file)); } /** diff --git a/src/core/timers.ts b/src/core/timers.ts index 5fbf6e60..07433428 100644 --- a/src/core/timers.ts +++ b/src/core/timers.ts @@ -4,6 +4,7 @@ */ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { retryBusy } from './fs-busy.ts'; import type { Logger, TimerEntry, TimersApi } from './types.ts'; import { nullLogger, shortId } from './util.ts'; @@ -124,6 +125,6 @@ export class TimerStore implements TimersApi { const tmp = this.file + '.tmp'; writeFileSync(tmp, JSON.stringify(this.entries, null, 2), 'utf8'); // 通过 rename 原子覆盖,不先删除,避免崩溃后定时器全部丢失。 - renameSync(tmp, this.file); + retryBusy(() => renameSync(tmp, this.file)); } } diff --git a/tests/core/state.test.ts b/tests/core/state.test.ts index 0c49fe92..f36a2918 100644 --- a/tests/core/state.test.ts +++ b/tests/core/state.test.ts @@ -136,3 +136,38 @@ describe('CoreState', () => { expect(s2.data.lastDeliveredCursor).toBe(172230); }); }); + +describe('CoreState 在 Windows 上遇到文件被短暂占用', () => { + let tmp: ReturnType; + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + beforeEach(() => { + tmp = makeTmpDir(); + Object.defineProperty(process, 'platform', { value: 'win32' }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', platform); + fsHooks.onRename = null; + tmp.cleanup(); + }); + + const busy = () => Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }); + + it('占用几十毫秒后放开时 save 照常落盘', () => { + const s = new CoreState(tmp.dir); + s.load(); + let left = 2; + fsHooks.onRename = () => { if (left-- > 0) throw busy(); }; + s.data.lastDeliveredCursor = 42; + s.save(); + const s2 = new CoreState(tmp.dir); + s2.load(); + expect(s2.data.lastDeliveredCursor).toBe(42); + }); + + it('一直被占用时 save 照原样抛出', () => { + const s = new CoreState(tmp.dir); + s.load(); + fsHooks.onRename = () => { throw busy(); }; + expect(() => s.save()).toThrow('EBUSY'); + }); +}); From aaa7d681295574807bb176b4244506ab5e98d6dc Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 16:24:27 -0500 Subject: [PATCH 3/5] =?UTF-8?q?fix(protocol):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=BF=BD=E5=8A=A0=E4=B8=8E=E9=87=8D=E5=86=99?= =?UTF-8?q?=E9=81=87=E5=88=B0=20Windows=20=E6=96=87=E4=BB=B6=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 追加时 open 失败还没写进任何字节,重试不会写重 Co-Authored-By: Claude Opus 5.5 --- src/protocol/open-responses/context-log.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/protocol/open-responses/context-log.ts b/src/protocol/open-responses/context-log.ts index aa434e32..4992c670 100644 --- a/src/protocol/open-responses/context-log.ts +++ b/src/protocol/open-responses/context-log.ts @@ -1,5 +1,6 @@ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, truncateSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; +import { retryBusy } from '../../core/fs-busy.ts'; import type { ContextRecord } from './context.ts'; function freeze(value: T): T { @@ -64,7 +65,7 @@ export class ContextLog { append(entry: ContextRecord): ContextRecord { const next = structuredClone(entry); if (this.stamp && next.context.ts === undefined) next.context.ts = this.stamp(); - appendFileSync(this.file, JSON.stringify(next) + '\n'); + retryBusy(() => appendFileSync(this.file, JSON.stringify(next) + '\n')); this.entries = Object.freeze([...this.entries, freeze(next)]); return next; } @@ -83,5 +84,5 @@ function atomicContextWrite(path: string, records: readonly ContextRecord[]): vo writeFileSync(fd, records.map(row => JSON.stringify(row)).join('\n') + (records.length ? '\n' : '')); fsyncSync(fd); } finally { closeSync(fd); } - renameSync(temporary, path); + retryBusy(() => renameSync(temporary, path)); } From 1fa206ebb33c20947abc0f616efad6f5f27e0bc7 Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 16:24:27 -0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(worlds/minecraft):=20=E6=BD=9C=E5=BD=B1?= =?UTF-8?q?=E7=9B=92=E8=BF=9B=E7=AE=B1=E5=AD=90=E6=97=8F=E5=90=8D=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E8=83=BD=E6=89=BE=E5=88=B0=E3=80=81=E5=8F=96=E6=94=BE?= =?UTF-8?q?=E3=80=81=E8=AE=B0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原色与 16 种染色共 17 种;mineflayer openContainer 本来就支持 - 挖方块的账本保护名单不加:潜影盒挖下来物品还在盒里 Co-Authored-By: Claude Opus 5.5 --- src/worlds/minecraft/chests.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/worlds/minecraft/chests.ts b/src/worlds/minecraft/chests.ts index 65465ccb..d7be4efc 100644 --- a/src/worlds/minecraft/chests.ts +++ b/src/worlds/minecraft/chests.ts @@ -10,7 +10,13 @@ import type { ItemStack } from './terrain.ts'; export const FURNACE_BLOCKS = ['furnace', 'smoker', 'blast_furnace'] as const; /** 箱子族方块名(能当仓库用的那些);工作台、炉子族、酿造台都不在内 */ -export const CHEST_BLOCKS = ['chest', 'trapped_chest', 'barrel', 'ender_chest'] as const; +export const CHEST_BLOCKS = [ + 'chest', 'trapped_chest', 'barrel', 'ender_chest', + 'shulker_box', 'white_shulker_box', 'orange_shulker_box', 'magenta_shulker_box', 'light_blue_shulker_box', + 'yellow_shulker_box', 'lime_shulker_box', 'pink_shulker_box', 'gray_shulker_box', 'light_gray_shulker_box', + 'cyan_shulker_box', 'purple_shulker_box', 'blue_shulker_box', 'brown_shulker_box', 'green_shulker_box', + 'red_shulker_box', 'black_shulker_box', +] as const; const CHEST_KINDS = new Set(CHEST_BLOCKS); From fbf49fca9aa90c27c6f87d1cc0cde9faea22c62c Mon Sep 17 00:00:00 2001 From: Phant Date: Wed, 21 Oct 2026 16:26:08 -0500 Subject: [PATCH 5/5] =?UTF-8?q?feat(core):=20=E4=B8=BB=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E7=9A=84=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=B8=A6=20event=20loop-exit,=E5=AE=BF=E4=B8=BB=E6=8D=AE?= =?UTF-8?q?=E6=AD=A4=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宿主订阅 runlog 按 event 判断,不比对说明文字 - Core 本身仍只记录并停定时器,进程去留归宿主 Co-Authored-By: Claude Opus 5.5 --- src/core/README.md | 4 ++-- src/core/core.ts | 2 +- tests/core/core.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/README.md b/src/core/README.md index 7852b5dc..90d411d3 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -107,7 +107,7 @@ handler 抛错,或返回值既不是字符串也不是带字符串 text 的对 装配层将 World 构造失败限制在该 World。`start()` 抛错时不挂载;`stop()` 失败或超时会记录 结果并继续卸载。`onOpening`、`onDelivery`、`onBatchEnd`、`onTurnEnded`、`onIdle` 异常记录 warn;`onHandoff` 异常记录 error 并使用默认交接策略。其他钩子的异常由调用方处理。 -读取 `webUi()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 error -并停止定时器。启动器处理未捕获的进程异常并执行关机流程。 +读取 `webUi()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 event 为 `loop-exit` 的 error +并停止定时器,进程不退出;宿主按这个 event 决定是否重启。启动器处理未捕获的进程异常并执行关机流程。 `instanceIsRunning(dataDir)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定(开机时刻、可执行文件名),不获取或更改锁。 diff --git a/src/core/core.ts b/src/core/core.ts index 95cf4aad..69b83b1b 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -616,7 +616,7 @@ export class Core { this.timers.start(); this.runPromise = this.loop.run().catch((e) => { this.timers.stop(); - this.log.error('主循环异常退出', { err: e }); + this.log.emit('error', '主循环异常退出', { event: 'loop-exit', err: e }); }); writeRunJson(this.run, { bot: this.loaded.config.displayName, diff --git a/tests/core/core.test.ts b/tests/core/core.test.ts index 8d1c9c7c..d6a7cf04 100644 --- a/tests/core/core.test.ts +++ b/tests/core/core.test.ts @@ -88,7 +88,7 @@ describe('Core · session 声明与 fork 原语', () => { const { core, llm } = build(); const exits: LogRecord[] = []; core.runlog.onWrite((entry) => { - if (entry.msg === '主循环异常退出') exits.push(entry); + if (entry.event === 'loop-exit') exits.push(entry); }); const due: string[] = []; core.timers.onDue((entry) => due.push(entry.id));