Merge pull request #1301 from pikasTech/fix-1287-evicted-session-status
fix: mark evicted agent sessions non-reusable
This commit is contained in:
@@ -2411,8 +2411,8 @@ function isThreadResumeFailedResult(result = {}) {
|
|||||||
result.agentRun?.providerTrace?.failureKind,
|
result.agentRun?.providerTrace?.failureKind,
|
||||||
result.agentRun?.failureKind
|
result.agentRun?.failureKind
|
||||||
].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-"));
|
].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-"));
|
||||||
return values.some((value) => value === "thread-resume-failed")
|
return values.some((value) => value === "thread-resume-failed" || value === "session-store-evicted")
|
||||||
|| /no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? ""));
|
|| /session stor(?:e|age).*evicted|pvc-backed session|no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? ""));
|
||||||
}
|
}
|
||||||
function threadResumeFailureThreadId(result = {}) {
|
function threadResumeFailureThreadId(result = {}) {
|
||||||
return boundedText(textOr(
|
return boundedText(textOr(
|
||||||
@@ -2425,7 +2425,7 @@ function threadResumeFailureThreadId(result = {}) {
|
|||||||
), 240) || null;
|
), 240) || null;
|
||||||
}
|
}
|
||||||
function terminalWorkbenchSessionStatus(result = {}) {
|
function terminalWorkbenchSessionStatus(result = {}) {
|
||||||
if (isThreadResumeFailedResult(result)) return "failed";
|
if (isThreadResumeFailedResult(result)) return "thread-resume-failed";
|
||||||
const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase();
|
const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase();
|
||||||
if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus;
|
if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus;
|
||||||
const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase();
|
const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase();
|
||||||
|
|||||||
@@ -807,7 +807,13 @@ function agentRunProjectIdForEnv(env = process.env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function shouldResetSessionAfterEviction(failureKind, failureMessage) {
|
async function shouldResetSessionAfterEviction(failureKind, failureMessage) {
|
||||||
return failureKind === "session-store-evicted" || /session store evicted/i.test(failureMessage ?? "");
|
return agentRunFailureRequiresFreshSession(failureKind, failureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunFailureRequiresFreshSession(failureKind, failureMessage) {
|
||||||
|
const kind = String(failureKind ?? "").trim().toLowerCase().replace(/_/gu, "-");
|
||||||
|
const message = String(failureMessage ?? "");
|
||||||
|
return kind === "session-store-evicted" || kind === "thread-resume-failed" || /session stor(?:e|age).*evicted|pvc-backed session|no rollout found for thread id|thread\/resume failed/iu.test(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newSessionIdAfterEviction(baseSessionId, traceId) {
|
function newSessionIdAfterEviction(baseSessionId, traceId) {
|
||||||
@@ -1456,14 +1462,16 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const partialContext = partialAgentRunContext(runnerTrace);
|
const partialContext = partialAgentRunContext(runnerTrace);
|
||||||
const resumableAfterFailure = !canceled && Boolean(safeOpaqueId(base.threadId ?? base.agentRun?.threadId));
|
const freshSessionRequired = !canceled && agentRunFailureRequiresFreshSession(code, message);
|
||||||
|
const failedSessionStatus = freshSessionRequired ? "thread-resume-failed" : "failed";
|
||||||
|
const resumableAfterFailure = !canceled && !freshSessionRequired && Boolean(safeOpaqueId(base.threadId ?? base.agentRun?.threadId));
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
status: canceled ? "canceled" : "failed",
|
status: canceled ? "canceled" : "failed",
|
||||||
canceled,
|
canceled,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"),
|
session: agentRunSessionSummary(base, canceled ? "canceled" : failedSessionStatus),
|
||||||
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: canceled ? undefined : resumableAfterFailure ? "failed-resumable" : "failed-requires-new-session" }),
|
sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: canceled ? undefined : freshSessionRequired ? "thread-resume-failed" : resumableAfterFailure ? "failed-resumable" : "failed-requires-new-session" }),
|
||||||
runner: agentRunRunnerSummary(base.agentRun),
|
runner: agentRunRunnerSummary(base.agentRun),
|
||||||
runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace,
|
runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace,
|
||||||
toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"),
|
toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"),
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export const CODE_AGENT_SESSION_LIFECYCLE_STATUSES = Object.freeze([
|
|||||||
"idle",
|
"idle",
|
||||||
"interrupted",
|
"interrupted",
|
||||||
"expired",
|
"expired",
|
||||||
"failed"
|
"failed",
|
||||||
|
"thread-resume-failed"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({
|
export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({
|
||||||
@@ -18,7 +19,7 @@ export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]);
|
const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]);
|
||||||
const TERMINAL_LIFECYCLE_STATUSES = new Set(["expired"]);
|
const TERMINAL_LIFECYCLE_STATUSES = new Set(["expired", "thread-resume-failed"]);
|
||||||
const RECOVERABLE_LIFECYCLE_STATUSES = new Set(["failed", "interrupted"]);
|
const RECOVERABLE_LIFECYCLE_STATUSES = new Set(["failed", "interrupted"]);
|
||||||
|
|
||||||
export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) {
|
export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) {
|
||||||
@@ -175,6 +176,13 @@ function lifecyclePresentation({ status, rawStatus, reused, newSession, degraded
|
|||||||
userMessage: "当前 Code Agent session 上一轮失败;可继续发送以尝试恢复同一 conversation/session/thread。"
|
userMessage: "当前 Code Agent session 上一轮失败;可继续发送以尝试恢复同一 conversation/session/thread。"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (status === "thread-resume-failed") {
|
||||||
|
return {
|
||||||
|
label: "会话存储已失效",
|
||||||
|
action: "new_session",
|
||||||
|
userMessage: "当前 Code Agent session/thread 的后端存储已失效;下一轮应从新的 session/thread 继续。"
|
||||||
|
};
|
||||||
|
}
|
||||||
if (status === "interrupted") {
|
if (status === "interrupted") {
|
||||||
return {
|
return {
|
||||||
label: rawStatus === "canceled" ? "会话已取消" : "会话已中断",
|
label: rawStatus === "canceled" ? "会话已取消" : "会话已中断",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test("code agent session registry creates, reuses, and expires sessions without
|
|||||||
assert.equal(describedCreating.recentSessions[0].sessionMode, "controlled-readonly-session-registry");
|
assert.equal(describedCreating.recentSessions[0].sessionMode, "controlled-readonly-session-registry");
|
||||||
assert.equal(describedCreating.recentSessions[0].workspace, "/workspace/hwlab");
|
assert.equal(describedCreating.recentSessions[0].workspace, "/workspace/hwlab");
|
||||||
assert.equal(describedCreating.recentSessions[0].secretMaterialStored, false);
|
assert.equal(describedCreating.recentSessions[0].secretMaterialStored, false);
|
||||||
assert.deepEqual(describedCreating.lifecycleStatuses, ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"]);
|
assert.deepEqual(describedCreating.lifecycleStatuses, ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed", "thread-resume-failed"]);
|
||||||
assert.equal(describedCreating.statusAliases.canceled, "interrupted");
|
assert.equal(describedCreating.statusAliases.canceled, "interrupted");
|
||||||
registry.release(first.session.sessionId, {
|
registry.release(first.session.sessionId, {
|
||||||
conversationId: "cnv_registry",
|
conversationId: "cnv_registry",
|
||||||
|
|||||||
@@ -1038,6 +1038,128 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cloud api AgentRun adapter marks evicted session storage as non-reusable", async () => {
|
||||||
|
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;
|
||||||
|
const send = (data) => {
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_agentrun_evicted" })}\n`);
|
||||||
|
};
|
||||||
|
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
|
||||||
|
return send({ id: "run_evicted", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_evicted/commands") {
|
||||||
|
return send({ id: "cmd_evicted", runId: "run_evicted", state: "queued" });
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_evicted/runner-jobs") {
|
||||||
|
return send({
|
||||||
|
action: "create-kubernetes-job",
|
||||||
|
runId: "run_evicted",
|
||||||
|
commandId: "cmd_evicted",
|
||||||
|
attemptId: "attempt_evicted",
|
||||||
|
runnerId: "runner_evicted",
|
||||||
|
namespace: "agentrun-v01",
|
||||||
|
jobName: "agentrun-v01-runner-evicted",
|
||||||
|
runner: { attemptId: "attempt_evicted", runnerId: "runner_evicted" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_evicted/events") {
|
||||||
|
return send({ items: [
|
||||||
|
{ id: "evt_evicted", runId: "run_evicted", seq: 1, type: "error", payload: { commandId: "cmd_evicted", 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-02T00:00:00.000Z" },
|
||||||
|
{ id: "evt_evicted_terminal", runId: "run_evicted", seq: 2, type: "terminal_status", payload: { commandId: "cmd_evicted", terminalStatus: "failed", failureKind: "session-store-evicted" }, createdAt: "2026-06-02T00:00:01.000Z" }
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_evicted/commands") {
|
||||||
|
return send({ items: [{ id: "cmd_evicted", runId: "run_evicted", state: "failed", terminalStatus: "failed", idempotencyKey: "trc_server-test-agentrun-evicted", payload: { traceId: "trc_server-test-agentrun-evicted" } }] });
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_evicted/commands/cmd_evicted/result") {
|
||||||
|
return send({
|
||||||
|
runId: "run_evicted",
|
||||||
|
commandId: "cmd_evicted",
|
||||||
|
attemptId: "attempt_evicted",
|
||||||
|
runnerId: "runner_evicted",
|
||||||
|
jobName: "agentrun-v01-runner-evicted",
|
||||||
|
namespace: "agentrun-v01",
|
||||||
|
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_evicted", conversationId: "cnv_evicted", threadId: "thread_evicted" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
response.writeHead(404, { "content-type": "application/json" });
|
||||||
|
response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_agentrun_evicted" })}\n`);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||||||
|
const agentRunPort = agentRunServer.address().port;
|
||||||
|
const ownerSessions = new Map([["ses_server-test-evicted", testAgentSessionRecord({
|
||||||
|
sessionId: "ses_server-test-evicted",
|
||||||
|
conversationId: "cnv_evicted",
|
||||||
|
threadId: "thread_evicted",
|
||||||
|
status: "idle"
|
||||||
|
})]]);
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
env: {
|
||||||
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
||||||
|
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
|
||||||
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
||||||
|
HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601",
|
||||||
|
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek",
|
||||||
|
HWLAB_ENVIRONMENT: "v02",
|
||||||
|
HWLAB_GITOPS_PROFILE: "v02"
|
||||||
|
},
|
||||||
|
accessController: {
|
||||||
|
required: false,
|
||||||
|
async authenticate() {
|
||||||
|
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
|
||||||
|
},
|
||||||
|
async recordAgentSessionOwner(input) {
|
||||||
|
const record = testAgentSessionRecord(input);
|
||||||
|
ownerSessions.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
async getAgentSession(sessionId) {
|
||||||
|
return ownerSessions.get(sessionId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { port } = server.address();
|
||||||
|
const traceId = "trc_server-test-agentrun-evicted";
|
||||||
|
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" },
|
||||||
|
body: JSON.stringify({ conversationId: "cnv_evicted", sessionId: "ses_server-test-evicted", threadId: "thread_evicted", message: "resume evicted session" })
|
||||||
|
});
|
||||||
|
assert.equal(submit.status, 202);
|
||||||
|
const payload = await pollAgentResult(port, traceId);
|
||||||
|
validateCodeAgentChatSchema(payload);
|
||||||
|
assert.equal(payload.status, "failed");
|
||||||
|
assert.equal(payload.error.code, "session-store-evicted");
|
||||||
|
assert.equal(payload.session.status, "thread-resume-failed");
|
||||||
|
assert.equal(payload.session.lifecycle.requiresNewSession, true);
|
||||||
|
assert.equal(payload.sessionReuse.threadId, "thread_evicted");
|
||||||
|
assert.equal(payload.sessionReuse.status, "thread-resume-failed");
|
||||||
|
assert.equal(payload.agentRun.reuseEligible, false);
|
||||||
|
assert.equal(payload.reuseEligible, false);
|
||||||
|
assert.equal(ownerSessions.get("ses_server-test-evicted")?.status, "thread-resume-failed");
|
||||||
|
} 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 AgentRun adapter maps minimax-m3 provider profile to AgentRun backend", async () => {
|
test("cloud api AgentRun adapter maps minimax-m3 provider profile to AgentRun backend", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const agentRunServer = createHttpServer(async (request, response) => {
|
const agentRunServer = createHttpServer(async (request, response) => {
|
||||||
|
|||||||
@@ -1644,6 +1644,8 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
function codeAgentOwnerStatusForResult(result = {}) {
|
function codeAgentOwnerStatusForResult(result = {}) {
|
||||||
|
const sessionStatus = textValue(result?.session?.status ?? result?.sessionSummary?.status ?? result?.sessionLifecycleStatus);
|
||||||
|
if (sessionStatus === "thread-resume-failed") return "thread-resume-failed";
|
||||||
if (result?.status === "completed") return "active";
|
if (result?.status === "completed") return "active";
|
||||||
if (result?.status === "canceled" || result?.status === "cancelled") return "canceled";
|
if (result?.status === "canceled" || result?.status === "cancelled") return "canceled";
|
||||||
return result?.status ?? "active";
|
return result?.status ?? "active";
|
||||||
|
|||||||
Reference in New Issue
Block a user