Files
WeiCHEN 77028af546 fix(pi-runner): cap /v1/run response size to stop typed reject
Truncate execution.log patches and keep members aligned with patch bodies so
verbose MiniMax-M3 runs no longer exceed the Go 8MB fail-closed limit.
Surface body-too-large vs typed-reject reasons and map contract failures to
fatal restart instead of misleading resumable TASK_INTERRUPTED.
2026-09-23 10:22:23 +08:00

126 lines
4.2 KiB
JavaScript

import { createHash } from "node:crypto";
export const RESULT_V1_SCHEMA_VERSION = 1;
export const MAX_RESULT_MEMBERS = 32;
export const MAX_RESULT_MEMBER_BYTES = 2 * 1024 * 1024;
/** Soft cap for HTTP wire payloads; Go fail-closes at 8MB. */
export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024;
/** Tail window for execution.log patches; full log remains on shared volume. */
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]);
const ALLOWED_CODES = new Set([
"PROMPT_EMPTY",
"UNAUTHORIZED",
"TIMEOUT",
"CANCELLED",
"TRANSIENT",
"MISSING_TARGET",
"RUN_FAILED",
]);
export function typedErrorCode(payload) {
const raw = String(payload?.fallback_reason || payload?.error || "");
const t = raw.toLowerCase();
if (t.includes("prompt_empty")) return "PROMPT_EMPTY";
if (t.includes("unauthorized")) return "UNAUTHORIZED";
if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT";
if (t.includes("cancel")) return "CANCELLED";
if (
t.includes("connection") ||
t.includes("terminated") ||
t.includes("econnreset") ||
t.includes("socket hang")
) {
return "TRANSIENT";
}
if (t.includes("missing target")) return "MISSING_TARGET";
if (payload?.retryable) return "TRANSIENT";
if (!payload?.ok) return "RUN_FAILED";
return "";
}
export function membersDigest(members) {
const rows = (Array.isArray(members) ? members : []).map((m) => ({
kind: m.kind,
path: m.path,
digest: m.digest,
size: m.size,
}));
return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
}
/** Keep only the trailing bytes of execution.log for the HTTP patch wire. */
export function truncateExecutionLogContent(content, maxBytes = EXECUTION_LOG_PATCH_BYTES) {
const buf = Buffer.from(String(content || ""), "utf8");
if (buf.length <= maxBytes) {
return buf.toString("utf8");
}
const tail = buf.subarray(buf.length - maxBytes);
return `[truncated ${buf.length - maxBytes} bytes]\n` + tail.toString("utf8");
}
/**
* Cap patch bodies so members and patches stay aligned under wire limits.
* Oversized / overflow members are dropped from both lists (fail closed on wire size).
*/
export function capPatches(patches, opts = {}) {
const maxMember = opts.maxMemberBytes ?? MAX_RESULT_MEMBER_BYTES;
const maxTotal = opts.maxTotalBytes ?? MAX_RESULT_WIRE_BYTES;
const maxMembers = opts.maxMembers ?? MAX_RESULT_MEMBERS;
const out = [];
let total = 0;
for (const p of Array.isArray(patches) ? patches : []) {
const rel = String(p?.rel_path || "");
if (!rel || rel.includes("..") || rel.startsWith("/")) continue;
let content = String(p.content || "");
if (rel === "execution.log" || rel.endsWith("/execution.log")) {
content = truncateExecutionLogContent(content);
}
const buf = Buffer.from(content, "utf8");
if (buf.length > maxMember) continue;
if (out.length >= maxMembers) break;
if (total + buf.length > maxTotal) continue;
total += buf.length;
out.push({ rel_path: rel, content });
}
return out;
}
export function estimateWireBytes(payload) {
return Buffer.byteLength(JSON.stringify(payload), "utf8");
}
export function withResultV1(payload) {
const out = { ...payload };
delete out.stderr;
const capped = capPatches(Array.isArray(out.patches) ? out.patches : []);
out.patches = capped;
const members = [];
for (const p of capped) {
const rel = String(p?.rel_path || "");
const buf = Buffer.from(String(p.content || ""), "utf8");
members.push({
kind: "file",
path: rel,
digest: createHash("sha256").update(buf).digest("hex"),
size: buf.length,
});
}
const ok = !!out.ok;
const errorCode = ok ? "" : typedErrorCode(out);
const retryable = !ok && RETRYABLE_CODES.has(errorCode) && !!out.retryable;
out.capabilities = { typed_result: true };
out.runner_protocol = "typed-v1";
out.result_v1 = {
schema_version: RESULT_V1_SCHEMA_VERSION,
run_id: String(out.run_id || "unknown").slice(0, 128),
outcome: ok ? "success" : "error",
retryable,
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED",
members,
content_digest: membersDigest(members),
};
return out;
}