fix(agentrun): 完善重试命令发现与错误输出
This commit is contained in:
@@ -4,6 +4,20 @@ import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand,
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests";
|
||||
|
||||
function renderedTextOf(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result");
|
||||
const text = (value as { renderedText?: unknown }).renderedText;
|
||||
if (typeof text !== "string") throw new Error("expected renderedText string");
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderedContentTypeOf(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result");
|
||||
const contentType = (value as { contentType?: unknown }).contentType;
|
||||
if (typeof contentType !== "string") throw new Error("expected contentType string");
|
||||
return contentType;
|
||||
}
|
||||
|
||||
const agentRunClientYaml = [
|
||||
"version: 1",
|
||||
"kind: AgentRunConfig",
|
||||
@@ -313,6 +327,137 @@ describe("AgentRun default transport contract", () => {
|
||||
const attemptHelp = await runAgentRunCommand(null, ["get", "attempts", "--help"]);
|
||||
expect("renderedText" in retryHelp ? retryHelp.renderedText : "").toContain("agentrun retry task/<taskId> --idempotency-key <key> --reason <text>");
|
||||
expect("renderedText" in attemptHelp ? attemptHelp.renderedText : "").toContain("agentrun get attempts --task <taskId>");
|
||||
|
||||
const rootHelp = Bun.spawnSync(["bun", "scripts/cli.ts", "agentrun", "--help"], {
|
||||
cwd: new URL("../..", import.meta.url).pathname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const rootHelpText = rootHelp.stdout.toString();
|
||||
expect(rootHelp.exitCode).toBe(0);
|
||||
expect(rootHelpText).toContain("Verbs: get, describe, events, logs, result, ack, cancel, retry,");
|
||||
expect(rootHelpText).toContain("Resources: task/qt, attempt, run,");
|
||||
expect(rootHelpText).toContain("agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run");
|
||||
expect(rootHelpText).toContain("agentrun get attempts --task <taskId> --limit 20");
|
||||
expect(rootHelpText.split("\n").length).toBeLessThan(40);
|
||||
});
|
||||
|
||||
test("local retry and attempt validation preserves bounded human and machine errors", async () => {
|
||||
const cases = [
|
||||
{
|
||||
args: ["retry", "task/qt_failed_1", "--reason", "dependency repaired"],
|
||||
message: "retry requires --idempotency-key <key>",
|
||||
},
|
||||
{
|
||||
args: ["retry", "task/qt_failed_1", "--idempotency-key", "retry-key-1"],
|
||||
message: "retry requires --reason <text>",
|
||||
},
|
||||
{
|
||||
args: ["get", "attempts"],
|
||||
message: "get attempts requires --task <taskId>",
|
||||
},
|
||||
{
|
||||
args: ["retry", "run/run_invalid", "--idempotency-key", "retry-key-1", "--reason", "dependency repaired"],
|
||||
message: "retry supports task/<taskId>",
|
||||
},
|
||||
{
|
||||
args: ["retry"],
|
||||
message: "retry requires task/<taskId>",
|
||||
},
|
||||
];
|
||||
for (const item of cases) {
|
||||
const human = await runAgentRunCommand(null, item.args);
|
||||
const humanText = renderedTextOf(human);
|
||||
expect(human.ok).toBe(false);
|
||||
expect(humanText).toContain("Error: validation-failed");
|
||||
expect(humanText).toContain(item.message);
|
||||
expect(humanText.length).toBeLessThan(1600);
|
||||
|
||||
for (const outputArgs of [["-o", "json"], ["--raw"]]) {
|
||||
const machine = await runAgentRunCommand(null, [...item.args, ...outputArgs]);
|
||||
const machineText = renderedTextOf(machine);
|
||||
const payload = JSON.parse(machineText) as { ok?: boolean; failureKind?: string; message?: string };
|
||||
expect(machine.ok).toBe(false);
|
||||
expect(renderedContentTypeOf(machine)).toBe("application/json");
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.failureKind).toBe("validation-failed");
|
||||
expect(payload.message).toBe(item.message);
|
||||
expect(machineText.length).toBeLessThan(2400);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cliArgs of [
|
||||
["retry", "run/run_invalid", "--idempotency-key", "retry-key-1", "--reason", "dependency repaired", "-o", "json"],
|
||||
["get", "attempts", "--raw"],
|
||||
]) {
|
||||
const cli = Bun.spawnSync(["bun", "scripts/cli.ts", "agentrun", ...cliArgs], {
|
||||
cwd: new URL("../..", import.meta.url).pathname,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const payload = JSON.parse(cli.stdout.toString()) as { ok?: boolean; failureKind?: string };
|
||||
expect(cli.exitCode).toBe(1);
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.failureKind).toBe("validation-failed");
|
||||
expect(cli.stdout.toString().length).toBeLessThan(2400);
|
||||
}
|
||||
});
|
||||
|
||||
test("server retry validation errors stay bounded in human and JSON output", async () => {
|
||||
const taskId = "qt_completed_1";
|
||||
const longMessage = `queue task ${taskId} cannot be retried from state completed: ${"x".repeat(6000)}`;
|
||||
const longDiagnostic = `server diagnostic: ${"y".repeat(9000)}`;
|
||||
let requests = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
expect(url.pathname).toBe(`/api/v1/queue/tasks/${taskId}/retry`);
|
||||
expect(request.method).toBe("POST");
|
||||
expect(await request.json()).toEqual({ idempotencyKey: "retry-key-terminal", reason: "operator requested retry", dryRun: true });
|
||||
requests += 1;
|
||||
return Response.json({
|
||||
ok: false,
|
||||
failureKind: "schema-invalid",
|
||||
message: longMessage,
|
||||
details: { allowedStates: ["failed", "blocked"], diagnostic: longDiagnostic, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
}, { status: 409 });
|
||||
},
|
||||
});
|
||||
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
const previousKey = process.env.HWLAB_API_KEY;
|
||||
process.env.HWLAB_API_KEY = "secret-value";
|
||||
const tempConfigPath = `/tmp/unidesk-agentrun-retry-error-test-${process.pid}-${Date.now()}.yaml`;
|
||||
try {
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath;
|
||||
await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, "")));
|
||||
const baseArgs = ["retry", `task/${taskId}`, "--idempotency-key", "retry-key-terminal", "--reason", "operator requested retry", "--dry-run"];
|
||||
const human = await runAgentRunCommand(null, baseArgs);
|
||||
const humanText = renderedTextOf(human);
|
||||
expect(human.ok).toBe(false);
|
||||
expect(humanText).toContain(`queue task ${taskId} cannot be retried from state completed`);
|
||||
expect(humanText).not.toContain("x".repeat(500));
|
||||
expect(humanText.length).toBeLessThan(1800);
|
||||
|
||||
const json = await runAgentRunCommand(null, [...baseArgs, "-o", "json"]);
|
||||
const jsonText = renderedTextOf(json);
|
||||
const payload = JSON.parse(jsonText) as { ok?: boolean; failureKind?: string; message?: string; agentrun?: { details?: { diagnostic?: string } } };
|
||||
expect(json.ok).toBe(false);
|
||||
expect(renderedContentTypeOf(json)).toBe("application/json");
|
||||
expect(payload.failureKind).toBe("validation-failed");
|
||||
expect(payload.message?.length).toBeLessThanOrEqual(400);
|
||||
expect(payload.agentrun?.details?.diagnostic?.length).toBeLessThanOrEqual(400);
|
||||
expect(jsonText.length).toBeLessThan(5000);
|
||||
expect(requests).toBe(2);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
|
||||
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
|
||||
else process.env.HWLAB_API_KEY = previousKey;
|
||||
rmSync(tempConfigPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -385,8 +385,8 @@ export function agentRunHelpText(args: string[]): string {
|
||||
return [
|
||||
"Usage: bun scripts/cli.ts agentrun <verb> <resource> [options]",
|
||||
"",
|
||||
"Verbs: get, describe, events, logs, result, ack, cancel, dispatch, create, apply, send, explain",
|
||||
"Resources: task/qt, run, command/cmd, runnerjob/rjob, session/ses, aipodspec/aps",
|
||||
"Verbs: get, describe, events, logs, result, ack, cancel, retry, dispatch, create, apply, send, explain",
|
||||
"Resources: task/qt, attempt, run, command/cmd, runnerjob/rjob, session/ses, aipodspec/aps",
|
||||
"",
|
||||
"Common:",
|
||||
" bun scripts/cli.ts agentrun get tasks --queue commander --limit 20",
|
||||
@@ -394,6 +394,8 @@ export function agentRunHelpText(args: string[]): string {
|
||||
" bun scripts/cli.ts agentrun describe run/<runId> --node NC01 --lane nc01-v02",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
|
||||
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
|
||||
" bun scripts/cli.ts agentrun get attempts --task <taskId> --limit 20",
|
||||
" bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin",
|
||||
"",
|
||||
"Machine/debug output:",
|
||||
|
||||
@@ -57,9 +57,17 @@ export function agentRunGetKindHelp(kindRaw: string): string {
|
||||
export async function runAgentRunResourceCommand(config: UniDeskConfig | null, verb: AgentRunResourceVerb, action: string | undefined, actionArgs: string[], canonicalArgs: string[]): Promise<RenderedCliResult> {
|
||||
if (isHelpArg(action) || actionArgs.some(isHelpArg)) return renderAgentRunHelp(canonicalArgs);
|
||||
const resourceArgs = action === undefined ? actionArgs : [action, ...actionArgs];
|
||||
const options = parseResourceOptions(resourceArgs);
|
||||
const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs);
|
||||
const command = `agentrun ${canonicalArgs.join(" ")}`.trim();
|
||||
let options: AgentRunResourceOptions;
|
||||
try {
|
||||
options = parseResourceOptions(resourceArgs);
|
||||
} catch (error) {
|
||||
const validationError = error instanceof AgentRunRestError
|
||||
? error
|
||||
: new AgentRunRestError("validation-failed", error instanceof Error ? error.message : String(error));
|
||||
return renderAgentRunRestError(command, validationError, resourceErrorOutputOptions(resourceArgs));
|
||||
}
|
||||
const bridgeActionArgs = stripAgentRunResourceWrapperArgs(actionArgs);
|
||||
try {
|
||||
return await withAgentRunRestTarget(resolveAgentRunRestTarget(config, options), async () => {
|
||||
if (verb === "explain") return renderedCliResult(true, command, agentRunExplain(action ?? "task", resourceArgs, options));
|
||||
@@ -84,6 +92,21 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v
|
||||
return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`);
|
||||
}
|
||||
|
||||
function resourceErrorOutputOptions(args: string[]): AgentRunResourceOptions {
|
||||
const options = parseResourceOptions([]);
|
||||
options.raw = args.includes("--raw");
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
const value = arg === "-o" || arg === "--output"
|
||||
? args[index + 1]
|
||||
: arg.startsWith("--output=") || arg.startsWith("-o=")
|
||||
? arg.slice(arg.indexOf("=") + 1)
|
||||
: null;
|
||||
if (value === "json" || value === "yaml") options.output = value;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function stripAgentRunResourceWrapperArgs(args: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -224,7 +247,8 @@ export async function resourceGet(config: UniDeskConfig | null, command: string,
|
||||
result = await runAgentRunRestCommand(config, "sessions", ["ps", "--limit", String(options.limit)]);
|
||||
return renderResourceResult(command, result, options, "Session", normalizeSessionItems(innerData(result)).slice(0, options.limit));
|
||||
}
|
||||
if (kind === "attempt" && options.taskId !== null) {
|
||||
if (kind === "attempt") {
|
||||
if (options.taskId === null) throw new AgentRunRestError("validation-failed", "get attempts requires --task <taskId>");
|
||||
result = await runAgentRunRestCommand(config, "queue", [
|
||||
"attempts",
|
||||
options.taskId,
|
||||
@@ -255,17 +279,23 @@ export async function resourceGet(config: UniDeskConfig | null, command: string,
|
||||
}
|
||||
|
||||
export async function resourceRetry(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise<RenderedCliResult> {
|
||||
const ref = parseResourceRef(action, args, "task");
|
||||
if (ref.kind !== "task") throw new Error("retry supports task/<taskId>");
|
||||
const idempotencyKey = options.idempotencyKey ?? requiredContext("retry", "--idempotency-key <key>");
|
||||
const reason = options.reason ?? requiredContext("retry", "--reason <text>");
|
||||
if (action === undefined || action.startsWith("-")) throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
|
||||
let ref: AgentRunResourceRef;
|
||||
try {
|
||||
ref = parseResourceRef(action, args, "task");
|
||||
} catch {
|
||||
throw new AgentRunRestError("validation-failed", "retry requires task/<taskId>");
|
||||
}
|
||||
if (ref.kind !== "task") throw new AgentRunRestError("validation-failed", "retry supports task/<taskId>");
|
||||
if (options.idempotencyKey === null) throw new AgentRunRestError("validation-failed", "retry requires --idempotency-key <key>");
|
||||
if (options.reason === null) throw new AgentRunRestError("validation-failed", "retry requires --reason <text>");
|
||||
const result = await runAgentRunRestCommand(config, "queue", [
|
||||
"retry",
|
||||
ref.name,
|
||||
"--idempotency-key",
|
||||
idempotencyKey,
|
||||
options.idempotencyKey,
|
||||
"--reason",
|
||||
reason,
|
||||
options.reason,
|
||||
...(options.dryRun ? ["--dry-run"] : []),
|
||||
]);
|
||||
return renderQueueRetrySummary(command, result, options, ref.name);
|
||||
|
||||
@@ -1059,8 +1059,10 @@ export async function withAgentRunRestTarget<T>(target: AgentRunRestTarget | nul
|
||||
}
|
||||
|
||||
export function renderAgentRunRestError(command: string, error: AgentRunRestError, options: AgentRunResourceOptions): RenderedCliResult {
|
||||
const payload = error.toPayload(command);
|
||||
if (options.raw || options.output === "json" || options.output === "yaml") return renderMachine(command, payload, options.output === "yaml" ? "yaml" : "json", false);
|
||||
const rawPayload = error.toPayload(command);
|
||||
if (options.raw) return renderMachine(command, rawPayload, "json", false);
|
||||
const payload = boundedAgentRunRestErrorPayload(rawPayload);
|
||||
if (options.output === "json" || options.output === "yaml") return renderMachine(command, payload, options.output === "yaml" ? "yaml" : "json", false);
|
||||
const laneHint = record(record(payload.agentrun).laneAwareLookup);
|
||||
const currentEndpoint = record(laneHint.currentEndpoint);
|
||||
const nextCommands = Array.isArray(laneHint.nextCommands)
|
||||
@@ -1075,12 +1077,18 @@ export function renderAgentRunRestError(command: string, error: AgentRunRestErro
|
||||
` Config: ${String(currentEndpoint.configPath ?? "(unknown)")}`,
|
||||
` ${String(laneHint.summary ?? "The resource was not found on the currently configured AgentRun manager endpoint.")}`,
|
||||
];
|
||||
const validationHelp = `bun scripts/cli.ts agentrun ${command.split(/\s+/u)[1] ?? "--help"} --help`;
|
||||
const nextLines = nextCommands.length > 0
|
||||
? nextCommands
|
||||
: [
|
||||
"Check config/agentrun.yaml manager.baseUrl and the AgentRun API route.",
|
||||
"Verify HWLAB_API_KEY is present in env or in the configured auth.file without printing the key.",
|
||||
];
|
||||
: error.failureKind === "validation-failed"
|
||||
? [
|
||||
"Review the AgentRun validation message and correct the request; do not bypass the formal resource API.",
|
||||
validationHelp,
|
||||
]
|
||||
: [
|
||||
"Check config/agentrun.yaml manager.baseUrl and the AgentRun API route.",
|
||||
"Verify HWLAB_API_KEY is present in env or in the configured auth.file without printing the key.",
|
||||
];
|
||||
return renderedCliResult(false, command, [
|
||||
`Error: ${payload.failureKind}`,
|
||||
String(payload.message ?? ""),
|
||||
@@ -1091,6 +1099,23 @@ export function renderAgentRunRestError(command: string, error: AgentRunRestErro
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
export function boundedAgentRunRestErrorPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return boundedAgentRunRestErrorValue(payload, 0) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function boundedAgentRunRestErrorValue(value: unknown, depth: number): unknown {
|
||||
if (typeof value === "string") return truncateOneLine(value, 400);
|
||||
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (Array.isArray(value)) return value.slice(0, 12).map((item) => boundedAgentRunRestErrorValue(item, depth + 1));
|
||||
if (typeof value !== "object") return String(value);
|
||||
if (depth >= 5) return "[nested details omitted]";
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of entries.slice(0, 20)) result[key] = boundedAgentRunRestErrorValue(item, depth + 1);
|
||||
if (entries.length > 20) result.omittedFieldCount = entries.length - 20;
|
||||
return result;
|
||||
}
|
||||
|
||||
export class AgentRunRestError extends Error {
|
||||
readonly failureKind: AgentRunFailureKind;
|
||||
readonly bridge: Record<string, unknown> | null;
|
||||
|
||||
Reference in New Issue
Block a user