feat(server,fs): W5-E production fsworker role, settings auto-investigate mode, replace old file to trash_item

- main.ts: `--role fsworker` dispatched before the rest of the module graph is
  loaded (other imports became dynamic); runs fsWorkerMain from the new
  `@umt/adapters/fsworker` export (~150 ms startup in the compiled binary)
- fsproxy: default worker command picked per runtime (deno CLI -> `deno run
  fsworker.ts`, compiled binary -> `<execPath> --role fsworker`); stats expose
  workerKind; UMT_FS_TEST_HANG_AFTER_BYTES acceptance switch for containers
- compile: --allow-run adds /app/umt-server (spawn self); start task allows deno
- organize: replaced old file moved into the file-manager trash layout
  (<root>/.umt-trash/<day>/<uuid>/<rel>) and registered in trash_item, ledger
  result follows; organize.transfer log carries fsproxy stats
- settings: autoInvestigateMode (investigate/recover) + max recover attempts
- deploy.md: container verification (subprocess self, TIMEOUT -> SIGKILL x3)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:50:10 +08:00
1 parent fc49c58359
commit 3641ab9573
12 files changed
+248 -48

No files matched your search

+54 -21
View File
@@ -7,7 +7,9 @@
//
// 与 MP 的差异:MP 是常驻 worker + 一把锁串行;我们每个操作一个子进程(~50 ms 启动,入库按文件计可忽略),
// 天然并发、互不拖累,也没有"上一个请求挂死把 worker 占住"的问题。
// 无法起子进程时(deno compile 出的二进制:execPath 不是 deno;或缺 --allow-run)回退 in-process:
// 启动方式:execPath 是 deno CLI(开发/测试)→ `deno run -A fsworker.ts`;否则视为 deno compile 出的 umt-server
// 二进制 → `<execPath> --role fsworker`(入口 main.ts 在加载其余模块前分派)。编译产物的 --allow-run 须含自身绝对路径
// (镜像内 /app/umt-server,见 apps/server/deno.json);起不来(NotCapable 等)回退 in-process:
// 同样的心跳看门狗,超时立即以 TIMEOUT 失败并中止操作,但卡死的系统调用占住的线程无法回收(见 README)。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
@@ -51,6 +53,7 @@ export interface FsCallOptions {
export interface FsProxyStats {
mode: "subprocess" | "inprocess";
workerKind: FsProxy["workerKind"];
spawned: number;
timeouts: number;
killed: number;
@@ -75,13 +78,48 @@ const WORKER_PATH = (() => {
}
})();
function denoCliAvailable(): boolean {
/** 当前运行时能用的缺省 worker 启动命令:deno CLI → run 脚本;编译二进制 → 自身 --role fsworker;都不行 → null */
export function defaultWorkerCommand(): { cmd: string; args: string[]; kind: "script" | "self" } | null {
const D = (globalThis as { Deno?: { execPath?: () => string; Command?: unknown } }).Deno;
if (!D?.Command || !D.execPath || !WORKER_PATH) return false;
if (!D?.Command || !D.execPath) return null;
let exe: string;
try {
return /^deno(\.exe)?$/.test(basename(D.execPath()));
exe = D.execPath();
} catch {
return false;
return null;
}
if (/^deno(\.exe)?$/.test(basename(exe))) {
if (!WORKER_PATH) return null;
return {
cmd: exe,
args: [
"run",
"--no-config",
"--quiet",
"--no-prompt",
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
"--allow-all",
WORKER_PATH,
],
kind: "script",
};
}
return { cmd: exe, args: ["--role", "fsworker"], kind: "self" };
}
/**
* 仅容器/手工验收用:UMT_FS_TEST_HANG_AFTER_BYTES=<n> 让每个数据类操作的 worker 在读到 n 字节后卡死 JS 线程
* (不响应 SIGTERM),用于在生产二进制里验证 停滞超时 → SIGKILL → TIMEOUT。只在子进程模式生效。
*/
function envTestHook(): FsTestHook | undefined {
try {
const v = (globalThis as { Deno?: { env?: { get(k: string): string | undefined } } }).Deno?.env?.get(
"UMT_FS_TEST_HANG_AFTER_BYTES",
);
if (v === undefined || v === "" || !/^\d+$/.test(v)) return undefined;
return { hangAfterBytes: Number(v), ignoreTerm: true };
} catch {
return undefined;
}
}
@@ -109,6 +147,8 @@ function pathOf(req: FsOpRequest): string | undefined {
export class FsProxy {
readonly cfg: Required<Omit<FsProxyConfig, "workerCommand" | "mode">> & Pick<FsProxyConfig, "workerCommand">;
/** 子进程怎么起:script = deno run fsworker.ts;self = 编译二进制 --role fsworker;custom = 显式 workerCommand */
readonly workerKind: "script" | "self" | "custom" | null;
#mode: "subprocess" | "inprocess";
#forced: boolean;
#stats = { spawned: 0, timeouts: 0, killed: 0 };
@@ -120,12 +160,15 @@ export class FsProxy {
quickTimeoutMs: cfg.quickTimeoutMs ?? 30_000,
progressIntervalMs: cfg.progressIntervalMs ?? 1000,
killGraceMs: cfg.killGraceMs ?? 2000,
...(cfg.workerCommand ? { workerCommand: cfg.workerCommand } : {}),
};
const auto = cfg.workerCommand ? null : defaultWorkerCommand();
const wc = cfg.workerCommand ?? (auto ? { cmd: auto.cmd, args: auto.args } : undefined);
if (wc) this.cfg.workerCommand = wc;
this.workerKind = cfg.workerCommand ? "custom" : (auto?.kind ?? null);
const mode = cfg.mode ?? "auto";
this.#forced = mode === "subprocess";
if (mode === "inprocess") this.#mode = "inprocess";
else if (cfg.workerCommand || denoCliAvailable()) this.#mode = "subprocess";
else if (wc) this.#mode = "subprocess";
else {
if (mode === "subprocess") throw new Error("fsproxy: 当前运行时无法启动 deno 子进程,且未提供 workerCommand");
this.#mode = "inprocess";
@@ -138,7 +181,7 @@ export class FsProxy {
}
stats(): FsProxyStats {
return { mode: this.#mode, ...this.#stats, fallbackReason: this.#fallbackReason };
return { mode: this.#mode, workerKind: this.workerKind, ...this.#stats, fallbackReason: this.#fallbackReason };
}
// ── 业务操作 ──
@@ -260,18 +303,7 @@ export class FsProxy {
async #runSubprocess(req: FsOpRequest, call: FsCallOptions): Promise<unknown> {
const D = (globalThis as unknown as { Deno: DenoLike }).Deno;
const cmd = this.cfg.workerCommand ?? {
cmd: D.execPath(),
args: [
"run",
"--no-config",
"--quiet",
"--no-prompt",
// Deno 对 /dev、/proc 下的路径(如 /dev/shm 上的库根)要求 --allow-all;worker 只执行本包固定的操作表
"--allow-all",
WORKER_PATH!,
],
};
const cmd = this.cfg.workerCommand!;
let child: DenoChild;
try {
child = new D.Command(cmd.cmd, {
@@ -287,7 +319,8 @@ export class FsProxy {
this.#stats.spawned += 1;
const limit = this.#limitFor(req);
const w = child.stdin.getWriter();
await w.write(new TextEncoder().encode(JSON.stringify(call._test ? { ...req, _test: call._test } : req)));
const hook = call._test ?? (DATA_OPS.has(req.op) ? envTestHook() : undefined);
await w.write(new TextEncoder().encode(JSON.stringify(hook ? { ...req, _test: hook } : req)));
await w.close().catch(() => undefined);
let stderr = "";