fix: return recovery action descriptors (#174)
Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user