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

+34 -3
View File
@@ -8,8 +8,37 @@ export type FsErrorCode =
| "SOURCE_CHANGED"
| "ROOT_OFFLINE"
| "ABORTED"
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
| "TIMEOUT"
| "IO";
/**
* 错误码 → 处置类别(对照 MP:所有 IO 故障归 OSError 复用重试;我们更细):
* - retry:可退避重试(IO / TIMEOUT / ROOT_OFFLINE / SOURCE_CHANGED),超限后进待处理
* - attention:直接进待处理、不重试(EACCES / ENOSPC / EXDEV)
* - manual:需人工判断(EEXIST / VERIFY_FAILED / ENOENT)
* - aborted:被取消
*/
export type FsErrorClass = "retry" | "attention" | "manual" | "aborted";
export function fsErrorClass(code: string): FsErrorClass {
switch (code) {
case "IO":
case "TIMEOUT":
case "ROOT_OFFLINE":
case "SOURCE_CHANGED":
return "retry";
case "EACCES":
case "ENOSPC":
case "EXDEV":
return "attention";
case "ABORTED":
return "aborted";
default:
return "manual";
}
}
export class FsError extends Error {
override readonly name = "FsError";
constructor(
@@ -33,9 +62,11 @@ export function toFsError(e: unknown, path?: string): FsError {
? code
: code === "EACCES" || code === "EPERM"
? "EACCES"
: (e as Error)?.name === "AbortError"
? "ABORTED"
: "IO";
: code === "ETIMEDOUT"
? "TIMEOUT"
: (e as Error)?.name === "AbortError"
? "ABORTED"
: "IO";
return new FsError(mapped, (e as Error)?.message ?? String(e), {
...(path !== undefined ? { path } : {}),
...((e as NodeJS.ErrnoException)?.syscall ? { syscall: (e as NodeJS.ErrnoException).syscall! } : {}),