fix: return recovery action descriptors (#174)

Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
This commit is contained in:
Lyon
2026-06-12 01:20:02 +08:00
committed by GitHub
parent 1a9b6debbb
commit 216209ca95
11 changed files with 298 additions and 74 deletions
+33 -1
View File
@@ -422,7 +422,39 @@ async function runFailureCase(options: { client: ManagerClient; managerUrl: stri
}
const command = await options.client.get(`/api/v1/runs/${item.runId}/commands/${item.commandId}`) as { state?: string };
assert.equal(command.state, "failed", options.mode);
assertNoSecretLeak(events);
const envelope = await options.client.get(`/api/v1/runs/${item.runId}/commands/${item.commandId}/result`) as JsonRecord;
if (options.mode === "provider-503-terminal") {
const classification = envelope.terminalClassification as JsonRecord;
const liveness = envelope.liveness as JsonRecord;
const timeoutBudget = liveness.timeoutBudget as JsonRecord;
assert.equal(classification.category, "provider-failed");
assert.equal(classification.providerEvidence, "failure-kind");
assert.equal(classification.providerInterruptionKnown, true);
assert.equal(classification.failureKind, "provider-http-error");
assert.equal(liveness.phase, "terminal");
assert.equal(typeof liveness.lastEventAgeMs, "number");
assert.equal(timeoutBudget.timeoutKind, "idle");
assert.equal(typeof timeoutBudget.idleElapsedMs, "number");
assertRecoveryActionDescriptors(liveness.recoveryActions);
}
assertNoSecretLeak({ events, envelope });
}
function assertRecoveryActionDescriptors(value: unknown): void {
assert.ok(Array.isArray(value), "recoveryActions must be an array");
const text = JSON.stringify(value);
assert.equal(text.includes("./scripts/agentrun sessions"), false, "server recoveryActions must not expose old sessions CLI paths");
assert.equal(text.includes("./scripts/agentrun commands"), false, "server recoveryActions must not expose old commands CLI paths");
assert.equal(text.includes("bun scripts/cli.ts agentrun"), false, "server recoveryActions must not hardcode render-only client commands");
for (const item of value) {
const action = item as JsonRecord;
assert.equal(Object.prototype.hasOwnProperty.call(action, "command"), false, "recovery action must be a descriptor, not a rendered command string");
assert.equal(typeof action.action, "string");
assert.equal(typeof action.operation, "string");
assert.equal(typeof action.resourceKind, "string");
assert.equal(typeof action.resourceName, "string");
assert.equal(action.valuesPrinted, false);
}
}
function eventPayload(event: { payload: unknown }): JsonRecord {
+60 -3
View File
@@ -29,6 +29,25 @@ const selfTest: SelfTestCase = async (context: SelfTestContext) => {
assert.equal(assistantLive.phase, "waiting-model-output");
assert.equal(((assistantLive.lastActivity as JsonRecord).activityKind), "assistant-progress");
const retry = await createActiveRun(client, context, "timeout-liveness-provider-retry", 120_000);
await client.post(`/api/v1/runs/${retry.runId}/events`, { type: "error", payload: { commandId: retry.commandId, failureKind: "provider-stream-disconnected", willRetry: true, message: "provider stream disconnected; retrying" } });
const retryResult = await commandResult(client, retry);
const retryLive = retryResult.liveness as JsonRecord;
const retryClassification = retryResult.terminalClassification as JsonRecord;
assert.equal(retryLive.active, true);
assert.equal(retryClassification.category, "active-retry-interruption");
assert.equal(retryClassification.providerEvidence, "retry-event");
assert.equal(retryClassification.retryInterruptionObserved, true);
assert.equal(((retryLive.timeoutBudget as JsonRecord).state), "within-budget");
assert.equal(typeof retryLive.lastEventAgeMs, "number");
const retryCancelAction = (retryLive.recoveryActions as JsonRecord[]).find((action) => action.action === "cancel-command") as JsonRecord;
assert.equal(retryCancelAction.operation, "cancel");
assert.equal(retryCancelAction.resourceKind, "command");
assert.equal(retryCancelAction.resourceName, retry.commandId);
assert.equal(retryCancelAction.runId, retry.runId);
assert.equal(retryCancelAction.commandId, retry.commandId);
assertRecoveryActionDescriptors(retryLive.recoveryActions);
const inactive = await createActiveRun(client, context, "timeout-liveness-inactive", 40);
await sleep(36);
const inactiveLive = (await commandResult(client, inactive)).liveness as JsonRecord;
@@ -72,7 +91,21 @@ const selfTest: SelfTestCase = async (context: SelfTestContext) => {
assert.equal((noSessionLive.transportDisconnect as JsonRecord).sourceSeq, 4);
assert.equal((noSessionLive.recoveryActions as JsonRecord[]).some((action) => action.action === "continue-session"), false, "sessionId=null must not suggest session-only continuation");
assert.equal((noSessionLive.recoveryActions as JsonRecord[]).some((action) => action.action === "poll-output"), false, "sessionId=null must not suggest session output path");
assert.ok((noSessionLive.recoveryActions as JsonRecord[]).some((action) => action.action === "poll-trace" && String(action.command).includes("runs events")));
assert.ok((noSessionLive.recoveryActions as JsonRecord[]).some((action) => action.action === "poll-trace" && action.operation === "events" && action.resourceKind === "run" && action.resourceName === noSession.runId));
assertRecoveryActionDescriptors(noSessionLive.recoveryActions);
const manualCancel = await createActiveRun(client, context, "timeout-liveness-manual-command-cancel", 120_000);
await client.post(`/api/v1/commands/${manualCancel.commandId}/cancel`, { reason: "self-test manual command cancel" });
const manualCancelResult = await commandResult(client, manualCancel);
const manualCancelLive = manualCancelResult.liveness as JsonRecord;
const manualCancelClassification = manualCancelResult.terminalClassification as JsonRecord;
assert.equal(manualCancelResult.terminalStatus, "cancelled");
assert.equal(manualCancelResult.failureKind, "cancelled");
assert.equal(manualCancelLive.phase, "terminal");
assert.equal(manualCancelClassification.category, "cancelled");
assert.equal(manualCancelClassification.reason, "terminal status or failureKind is cancelled");
assert.ok((manualCancelLive.recoveryActions as JsonRecord[]).some((action) => action.action === "inspect-result" && action.operation === "result" && action.resourceKind === "command" && action.resourceName === manualCancel.commandId));
assertRecoveryActionDescriptors(manualCancelLive.recoveryActions);
const stale = await createActiveRun(client, context, "timeout-liveness-stale-claimed", 120_000, { session: false, leaseMs: 1 });
await store.saveRunnerJob({
@@ -98,12 +131,14 @@ const selfTest: SelfTestCase = async (context: SelfTestContext) => {
assert.equal(staleDiagnosis.runnerLost, true);
assert.equal(((staleDiagnosis.runnerJob as JsonRecord).phase), "created");
assert.equal(((staleDiagnosis.session as JsonRecord).sessionRefNull), true);
assertRecoveryActionDescriptors(staleDiagnosis.recoveryActions);
assert.ok(terminal.sessionId, "terminal fixture must have a session id");
const terminalSessionId = terminal.sessionId;
const session = await client.get(`/api/v1/sessions/${terminalSessionId}?readerId=timeout-liveness`) as JsonRecord;
assert.equal(((session.liveness as JsonRecord).phase), "terminal");
assert.ok(Array.isArray(((session.supervisor as JsonRecord).recoveryActions)), "session show must keep terminal recovery actions");
assertRecoveryActionDescriptors((session.supervisor as JsonRecord).recoveryActions);
const task = await client.post("/api/v1/queue/tasks", queueTask(context, terminalSessionId, 50)) as JsonRecord;
store.updateQueueTaskAttempt(String(task.id), {
@@ -117,16 +152,21 @@ const selfTest: SelfTestCase = async (context: SelfTestContext) => {
assert.equal((((commanderItem.supervisor as JsonRecord).diagnosis as JsonRecord).category), "execution-idle-timeout");
assert.equal((((commanderItem.supervisor as JsonRecord).timeoutBudget as JsonRecord).state), "timed-out");
assert.equal((((commanderItem.supervisor as JsonRecord).timeoutBudget as JsonRecord).timeoutKind), "idle");
assert.equal(typeof ((commanderItem.supervisor as JsonRecord).lastEventAgeMs), "number");
assert.equal(typeof ((commanderItem.supervisor as JsonRecord).leaseRemainingMs), "number");
const commanderSummary = summarizeQueueCommanderSnapshot(commander, { limit: 5 });
const summaryItem = ((commanderSummary.items as JsonRecord[]) ?? []).find((item) => item.id === task.id) as JsonRecord;
assert.equal(((summaryItem.supervisor as JsonRecord).phase), "terminal");
assert.equal((((summaryItem.supervisor as JsonRecord).terminalClassification as JsonRecord).category), "execution-idle-timeout");
assert.equal((((summaryItem.supervisor as JsonRecord).terminalClassification as JsonRecord).providerEvidence), "insufficient");
assert.equal(typeof ((summaryItem.supervisor as JsonRecord).lastEventAgeMs), "number");
assert.equal(typeof (((summaryItem.supervisor as JsonRecord).timeoutBudget as JsonRecord).idleElapsedMs), "number");
assertRecoveryActionDescriptors((summaryItem.supervisor as JsonRecord).recoveryActions);
assert.equal(JSON.stringify(commanderSummary).includes("hwpod workspace apply-patch"), false, "commander summary must stay compact and avoid dumping command bodies");
assert.equal(JSON.stringify(summaryItem).includes("fullRecordBytes"), false, "commander item must not add bookkeeping noise");
assertNoSecretLeak({ toolResult, assistantLive, inactiveLive, terminalResult, noSessionResult, staleResult, session, commanderSummary });
assertNoSecretLeak({ toolResult, assistantLive, retryResult, inactiveLive, terminalResult, noSessionResult, manualCancelResult, staleResult, session, commanderSummary });
return { name: "timeout-liveness", tests: ["tool-in-flight-liveness", "assistant-progress-liveness", "stdio-inactive-timeout-budget", "terminal-timeout-recovery", "no-session-drilldown", "terminal-classification", "queue-commander-supervisor", "diagnosis-visibility", "stale-claimed-runner-lost"] };
return { name: "timeout-liveness", tests: ["tool-in-flight-liveness", "assistant-progress-liveness", "active-provider-retry-summary", "stdio-inactive-timeout-budget", "terminal-timeout-recovery", "no-session-drilldown", "manual-command-cancel-summary", "terminal-classification", "queue-commander-supervisor", "diagnosis-visibility", "stale-claimed-runner-lost"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -188,4 +228,21 @@ function executionPolicy(timeoutMs: number, codexHome: string): JsonRecord {
};
}
function assertRecoveryActionDescriptors(value: unknown): void {
assert.ok(Array.isArray(value), "recoveryActions must be an array");
const text = JSON.stringify(value);
assert.equal(text.includes("./scripts/agentrun sessions"), false, "server recoveryActions must not expose old sessions CLI paths");
assert.equal(text.includes("./scripts/agentrun commands"), false, "server recoveryActions must not expose old commands CLI paths");
assert.equal(text.includes("bun scripts/cli.ts agentrun"), false, "server recoveryActions must not hardcode render-only client commands");
for (const item of value) {
const action = item as JsonRecord;
assert.equal(Object.prototype.hasOwnProperty.call(action, "command"), false, "recovery action must be a descriptor, not a rendered command string");
assert.equal(typeof action.action, "string");
assert.equal(typeof action.operation, "string");
assert.equal(typeof action.resourceKind, "string");
assert.equal(typeof action.resourceName, "string");
assert.equal(action.valuesPrinted, false);
}
}
export default selfTest;