Merge pull request #1300 from pikasTech/fix-1287-session-resume
fix: allow failed code agent sessions to resume
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user