Merge pull request #1300 from pikasTech/fix-1287-session-resume

fix: allow failed code agent sessions to resume
This commit is contained in:
Lyon
2026-06-16 03:26:41 +08:00
committed by GitHub
9 changed files with 43 additions and 54 deletions
@@ -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
};
@@ -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") {
@@ -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");
@@ -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({
@@ -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 =
-17
View File
@@ -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,
+4 -3
View File
@@ -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);
+1 -1
View File
@@ -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 } = {}) {
+1 -1
View File
@@ -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,