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

+14 -5
View File
@@ -79,7 +79,7 @@ export interface CrossMoveResult {
steps: CrossMoveStepLog[];
}
export interface CrossMoveOptions extends Omit<CopyOptions, "hash"> {
export interface CrossMoveOptions extends Omit<CopyOptions, "hash" | "onExisting" | "replaceOld"> {
hash?: VerifyMethod;
/** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */
previous?: CrossMoveStepLog[];
@@ -103,6 +103,10 @@ async function exists(p: string): Promise<boolean> {
*/
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
const method = opts.hash ?? "size";
const io = {
...(opts.signal ? { signal: opts.signal } : {}),
...(opts.onVerifyProgress ? { onProgress: opts.onVerifyProgress } : {}),
};
const steps: CrossMoveStepLog[] = [];
const log = (step: CrossMoveStep, status: CrossMoveStepLog["status"], detail?: string, err?: unknown) => {
const e = err === undefined ? undefined : toFsError(err);
@@ -136,11 +140,14 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
return { status: dstExists ? "attention" : "failed", steps };
}
let fullyVerified = false;
if (dstExists) {
try {
// 目标不是本次写出的:只比大小/抽样会把同尺寸的不同文件当成一致并删源,这里一律用 sha256
if (await filesMatch(src, dst, "sha256")) log("copy", "skipped", "target already matches source");
else {
if (await filesMatch(src, dst, "sha256", io)) {
log("copy", "skipped", "target already matches source");
fullyVerified = true;
} else {
log("copy", "failed", "target exists and differs", new FsError("EEXIST", `target exists: ${dst}`));
return { status: "failed", steps };
}
@@ -153,6 +160,8 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
const { previous: _p, ...copyOpts } = opts;
const r = await copyWithVerify(src, dst, { ...copyOpts, hash: method });
log("copy", "done", `${r.bytes} bytes`);
// copy 已用流式哈希 + 回读临时文件全量比对,并确认源 size/mtime 未变:verify 不再重复全量哈希两边
if (r.sha256) fullyVerified = true;
} catch (e) {
log("copy", "failed", undefined, e);
return { status: "failed", steps };
@@ -160,11 +169,11 @@ export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveO
}
try {
if (!(await filesMatch(src, dst, method))) {
if (!(await filesMatch(src, dst, fullyVerified ? "size" : method, io))) {
log("verify", "failed", undefined, new FsError("VERIFY_FAILED", "target differs from source"));
return { status: "failed", steps };
}
log("verify", "done", method);
log("verify", "done", fullyVerified ? `${method} (sha256 verified in copy step)` : method);
} catch (e) {
log("verify", "failed", undefined, e);
return { status: "failed", steps };