feat(fs): W4-E4 fsproxy hang isolation, partial sweep, replace-with-verify, TIMEOUT retry

- adapters/fs: FsProxy + fsworker run each file op in a killable subprocess;
  progress-stall watchdog (FS_HANG_TIMEOUT_MS) for copy/hash, total timeout for
  quick ops; SIGTERM -> SIGKILL -> FsError('TIMEOUT'); stat/checkRoot timeouts
  never read as missing
- copyWithVerify: onExisting conflict/skip/replace (hardlink backup + atomic
  rename, old file to .umt-trash or deleted), sweep stale partials before write,
  document why permissions/owner are not copied
- filesMatch/filesMatchSha256 throw FsError on read failure instead of
  match=false; crossDeviceMove skips redundant re-hash after sha256 copy
- FsErrorCode TIMEOUT + fsErrorClass; organize executeRecord routes fs ops
  through fsproxy, retries TIMEOUT/IO up to FS_TRANSFER_MAX_ATTEMPTS then opens
  a decision without deleting the record; reconcile errors no longer reported
  as EEXIST
- domain: subtitle .default marker (ORGANIZE_DEFAULT_SUBTITLE_LANG) and
  findOtherVersions (same season/episode/part, video only) as upgrade
  disposal candidates
- scripts/bench/fs-bench.ts and benchmark record in porting doc

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:15:05 +08:00
1 parent 36dc048e89
commit 2e2d56184c
21 files changed
+1970 -179

No files matched your search

+25
View File
@@ -30,6 +30,31 @@ export const ConfigSchema = z.object({
DATA_DIR: z.string().min(1).default("./data"),
/** Agent JSONL 会话目录;缺省 DATA_DIR/agent-sessions(api 与 worker 必须共享同一目录) */
AGENT_SESSION_DIR: z.string().min(1).optional(),
// ── 文件操作挂死隔离(adapters fsproxy,对标 MP fsproxy)──
/** auto:能起 deno 子进程就把单个文件操作放进执行子进程(可 SIGKILL 回收),否则进程内看门狗;inprocess 强制进程内 */
FS_PROXY_MODE: z.enum(["auto", "subprocess", "inprocess"]).default("auto"),
/** 复制/哈希:进度停滞多久判挂死(ms),超时 SIGTERM→SIGKILL 并以 TIMEOUT 失败 */
FS_HANG_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000),
/** stat/根检查/同卷 move 等快操作的总超时(ms) */
FS_QUICK_TIMEOUT_MS: z.coerce.number().int().min(500).default(30_000),
/** 执行子进程上报进度心跳的间隔(ms) */
FS_PROGRESS_INTERVAL_MS: z.coerce.number().int().min(10).default(1000),
/** SIGTERM 到 SIGKILL 的宽限(ms) */
FS_KILL_GRACE_MS: z.coerce.number().int().min(100).default(2000),
/** 写入前清理目标目录残留 partial 的年龄阈值(ms);0 = 不清 */
FS_PARTIAL_MAX_AGE_MS: z.coerce
.number()
.int()
.min(0)
.default(24 * 3600_000),
/** 单个文件的 TIMEOUT/IO 类失败最多尝试几次(含首次);超限转待处理,不删记录不重下 */
FS_TRANSFER_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(3),
/** 替换入库(replace_with_verify)时旧文件去向:trash = 移到库根 .umt-trash;delete = 删除 */
FS_REPLACE_OLD: z.enum(["trash", "delete"]).default("trash"),
/** 入库目标已存在时的缺省策略:conflict = 转待处理;skip = 跳过;replace_with_verify = 授权后写临时→校验→原子替换 */
ORGANIZE_ON_EXISTING: z.enum(["conflict", "skip", "replace_with_verify"]).default("conflict"),
/** 随行字幕中此语言加 .default 标记(对照 MP DEFAULT_SUB);不设 = 不加 */
ORGANIZE_DEFAULT_SUBTITLE_LANG: z.enum(["zh-cn", "zh-tw", "en", "ja", "ko"]).optional(),
});
export type Config = z.infer<typeof ConfigSchema>;