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
+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),
});
}