fix: retry evicted agentrun projection resume

This commit is contained in:
root
2026-06-29 17:17:42 +00:00
parent b2d2642b5b
commit 4ed8db2c00
5 changed files with 289 additions and 4 deletions
@@ -696,6 +696,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
const projectionState = await loadWorkbenchProjectionStateForAgentRun(options.runtimeStore, { traceId, agentRun: mapped.agentRun });
const projectionRetryParams = retryParams ?? projectionState?.retryParams ?? null;
const fetchPlan = buildAgentRunProjectionEventsFetchPlan({
projectionState,
agentRun: mapped.agentRun,
@@ -714,6 +715,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
eventsResponse,
resultSyncState: terminalFromEvents ? "pending" : null,
projectionStatus: terminalFromEvents ? "terminal" : null,
retryParams: projectionRetryParams,
now: options.now
});
const deferResultSync = agentRunResultSyncDeferred({ forceResultSync, options, env });
@@ -744,6 +746,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
eventsResponse,
resultSyncState: agentRunMetricStatusForError(error) === "timeout" ? "timed_out" : "failed",
projectionStatus: terminalFromEvents ? "terminal" : null,
retryParams: projectionRetryParams,
error,
now: options.now
});
@@ -761,7 +764,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
valuesPrinted: false
});
const freshSessionRetry = await retryAgentRunTurnAfterFreshSessionFailure({
params: retryParams,
params: projectionRetryParams,
options,
traceId,
traceStore,
@@ -783,6 +786,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
eventsResponse: null,
resultSyncState: null,
projectionStatus: "projecting",
retryParams: projectionRetryParams,
now: options.now
});
return { result: freshSessionRetry.payload, runnerTrace: freshSessionRetry.payload.runnerTrace ?? traceStore.snapshot(traceId), found: true, eventsRefreshed: Boolean(eventsResponse), resultSynced: false, freshSessionRetried: true };
@@ -797,6 +801,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
eventsResponse,
resultSyncState: "synced",
projectionStatus: isAgentRunTerminalStatus(payload?.status) ? "terminal" : null,
retryParams: projectionRetryParams,
now: options.now
});
return { result: payload, runnerTrace: payload.runnerTrace ?? traceStore.snapshot(traceId), found: true, eventsRefreshed: Boolean(eventsResponse), resultSynced: true };
+194
View File
@@ -3032,6 +3032,200 @@ test("cloud api resumes AgentRun projection from durable state after restart", a
}
});
test("cloud api projection resume retries evicted AgentRun session with stored prompt", async () => {
const calls = [];
const durableEvents = [];
const projectionStates = new Map();
const traceId = "trc_projection_resume_evicted_retry";
const runId = "run_projection_resume_evicted";
const commandId = "cmd_projection_resume_evicted";
const retryRunId = "run_projection_resume_evicted_retry";
const retryCommandId = "cmd_projection_resume_evicted_retry";
const sessionId = "ses_projection_resume_evicted";
const conversationId = "cnv_projection_resume_evicted";
const promptText = "resume projection evicted session with original prompt";
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
calls.push({ method: request.method, path: url.pathname, search: url.search, afterSeq: url.searchParams.get("afterSeq"), body });
const send = (data, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify({ ok: status < 400, data })}\n`);
};
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) {
return send({ items: [
{ id: "evt_projection_evicted_1", runId, seq: 1, type: "error", payload: { commandId, failureKind: "session-store-evicted", message: "codex app-server thread/resume reported no rollout found for PVC-backed session; session storage was likely evicted" }, createdAt: "2026-06-19T12:01:00.000Z" },
{ id: "evt_projection_evicted_2", runId, seq: 2, type: "terminal_status", payload: { commandId, terminalStatus: "failed", failureKind: "session-store-evicted" }, createdAt: "2026-06-19T12:01:01.000Z" }
] });
}
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) {
return send({
runId,
commandId,
status: "failed",
runStatus: "failed",
commandState: "failed",
terminalStatus: "failed",
failureKind: "session-store-evicted",
failureMessage: "codex app-server thread/resume reported no rollout found for PVC-backed session; session storage was likely evicted",
completed: false,
lastSeq: 2,
eventCount: 2,
sessionRef: { sessionId: "ses_agentrun_projection_resume_evicted", conversationId, threadId: "thread_projection_evicted" }
});
}
if (request.method === "POST" && url.pathname === "/api/v1/sessions") {
assert.match(body.sessionId, /-reset-/u);
return send({ sessionId: body.sessionId, backendProfile: body.backendProfile });
}
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
assert.match(body.sessionRef?.sessionId, /-reset-/u);
assert.equal(body.sessionRef?.threadId, undefined);
return send({ id: retryRunId, status: "pending", backendProfile: "dsflash-go", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
}
if (request.method === "POST" && url.pathname === `/api/v1/runs/${retryRunId}/commands`) {
assert.equal(body.type, "turn");
assert.equal(body.payload.prompt, promptText);
assert.equal(body.payload.message, promptText);
assert.equal(body.payload.threadId, null);
assert.match(body.payload.sessionId, /-reset-/u);
assert.equal(body.payload.providerProfile, "dsflash-go");
return send({ id: retryCommandId, runId: retryRunId, state: "queued" });
}
if (request.method === "POST" && url.pathname === `/api/v1/runs/${retryRunId}/runner-jobs`) {
return send({
action: "create-kubernetes-job",
runId: retryRunId,
commandId: retryCommandId,
attemptId: "attempt_projection_resume_evicted_retry",
runnerId: "runner_projection_resume_evicted_retry",
namespace: "agentrun-v01",
jobName: "agentrun-v01-projection-resume-evicted-retry",
runner: { attemptId: "attempt_projection_resume_evicted_retry", runnerId: "runner_projection_resume_evicted_retry" }
});
}
return send({ message: `unexpected ${request.method} ${url.pathname}` }, 500);
});
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
const agentRunPort = agentRunServer.address().port;
projectionStates.set(traceId, {
traceId,
sessionId,
conversationId,
threadId: "thread_projection_evicted",
ownerUserId: TEST_AGENT_ACTOR.id,
ownerRole: TEST_AGENT_ACTOR.role,
runId,
commandId,
sourceRunId: runId,
sourceCommandId: commandId,
managerUrl: `http://127.0.0.1:${agentRunPort}`,
backendProfile: "dsflash-go",
providerId: "JD01",
lastSourceSeq: 0,
lastAgentRunSeq: 0,
lastProjectedSeq: 0,
sourceLatestSeq: 0,
upstreamLatestSeq: 0,
projectionStatus: "projecting",
projectionHealth: "healthy",
resultSyncState: "not_started",
retryParams: {
message: promptText,
prompt: promptText,
text: promptText,
sessionId,
conversationId,
threadId: "thread_projection_evicted",
ownerUserId: TEST_AGENT_ACTOR.id,
ownerRole: TEST_AGENT_ACTOR.role,
providerProfile: "dsflash-go",
codeAgentProviderProfile: "dsflash-go",
backendProfile: "dsflash-go",
valuesPrinted: false
},
createdAt: "2026-06-19T12:00:00.000Z",
updatedAt: "2026-06-19T12:00:00.000Z",
valuesPrinted: false
});
const runtimeStore = {
async queryAgentTraceEvents(params = {}) {
return { events: durableEvents.filter((event) => event.traceId === params.traceId), count: durableEvents.length };
},
async writeAgentTraceEvent(params = {}) {
const event = params.event ?? params.traceEvent ?? params;
durableEvents.push(event);
return { written: true, traceEvent: event };
},
async getWorkbenchProjectionState(params = {}) {
const projectionState = projectionStates.get(params.traceId) ?? null;
return { projectionState, found: Boolean(projectionState) };
},
async queryWorkbenchProjectionStates() {
return { states: [...projectionStates.values()], count: projectionStates.size };
},
async writeWorkbenchProjectionState(params = {}) {
const projectionState = params.projectionState ?? params.state ?? params;
projectionStates.set(projectionState.traceId, projectionState);
return { written: true, projectionState };
}
};
const accessController = {
required: false,
store: {
async listAgentSessionsForUser() { return []; },
async getAgentSessionByTraceId() { return null; }
},
async authenticate() { return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; }
};
const server = createCloudApiServer({
accessController,
runtimeStore,
codeAgentChatResults: new Map(),
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "a".repeat(40),
HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "JD01",
HWLAB_RUNTIME_NAMESPACE: "hwlab-v03",
HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE: "dsflash-go",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS: "0",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS: "0",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE: "10"
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
for (let index = 0; index < 100 && !calls.some((call) => call.path === `/api/v1/runs/${retryRunId}/commands`); index += 1) await delay(10);
assert.equal(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${commandId}/result`), true);
assert.equal(calls.some((call) => call.path === `/api/v1/runs/${retryRunId}/commands`), true);
const state = projectionStates.get(traceId);
assert.equal(state.runId, retryRunId);
assert.equal(state.commandId, retryCommandId);
assert.equal(state.sourceRunId, retryRunId);
assert.equal(state.sourceCommandId, retryCommandId);
assert.equal(state.projectionStatus, "projecting");
assert.equal(state.retryParams.message, promptText);
assert.equal(state.retryParams.providerProfile, "dsflash-go");
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await new Promise((resolve, reject) => {
agentRunServer.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api trace skips AgentRun refresh when terminal snapshot is already complete (#1422)", async () => {
const calls = [];
const traceId = "trc_issue1422_trace_terminal_snapshot";
+29 -1
View File
@@ -1683,9 +1683,11 @@ function agentRunProjectionResumeCandidate(session = {}, record = {}, { minAgeMs
const threadId = safeOpaqueId(record.threadId ?? session.threadId) || null;
const ownerUserId = textValue(record.ownerUserId ?? session.ownerUserId);
const ownerRole = textValue(record.ownerRole ?? session.ownerRole) || "user";
const retryParams = agentRunProjectionRetryParams(record.retryParams ?? record.projectionRetryParams ?? session.retryParams);
return {
traceId,
params: {
...retryParams,
traceId,
sessionId,
conversationId,
@@ -1731,9 +1733,11 @@ function agentRunProjectionResumeCandidateFromState(state = {}, { minAgeMs = DEF
const ownerUserId = textValue(state.ownerUserId);
const ownerRole = textValue(state.ownerRole) || "user";
const lastSeq = Number.isFinite(Number(state.lastSourceSeq ?? state.lastAgentRunSeq)) ? Number(state.lastSourceSeq ?? state.lastAgentRunSeq) : 0;
const retryParams = agentRunProjectionRetryParams(state.retryParams ?? state.projectionRetryParams);
return {
traceId,
params: {
...retryParams,
traceId,
sessionId,
conversationId,
@@ -1772,6 +1776,29 @@ function agentRunProjectionResumeCandidateFromState(state = {}, { minAgeMs = DEF
};
}
function agentRunProjectionRetryParams(value = null) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const message = textValue(value.message ?? value.prompt ?? value.text);
if (!message) return {};
const result = {
message,
prompt: message,
text: message,
projectId: textValue(value.projectId) || undefined,
ownerUserId: textValue(value.ownerUserId) || undefined,
ownerRole: textValue(value.ownerRole) || undefined,
providerProfile: textValue(value.providerProfile ?? value.codeAgentProviderProfile ?? value.backendProfile) || undefined,
codeAgentProviderProfile: textValue(value.codeAgentProviderProfile ?? value.providerProfile ?? value.backendProfile) || undefined,
backendProfile: textValue(value.backendProfile ?? value.providerProfile ?? value.codeAgentProviderProfile) || undefined,
providerId: textValue(value.providerId) || undefined,
valuesPrinted: false
};
for (const [key, item] of Object.entries(result)) {
if (item === undefined || item === "") delete result[key];
}
return result;
}
async function resumeAgentRunProjectionCandidate({ candidate, options = {}, traceStore = defaultCodeAgentTraceStore } = {}) {
const { currentResult } = await agentRunProjectionResumeResultWithCursor(candidate, options);
const resumeOptions = { ...options, deferAgentRunResultSync: false };
@@ -1781,7 +1808,8 @@ async function resumeAgentRunProjectionCandidate({ candidate, options = {}, trac
options: resumeOptions,
traceStore,
forceResultSync: true,
refreshEvents: true
refreshEvents: true,
retryParams: candidate.params
});
const payload = codeAgentPayloadWithObservedTerminalStatus(synced?.result ?? currentResult, synced?.runnerTrace ?? traceStore?.snapshot?.(candidate.traceId)) ?? (synced?.result ?? currentResult);
if (isTraceCommandTerminalStatus(payload?.status)) {
@@ -80,3 +80,32 @@ test("AgentRun projection state advances from incremental events response", () =
assert.equal(state.createdAt, "2026-06-19T12:00:00.000Z");
assert.equal(state.updatedAt, "2026-06-19T12:01:00.000Z");
});
test("AgentRun projection state preserves retry params for background resume", () => {
const state = buildWorkbenchProjectionStateUpdate({
currentState: {
traceId: "trc_cursor_retry_params",
sourceRunId: "run_cursor_retry_params",
sourceCommandId: "cmd_cursor_retry_params",
retryParams: {
message: "preserve this prompt for fresh-session retry",
sessionId: "ses_cursor_retry",
providerProfile: "dsflash-go",
valuesPrinted: false
},
createdAt: "2026-06-19T12:00:00.000Z"
},
agentRun: {
runId: "run_cursor_retry_params",
commandId: "cmd_cursor_retry_params",
lastSeq: 3
},
now: () => "2026-06-19T12:01:00.000Z"
});
assert.equal(state.retryParams.message, "preserve this prompt for fresh-session retry");
assert.equal(state.retryParams.prompt, "preserve this prompt for fresh-session retry");
assert.equal(state.retryParams.sessionId, "ses_cursor_retry");
assert.equal(state.retryParams.providerProfile, "dsflash-go");
assert.equal(state.retryParams.valuesPrinted, false);
});
+31 -2
View File
@@ -31,6 +31,7 @@ export function buildWorkbenchProjectionStateUpdate({
resultSyncState = null,
projectionStatus = null,
projectionHealth = null,
retryParams = null,
error = null,
now = () => new Date().toISOString()
} = {}) {
@@ -38,8 +39,8 @@ export function buildWorkbenchProjectionStateUpdate({
const mapping = normalizeAgentRunMapping(agentRun ?? result?.agentRun);
const timestamp = timestampValue(now);
const traceId = textValue(previous.traceId ?? result?.traceId ?? mapping.traceId);
const runId = textValue(previous.sourceRunId ?? previous.runId ?? mapping.runId);
const commandId = textValue(previous.sourceCommandId ?? previous.commandId ?? mapping.commandId);
const runId = textValue(mapping.runId) || textValue(previous.sourceRunId ?? previous.runId);
const commandId = textValue(mapping.commandId) || textValue(previous.sourceCommandId ?? previous.commandId);
const lastSourceSeq = Math.max(
maxNonNegativeInteger(previous.lastSourceSeq, previous.lastAgentRunSeq),
maxNonNegativeInteger(mapping.lastSeq),
@@ -50,6 +51,7 @@ export function buildWorkbenchProjectionStateUpdate({
maxNonNegativeInteger(eventsResponse?.maxSeq),
lastSourceSeq
);
const nextRetryParams = normalizeProjectionRetryParams(retryParams) ?? normalizeProjectionRetryParams(previous.retryParams);
const nextResultSyncState = textValue(resultSyncState ?? previous.resultSyncState) || "not_started";
const nextProjectionStatus = textValue(projectionStatus) || inferProjectionStatus({ lastSourceSeq, sourceLatestSeq, resultSyncState: nextResultSyncState, previous });
const nextProjectionHealth = textValue(projectionHealth) || (error ? "degraded" : "healthy");
@@ -82,6 +84,7 @@ export function buildWorkbenchProjectionStateUpdate({
lastErrorMessage: error ? textValue(error.message ?? "Projection sync failed.") : null,
failureCount: error ? maxNonNegativeInteger(previous.failureCount) + 1 : 0,
nextRetryAt: error ? previous.nextRetryAt ?? null : null,
retryParams: nextRetryParams,
createdAt: previous.createdAt ?? timestamp,
updatedAt: timestamp,
valuesPrinted: false
@@ -104,6 +107,32 @@ function normalizeAgentRunMapping(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function normalizeProjectionRetryParams(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const message = textValue(value.message ?? value.prompt ?? value.text);
if (!message) return null;
const result = {
message,
prompt: message,
text: message,
sessionId: textValue(value.sessionId) || null,
conversationId: textValue(value.conversationId) || null,
threadId: textValue(value.threadId) || null,
projectId: textValue(value.projectId) || null,
ownerUserId: textValue(value.ownerUserId) || null,
ownerRole: textValue(value.ownerRole) || null,
providerProfile: textValue(value.providerProfile ?? value.codeAgentProviderProfile ?? value.backendProfile) || null,
codeAgentProviderProfile: textValue(value.codeAgentProviderProfile ?? value.providerProfile ?? value.backendProfile) || null,
backendProfile: textValue(value.backendProfile ?? value.providerProfile ?? value.codeAgentProviderProfile) || null,
providerId: textValue(value.providerId) || null,
valuesPrinted: false
};
for (const [key, item] of Object.entries(result)) {
if (item === null || item === "") delete result[key];
}
return result;
}
function inferProjectionStatus({ lastSourceSeq, sourceLatestSeq, resultSyncState, previous }) {
if (["pending", "timed_out", "failed"].includes(resultSyncState)) return "terminal";
if (sourceLatestSeq > lastSourceSeq) return "projecting";