From 43b79b6c7e80b1978dbe2dea76896bd62fb3b36b Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 03:24:33 +0800 Subject: [PATCH] fix: allow failed code agent sessions to resume --- internal/cloud/code-agent-agentrun-adapter.ts | 7 +-- .../cloud/code-agent-session-lifecycle.ts | 9 ++-- .../cloud/code-agent-session-registry.test.ts | 46 ++++++++++--------- internal/cloud/code-agent-session-registry.ts | 2 +- internal/cloud/codex-stdio-session-helpers.ts | 5 +- internal/cloud/codex-stdio-session.ts | 17 ------- internal/cloud/server-agent-chat.test.ts | 7 +-- internal/cloud/server-code-agent-http.ts | 2 +- tools/src/hwlab-cli-lib.ts | 2 +- 9 files changed, 43 insertions(+), 54 deletions(-) diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index a1cb8f12..ce6d5d53 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1456,13 +1456,14 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a }); } const partialContext = partialAgentRunContext(runnerTrace); + const resumableAfterFailure = !canceled && Boolean(safeOpaqueId(base.threadId ?? base.agentRun?.threadId)); return { ...base, status: canceled ? "canceled" : "failed", canceled, updatedAt: now, session: agentRunSessionSummary(base, canceled ? "canceled" : "failed"), - sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: canceled ? undefined : "failed-requires-new-session" }), + sessionReuse: agentRunSessionReuseSummary(base, base.agentRun.reused === true, { status: canceled ? undefined : resumableAfterFailure ? "failed-resumable" : "failed-requires-new-session" }), runner: agentRunRunnerSummary(base.agentRun), runnerTrace: partialContext ? { ...runnerTrace, partialContext } : runnerTrace, toolCalls: agentRunToolCalls(result, canceled ? "canceled" : "failed"), @@ -1489,8 +1490,8 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a route: "/v1/agent/chat", toolName: "agentrun.manual-dispatch" }, - agentRun: { ...base.agentRun, terminalStatus, completed: false, reuseEligible: false, providerTrace, valuesPrinted: false }, - reuseEligible: false, + agentRun: { ...base.agentRun, terminalStatus, completed: false, reuseEligible: resumableAfterFailure, providerTrace, valuesPrinted: false }, + reuseEligible: resumableAfterFailure, ...(partialContext ? { partialContext } : {}), valuesPrinted: false }; diff --git a/internal/cloud/code-agent-session-lifecycle.ts b/internal/cloud/code-agent-session-lifecycle.ts index ba3d1841..1b30e483 100644 --- a/internal/cloud/code-agent-session-lifecycle.ts +++ b/internal/cloud/code-agent-session-lifecycle.ts @@ -18,7 +18,8 @@ export const CODE_AGENT_SESSION_STATUS_ALIASES = Object.freeze({ }); const ACTIVE_LIFECYCLE_STATUSES = new Set(["creating", "ready", "busy", "idle"]); -const TERMINAL_LIFECYCLE_STATUSES = new Set(["interrupted", "expired", "failed"]); +const TERMINAL_LIFECYCLE_STATUSES = new Set(["expired"]); +const RECOVERABLE_LIFECYCLE_STATUSES = new Set(["failed", "interrupted"]); export function normalizeCodeAgentSessionLifecycleStatus(value, { fallback = null } = {}) { const text = String(value ?? "").trim().toLowerCase(); @@ -75,7 +76,7 @@ export function codeAgentSessionLifecycleSummary(input = {}) { (gateBlocked && !concreteLifecycleBlock); if ((fallbackUsed || degraded) && !hasSession) status = "failed"; const requiresNewSession = status ? TERMINAL_LIFECYCLE_STATUSES.has(status) : degraded || fallbackUsed; - const reusable = status ? ACTIVE_LIFECYCLE_STATUSES.has(status) && status !== "creating" && degraded !== true : false; + const reusable = status ? (ACTIVE_LIFECYCLE_STATUSES.has(status) || RECOVERABLE_LIFECYCLE_STATUSES.has(status)) && status !== "creating" && degraded !== true : false; const newSession = reused === false || (hasSession && !reused && !requiresNewSession); const reasonCode = firstText( input.degradationReasonCode, @@ -170,8 +171,8 @@ function lifecyclePresentation({ status, rawStatus, reused, newSession, degraded if (status === "failed") { return { label: "会话失败", - action: "new_session", - userMessage: "当前 Code Agent session 已失败;需要显式创建新 session 后再继续。" + action: "retry", + userMessage: "当前 Code Agent session 上一轮失败;可继续发送以尝试恢复同一 conversation/session/thread。" }; } if (status === "interrupted") { diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index c7749447..91e4f2b5 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -324,7 +324,7 @@ test("code agent session registry reports busy sessions as structured blocker", assert.equal(busy.session.currentTraceId, "trc_busy_1"); }); -test("code agent session registry blocks failed and interrupted sessions", () => { +test("code agent session registry reuses failed and interrupted sessions", () => { const failedRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_failed" }); @@ -346,13 +346,14 @@ test("code agent session registry blocks failed and interrupted sessions", () => traceId: "trc_failed_2", now: "2026-05-23T00:00:01.000Z" }); - assert.equal(failed.ok, false); - assert.equal(failed.code, "session_failed"); + assert.equal(failed.ok, true); + assert.equal(failed.reused, true); assert.equal(failed.session.sessionId, "ses_failed"); - assert.equal(failed.session.status, "failed"); - assert.equal(failed.session.lifecycleStatus, "failed"); + assert.equal(failed.session.status, "busy"); + assert.equal(failed.session.lifecycleStatus, "busy"); assert.equal(failed.session.lastTraceId, "trc_failed_2"); - assert.equal(failed.session.lifecycle.requiresNewSession, true); + assert.equal(failed.session.currentTraceId, "trc_failed_2"); + assert.equal(failed.session.lifecycle.requiresNewSession, false); const interruptedRegistry = createCodeAgentSessionRegistry({ idFactory: () => "ses_interrupted" @@ -375,10 +376,11 @@ test("code agent session registry blocks failed and interrupted sessions", () => traceId: "trc_interrupted_2", now: "2026-05-23T00:00:01.000Z" }); - assert.equal(interrupted.ok, false); - assert.equal(interrupted.code, "session_interrupted"); - assert.equal(interrupted.session.status, "interrupted"); - assert.equal(interrupted.session.lifecycleStatus, "interrupted"); + assert.equal(interrupted.ok, true); + assert.equal(interrupted.reused, true); + assert.equal(interrupted.session.status, "busy"); + assert.equal(interrupted.session.lifecycleStatus, "busy"); + assert.equal(interrupted.session.currentTraceId, "trc_interrupted_2"); }); test("Codex stdio readiness blocker does not use read-only session fallback", async () => { @@ -492,7 +494,7 @@ test("stale read-only conversation facts do not mask Codex stdio blockers", asyn assert.equal(payload.provider === "openai-responses", false); }); -test("expired busy and failed sessions are blockers", async () => { +test("expired and busy sessions are blockers while failed sessions are reusable", async () => { const expiredRegistry = createCodeAgentSessionRegistry({ idleTimeoutMs: 10, idFactory: () => "ses_expired_chat" @@ -645,12 +647,12 @@ test("expired busy and failed sessions are blockers", async () => { traceId: "trc_failed_request", now: "2026-05-23T00:04:01.000Z" }); - assert.equal(failedAcquire.ok, false); - assert.equal(failedAcquire.code, "session_failed"); + assert.equal(failedAcquire.ok, true); + assert.equal(failedAcquire.reused, true); assert.equal(failedAcquire.session.sessionId, "ses_failed_chat"); - assert.equal(failedAcquire.session.status, "failed"); - assert.equal(failedAcquire.session.lifecycleStatus, "failed"); - assert.equal(failedAcquire.session.lifecycle.requiresNewSession, true); + assert.equal(failedAcquire.session.status, "busy"); + assert.equal(failedAcquire.session.lifecycleStatus, "busy"); + assert.equal(failedAcquire.session.lifecycle.requiresNewSession, false); const failedFacts = failedRegistry.getConversationFacts("cnv_failed_chat"); assert.equal(failedFacts.conversationId, "cnv_failed_chat"); assert.equal(failedFacts.sessionId, "ses_failed_chat"); @@ -1866,7 +1868,7 @@ test("Codex stdio skills discovery returns structured blocker when manifests are } }); -test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => { +test("Codex stdio runner startup failure marks session failed but keeps the session resumable", async () => { const fakeCodex = await createFakeCodexCommand(); const codexHome = await prepareFakeCodexHome(); const manager = createCodexStdioSessionManager({ @@ -1918,9 +1920,9 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li assert.equal(payload.session.status, "failed"); assert.equal(payload.session.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.session.statusReason, "codex_stdio_failed"); - assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.equal(payload.longLivedSessionGate.pass, false); - assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed")); + assert.equal(payload.longLivedSessionGate.status, "pass"); + assert.equal(payload.longLivedSessionGate.pass, true); + assert.equal(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed"), false); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(payload.runnerTrace.sessionStatus, "failed"); assert.equal(payload.runnerTrace.valuesPrinted, false); @@ -2290,8 +2292,8 @@ test("Codex app-server failed turn is not wrapped as completed assistant output" assert.equal(payload.error.code, "codex_stdio_failed"); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(payload.session.status, "failed"); - assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed")); + assert.equal(payload.longLivedSessionGate.status, "pass"); + assert.equal(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed"), false); assert.equal(payload.error.message.includes("sk-test-secret"), false); assert.equal(payload.error.message.includes("TOKEN=abc123"), false); assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); diff --git a/internal/cloud/code-agent-session-registry.ts b/internal/cloud/code-agent-session-registry.ts index 5bbc30fa..fa7844d4 100644 --- a/internal/cloud/code-agent-session-registry.ts +++ b/internal/cloud/code-agent-session-registry.ts @@ -84,7 +84,7 @@ export function createCodeAgentSessionRegistry(options = {}) { }); } - if (session && ["interrupted", "canceled", "failed", "error", "timeout", "expired"].includes(session.status)) { + if (session && ["expired"].includes(session.status)) { session.updatedAt = timestamp; session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; return blockedAcquire({ diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index 8b50e292..64c84916 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -212,6 +212,7 @@ export function longLivedSessionGate({ const normalizedRunnerKind = String(runnerKind ?? "").trim(); const normalizedSessionMode = String(sessionMode ?? "").trim(); const normalizedImplementation = String(implementationType ?? "").trim(); + const resumableSessionStatuses = ["idle", "ready", "busy", "failed", "interrupted", "timeout", "error", "canceled", "cancelled"]; const blockers = []; if (normalizedProvider === "openai-responses" || normalizedRunnerKind === "openai-responses-fallback") { blockers.push({ @@ -234,7 +235,7 @@ export function longLivedSessionGate({ summary: "This response is backed by a reusable controlled read-only session registry, not Codex stdio or an equivalent full Code Agent protocol adapter." }); } - if (session && !["idle", "ready", "busy"].includes(session.status)) { + if (session && !resumableSessionStatuses.includes(session.status)) { blockers.push({ code: `session_${session.status || "inactive"}`, sourceIssue: "pikasTech/HWLAB#317", @@ -260,7 +261,7 @@ export function longLivedSessionGate({ session?.codexStdio === true && session?.writeCapable === true && session?.durable === true && - ["idle", "ready", "busy"].includes(session?.status) && + resumableSessionStatuses.includes(session?.status) && feasible && blockers.length === 0; const feasiblePass = diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index f25f11d9..d8902935 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -1206,23 +1206,6 @@ export function createCodexStdioSessionManager(options = {}) { } } - if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status) && !requestedSessionId && mappedSessionId) { - conversations.delete(conversationId); - effectiveSessionId = requiredId(idFactory(), "ses"); - session = null; - reused = false; - } - - if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) { - return blockedAcquire({ - code: `session_${session.status}`, - summary: `Codex stdio session ${effectiveSessionId} is ${session.status}; create a new session before retrying.`, - session, - timestamp, - traceId: params.traceId - }); - } - if (!session) { session = { sessionId: effectiveSessionId, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 6bb80d33..6aed353a 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1023,10 +1023,11 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result assert.equal(payload.blocker.category, "provider_invalid_tool_call"); assert.match(payload.blocker.summary, /invalid function arguments json string/u); assert.equal(payload.session.status, "failed"); - assert.equal(payload.session.lifecycle.requiresNewSession, true); + assert.equal(payload.session.lifecycle.requiresNewSession, false); assert.equal(payload.sessionReuse.threadId, "thread_invalid_tool"); - assert.equal(payload.agentRun.reuseEligible, false); - assert.equal(payload.reuseEligible, false); + assert.equal(payload.sessionReuse.status, "failed-resumable"); + assert.equal(payload.agentRun.reuseEligible, true); + assert.equal(payload.reuseEligible, true); const serializedPayload = JSON.stringify(payload); assert.equal(serializedPayload.includes("repo-owned-codex"), false); assert.equal(serializedPayload.includes("codex-app-server-stdio"), false); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 3feaaa33..2f69ed5c 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -421,7 +421,7 @@ function publicManualAgentSession(session) { function manualSessionUsable(status) { const value = String(status ?? "").trim().toLowerCase().replace(/_/gu, "-"); - return !["failed", "timeout", "canceled", "cancelled", "error", "blocked", "expired", "interrupted", "stale", "thread-resume-failed"].includes(value); + return !["blocked", "expired", "stale", "thread-resume-failed"].includes(value); } function manualSessionErrorPayload({ code, message, reason = null, traceId = null, sessionId = null, session = null, retryable = true, nextCommands = undefined } = {}) { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index e8724035..ab92569f 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1928,7 +1928,7 @@ async function resolveAgentReplayContext(context: any) { const threadId = text(body.query?.threadId) || text(body.session?.threadId) || text(body.conversationFacts?.threadId); const sessionStatus = text(body.session?.status ?? body.conversationFacts?.sessionStatus ?? body.runnerTrace?.sessionStatus ?? body.status); if (isCodeAgentSessionUnusableStatus(sessionStatus)) { - throw cliError("session_not_usable", "client agent send --from-trace refuses to reuse a failed/stale Code Agent session; create/select a new session first", { + throw cliError("session_not_usable", "client agent send --from-trace refuses to reuse a blocked/stale Code Agent session; create/select a new session first", { fromTrace: traceId, conversationId: conversationId || null, sessionId: sessionId || null,