fix: report apply-patch partial failures

This commit is contained in:
Codex
2026-05-30 04:05:52 +00:00
parent b2435d406b
commit 531d435777
7 changed files with 172 additions and 37 deletions
+6
View File
@@ -2,6 +2,12 @@
UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文件是项目顶级索引,也承担 `scripts/cli.ts` 的 CLI 使用说明入口。
## P0 最高优先级:最新要求与旧门禁清除
- P0: 用户最新明确要求优先于旧测试、旧门禁、旧预检、旧断言、旧 guard 和旧 gate;开发过程中遇到任何阻碍最新目标的旧测试、旧门禁、旧预检或旧断言,一律拆除,禁止继续维护、修补、叠例外或用兼容路径绕行。
- P0: 短连接 CLI、临时工具、文档和轻量 helper 不套用常驻服务、镜像、Job、GitOps、PR、CI/CD 或重型发布流程的旧门禁;如果旧流程要求与最新架构定位冲突,以最新架构定位为准并删除旧流程入口。
- P0: 任何测试、预检或自检只允许表达当前最新目标行为;旧历史断言不得作为回归保护保留,避免把旧路线固化成长期摩擦。
## Critical Long-Term Reference Docs Rule
- P0: 任何新增、修改或蒸馏 `docs/reference/*.md` 长期参考文档的动作,必须遵循 `docs-spec` 规范;禁止绕过 `docs-spec` 凭临时习惯改写长期参考。
+1 -1
View File
@@ -29,7 +29,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 DEV/PROD 滚动、P
- `server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`;该命令用于替代手工删除容器的兜底流程,其中 `dev-frontend-proxy` 只更新主 server dev 入口薄代理,`todo-note``code-queue-mgr``project-manager``baidu-netdisk``oa-event-flow` 只重建主 server 承载的对应后端,不会重建或删除 database 命名卷。D601 Code Queue 执行面不由 `server rebuild` 管理,Rust backend-core 迭代不得用 `server rebuild backend-core` 在 master server 编译,规则见 `docs/reference/dev-environment.md`
- `provider attach <providerId> [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-<ID>.env` 默认只包含 `UNIDESK_MASTER_SERVER``PROVIDER_ID``provider-<ID>.yml` 固定 Docker socket、`pid: "host"``restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build``provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap``service-degraded``provider-degraded``global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full``--raw`;推荐交叉验证命令仍包含 `debug health``debug dispatch <providerId> host.ssh --wait-ms 15000``ssh <providerId> argv true``artifact-registry health --provider-id <providerId>``microservice health k3sctl-adapter``microservice health code-queue``codex tasks --view supervisor --limit 20`
- `ssh <route> [operation args...]` / `tran <route> [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点;`route` 基础形态是 provider id,例如 `D601``G14`,也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win``D601:win/c/test``G14:k3s``D601:k3s``G14:k3s:<namespace>:<workload>`。WSL provider 的 Windows cmd 入口固定写 `tran D601:win cmd <command-line>`,需要 Windows cwd 时用 `tran D601:win/c/test cmd cd`,由 CLI 自动设置 `chcp 65001``PYTHONUTF8=1``PYTHONIOENCODING=utf-8`;命名只允许 `win`,不得使用 `win32`。非交互远端命令优先使用 `ssh <providerId> argv ...`;需要 shell 脚本、管道、变量或循环时优先使用 quoted heredoc 单步传输,例如 `tran G14 script <<'SCRIPT'``tran G14:k3s script <<'SCRIPT'``tran G14:k3s:<namespace>:<workload> script <<'SCRIPT'`,把脚本走 stdin。`script -- '<单个字符串>'` 是无需 stdin 的远端 shell one-liner,例如 `tran G14:/root/hwlab script -- 'cd /root/hwlab && git status --short --branch'``script -- <多个 argv>` 才是 direct argv,适合 `tran D601:/path script -- sed -n '1,20p' file` 这类带短横线的单进程命令。顶层 remote option parser 必须保留命令已经开始后的 `--`,不得把它吞成全局选项结束符。需要远端改文本文件时默认优先使用 `<route> apply-patch < patch.diff`;需要可靠传输非文本或整文件时使用 `<route> upload <local-file> <remote-file>``<route> download <remote-file> <local-file>`CLI 会按字节数与 SHA-256 自动校验并在 provider-gateway stdin/argv 限制下切换客户端分块策略;需要旧 helper 时显式使用 `<provider>:k3s:<namespace>:<workload> apply-patch-v1``<providerId> apply-patch-v1`。ssh-like 命令遇到 timeout/kex/255 类失败时,CLI 会在 stderr 追加一行 `UNIDESK_SSH_HINT` JSON,提示 stdin script/argv 重试和 provider triage 交叉验证。
- `ssh <route> apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`ssh <providerId> apply-patch-v1 [tool args...] < patch.diff` 保留为 v1 fallback,直接调用远端注入的 `apply_patch` sh/perl helper;只有默认 v2 引擎出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1。
- `ssh <route> apply-patch < patch.diff` 是默认推荐的远端 patch 入口:本地 TypeScript line-based engine 解析和计算新文件内容,远端 route 只负责读写文件;支持 host workspace、k3s pod workspace、Windows workspace route(例如 `D601:win/c/test`)和 frontend transport,并优先处理长中文/Unicode、低上下文插入、重复块 `@@` 定位等旧 helper 容易失败的场景。`apply-patch` 输出按 Codex 标准文本口径,不套 UniDesk JSON 限制:成功 stdout 为 `Success. Updated the following files:`,失败 stdout 为空、stderr 写失败原因;多文件补丁中途失败时,stderr 还会直接列出 `Applied before failure``Failed``Pending after failure`,避免再二次查询哪些文件已写。Windows route 复用同一套 v2 核心算法,只把底层读写替换成 PowerShell 文件系统接口;`ssh <providerId> apply-patch-v1 [tool args...] < patch.diff` 保留为 v1 fallback,直接调用远端注入的 `apply_patch` sh/perl helper;只有默认 v2 引擎出现问题、需要复用旧 helper 行为或人工确认 `--allow-loose` 时才优先使用 v1。
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills``.codex/skills`
- `ssh <providerId>:k3s[:namespace:workload[:container]] <operation> ...` 是原生 k3s 结构化 route 入口,route 只定位控制面或 workload`kubectl``logs``exec``script``apply-patch`、旧 `apply-patch-v1` fallback 和普通容器命令作为 operation 放在 route 之后;CLI 固定注入 `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` 并把 kubectl、workload exec、logs 和 pod workspace 读写参数组装成 argv,避免在 Host SSH、bash、kubectl exec 和容器 shell 之间反复手写多层引号;D601 与 G14 都有 provider-specific guard,分别校验 `d601` 和 G14 k3s 节点身份。
+115 -31
View File
@@ -23,10 +23,27 @@ export interface PatchUpdateResult {
newContent: string;
}
export interface ApplyPatchV2Outcome {
hunk: number;
action: PatchHunk["kind"] | "move";
status: "applied" | "failed" | "pending";
path: string;
targetPath?: string;
change?: string;
reason?: string;
partialChanges?: string[];
error?: {
name?: string;
message: string;
details?: unknown;
};
}
export interface ApplyPatchV2Options {
executor: ApplyPatchV2Executor;
stdin: Readable;
stdout: Writable;
stderr?: Writable;
argv?: string[];
}
@@ -66,6 +83,7 @@ type Replacement = [start: number, oldLength: number, newLines: string[]];
interface ApplyPatchV2Plan {
changed: string[];
outcomes: ApplyPatchV2Outcome[];
}
const beginMarker = "*** Begin Patch";
@@ -185,35 +203,27 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
return 0;
}
const stderr = options.stderr ?? process.stderr;
if ((options.argv?.length ?? 0) > 0) {
options.stdout.write(`${JSON.stringify({
ok: false,
error: {
code: "apply_patch_unsupported_args",
message: "ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options."
},
unsupportedArgs: options.argv,
help: applyPatchV2HelpPayload()
}, null, 2)}\n`);
stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n");
return 2;
}
const patchText = await readStreamText(options.stdin);
if (!patchText.trim()) {
options.stdout.write(`${JSON.stringify({
ok: false,
error: {
code: "apply_patch_stdin_required",
message: "ssh apply-patch requires patch text on stdin."
},
help: applyPatchV2HelpPayload()
}, null, 2)}\n`);
stderr.write("ssh apply-patch requires patch text on stdin.\n");
return 2;
}
const parsed = parseApplyPatchV2(patchText);
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
options.stdout.write("Success. Updated the following files:\n");
for (const item of plan.changed) options.stdout.write(`${item}\n`);
return 0;
try {
const parsed = parseApplyPatchV2(patchText);
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
options.stdout.write("Success. Updated the following files:\n");
for (const item of plan.changed) options.stdout.write(`${item}\n`);
return 0;
} catch (error) {
if (options.stderr === undefined) throw error;
options.stderr.write(formatApplyPatchFailure(error));
return 1;
}
}
async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
@@ -221,6 +231,7 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
const states = new Map<string, PlannedFileState>();
const changed: string[] = [];
const outcomes: ApplyPatchV2Outcome[] = [];
async function readPlannedText(filePath: string): Promise<string> {
const state = states.get(filePath);
@@ -249,16 +260,20 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
states.set(filePath, { exists: false, content: "" });
}
try {
for (const hunk of hunks) {
for (let index = 0; index < hunks.length; index += 1) {
const hunk = hunks[index] as PatchHunk;
const changedBefore = changed.length;
try {
if (hunk.kind === "add") {
await applyWrite(hunk.path, hunk.content);
changed.push(`A ${hunk.path}`);
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `A ${hunk.path}` });
continue;
}
if (hunk.kind === "delete") {
await applyDelete(hunk.path);
changed.push(`D ${hunk.path}`);
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `D ${hunk.path}` });
continue;
}
const originalContent = await readPlannedText(hunk.path);
@@ -267,20 +282,89 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
await applyWrite(hunk.movePath, update.newContent);
changed.push(`M ${hunk.movePath}`);
await applyDelete(hunk.path);
outcomes.push({ ...outcomeBase(hunk, index), action: "move", status: "applied", change: `M ${hunk.movePath}` });
continue;
}
await applyWrite(hunk.path, update.newContent);
changed.push(`M ${hunk.path}`);
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `M ${hunk.path}` });
} catch (error) {
const partialChanges = changed.slice(changedBefore);
outcomes.push({
...outcomeBase(hunk, index),
status: "failed",
...(partialChanges.length > 0 ? { partialChanges } : {}),
error: errorSummary(error),
});
for (let pendingIndex = index + 1; pendingIndex < hunks.length; pendingIndex += 1) {
outcomes.push({
...outcomeBase(hunks[pendingIndex] as PatchHunk, pendingIndex),
status: "pending",
reason: "skipped_after_previous_failure",
});
}
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
partialChanges: changed,
outcomes,
failed: outcomes.find((item) => item.status === "failed") ?? null,
pending: outcomes.filter((item) => item.status === "pending"),
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
});
}
} catch (error) {
if (changed.length === 0) throw error;
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
partialChanges: changed,
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
});
}
return { changed };
return { changed, outcomes };
}
function outcomeBase(hunk: PatchHunk, index: number): Omit<ApplyPatchV2Outcome, "status"> {
if (hunk.kind === "update" && hunk.movePath !== null && hunk.movePath !== hunk.path) {
return { hunk: index + 1, action: "move", path: hunk.path, targetPath: hunk.movePath };
}
return { hunk: index + 1, action: hunk.kind, path: hunk.path };
}
function errorSummary(error: unknown): ApplyPatchV2Outcome["error"] {
if (error instanceof ApplyPatchV2Error) {
return { name: error.name, message: error.message, details: error.details };
}
if (error instanceof Error) {
return { name: error.name, message: error.message };
}
return { message: String(error) };
}
function formatApplyPatchFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const details = error instanceof ApplyPatchV2Error ? error.details : {};
const outcomes = Array.isArray(details.outcomes) ? details.outcomes as ApplyPatchV2Outcome[] : [];
const lines = [`${message.trimEnd()}`];
if (outcomes.length > 1 || outcomes.some((item) => item.status === "applied")) {
lines.push("Patch status:");
appendOutcomeSection(lines, "Applied before failure:", outcomes.filter((item) => item.status === "applied"));
appendOutcomeSection(lines, "Failed:", outcomes.filter((item) => item.status === "failed"));
appendOutcomeSection(lines, "Pending after failure:", outcomes.filter((item) => item.status === "pending"));
}
return `${lines.join("\n")}\n`;
}
function appendOutcomeSection(lines: string[], title: string, outcomes: ApplyPatchV2Outcome[]): void {
if (outcomes.length === 0) return;
lines.push(title);
for (const outcome of outcomes) {
lines.push(` ${formatOutcome(outcome)}`);
}
}
function formatOutcome(outcome: ApplyPatchV2Outcome): string {
const target = outcome.targetPath === undefined ? outcome.path : `${outcome.path} -> ${outcome.targetPath}`;
const base = `hunk ${outcome.hunk} ${outcome.action} ${target}`;
if (outcome.status === "applied") return outcome.change === undefined ? base : `${outcome.change} (${base})`;
if (outcome.status === "pending") return `${base} (pending)`;
const error = outcome.error?.message ?? "failed";
const partial = outcome.partialChanges === undefined || outcome.partialChanges.length === 0
? ""
: `; partial changes: ${outcome.partialChanges.join(", ")}`;
return `${base}: ${error}${partial}`;
}
async function ensureRemoteFileExists(executor: ApplyPatchV2Executor, target: string): Promise<void> {
+1 -1
View File
@@ -191,7 +191,7 @@ export function sshHelp(): unknown {
"When a one-line shell command is easier to type through the script path, `script -- '<command && command>'` runs that single string through the remote shell without waiting for stdin. When `script --` is followed by multiple tokens, it stays a direct argv form for commands such as `tran D601:/work script -- sed -n '1,20p' file`.",
"script and shell helper modes inject a tiny POSIX-compatible printf wrapper before user shell text, so portable printf headings such as `printf \"--- section ---\\n\"` work consistently under dash/sh and bash. Direct argv commands are unchanged.",
"For arbitrary stdin streams into a workload command, use a workload route plus `exec --stdin -- <command> ...`; this keeps the route as location-only and avoids heredoc/base64/tar shell wrapping.",
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser.",
"`apply-patch` is the default remote text patch entry and uses the v2 local line-based patch engine with remote read/write operations, including Windows routes such as `D601:win/c/test`, so long Unicode/Chinese lines and pure insertion hunks avoid the legacy remote shell hunk parser. Its stdout/stderr follows Codex apply_patch text output rather than UniDesk JSON output; on multi-file failure, stderr lists applied, failed and pending hunks.",
"`upload` and `download` are the default whole-file transfer entries for non-text and generated files. They write through remote temp files, verify byte count and SHA-256 on both sides, and fall back from a single stdin payload to bounded client-side chunks before treating provider-gateway limits as a server-side problem.",
"`apply-patch-v1` is the only legacy fallback entry: it rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
"script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
+1 -1
View File
@@ -1277,7 +1277,7 @@ async function runRemoteSshOverFrontend(session: FrontendSession, target: string
const executor: ApplyPatchV2Executor = {
run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input),
};
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, argv: args.slice(1) });
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, argv: args.slice(1) });
}
return runRemoteSshWebSocket(session, invocation);
}
+1
View File
@@ -2394,6 +2394,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
executor,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
argv: args.slice(1),
});
}
+47 -3
View File
@@ -102,24 +102,33 @@ function applyPatchFixture(args: string[], patch: string, files: Record<string,
}
}
async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, string>): Promise<{ stdout: string; files: Record<string, string>; commands: string[]; error: unknown | null }> {
async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, string>, options: { stderrOutput?: boolean } = {}): Promise<{ stdout: string; stderr: string; exitCode: number | null; files: Record<string, string>; commands: string[]; error: unknown | null }> {
const state = new Map(Object.entries(files));
const pendingWrites = new Map<string, string>();
const commands: string[] = [];
const stdin = new PassThrough();
stdin.end(patch);
let stdout = "";
let stderr = "";
const stdoutSink = new Writable({
write(chunk, _encoding, callback) {
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
const stderrSink = new Writable({
write(chunk, _encoding, callback) {
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
callback();
},
});
let error: unknown | null = null;
let exitCode: number | null = null;
try {
await runApplyPatchV2({
exitCode = await runApplyPatchV2({
stdin,
stdout: stdoutSink,
...(options.stderrOutput === true ? { stderr: stderrSink } : {}),
executor: {
async run(command, input) {
const operation = command[4] ?? "";
@@ -199,7 +208,7 @@ async function applyPatchV2FixtureAttempt(patch: string, files: Record<string, s
} catch (caught) {
error = caught;
}
return { stdout, files: Object.fromEntries(state), commands, error };
return { stdout, stderr, exitCode, files: Object.fromEntries(state), commands, error };
}
async function applyPatchV2ActualShellFixtureAttempt(
@@ -734,6 +743,41 @@ export async function runSshArgvGuidanceContract(): Promise<JsonRecord> {
failedCompoundV2.error,
);
const failedCompoundVisibleV2 = await applyPatchV2FixtureAttempt([
"*** Begin Patch",
"*** Update File: first.txt",
"@@",
"-old first",
"+new first",
"*** Update File: second.txt",
"@@",
"-missing second",
"+new second",
"*** Update File: third.txt",
"@@",
"-old third",
"+new third",
"*** End Patch",
"",
].join("\n"), {
"first.txt": "old first\n",
"second.txt": "old second\n",
"third.txt": "old third\n",
}, { stderrOutput: true });
assertCondition(failedCompoundVisibleV2.exitCode === 1 && failedCompoundVisibleV2.error === null, "v2 CLI path should return non-zero instead of throwing when stderr is provided", failedCompoundVisibleV2);
assertCondition(failedCompoundVisibleV2.stdout === "", "v2 failed CLI path should keep Codex-style empty stdout", failedCompoundVisibleV2);
assertCondition(
failedCompoundVisibleV2.stderr.includes("failed to find expected lines")
&& failedCompoundVisibleV2.stderr.includes("Applied before failure:")
&& failedCompoundVisibleV2.stderr.includes("M first.txt")
&& failedCompoundVisibleV2.stderr.includes("Failed:")
&& failedCompoundVisibleV2.stderr.includes("hunk 2 update second.txt")
&& failedCompoundVisibleV2.stderr.includes("Pending after failure:")
&& failedCompoundVisibleV2.stderr.includes("hunk 3 update third.txt"),
"v2 failed CLI path should print Codex-style stderr plus per-file applied/failed/pending summary",
failedCompoundVisibleV2.stderr,
);
const addBeforeFailedUpdateV2 = await applyPatchV2FixtureAttempt([
"*** Begin Patch",
"*** Add File: hwpod",