From 43a6059b783ab578c36724b1f27e28c1bb1eefe3 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 23 May 2026 23:20:30 +0800 Subject: [PATCH] fix: block failed codex stdio sessions Tighten Code Agent long-lived session gate so failed/interrupted/expired Codex stdio sessions remain blocked, and surface missing stdio tools in structured blocker payloads. --- internal/cloud/code-agent-chat.mjs | 1 + .../code-agent-session-registry.test.mjs | 100 ++++++++++++++++++ internal/cloud/codex-stdio-session.mjs | 10 +- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 123c879c..5fd1e846 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -773,6 +773,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI blockers: error.blockers ?? availability.blockers, route: null, toolName: error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session", + missingTools: error.missingTools, availability: describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 910b42ef..423e47f0 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -787,3 +787,103 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with assert.equal(calls[1].name, "codex-reply"); assert.equal(calls[1].args.threadId, "thread_stdio_ready"); }); + +test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => { + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_start_failed", + createRpcClient: async () => ({ + async initialize() { + throw new Error("MCP initialize failed with sk-test-secret and TOKEN=abc123"); + }, + close() {} + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + }; + + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_start_failed", + traceId: "trc_stdio_start_failed", + message: "启动后回答" + }, + { + now: () => "2026-05-23T00:08:00.000Z", + codexStdioManager: manager, + env + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.error.code, "codex_stdio_failed"); + assert.equal(payload.session.sessionId, "ses_stdio_start_failed"); + assert.equal(payload.session.status, "failed"); + 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.capabilityLevel, "blocked"); + assert.equal(payload.runnerTrace.sessionStatus, "failed"); + assert.equal(payload.runnerTrace.valuesPrinted, false); + const serialized = JSON.stringify(payload); + assert.equal(serialized.includes("sk-test-secret"), false); + assert.equal(serialized.includes("TOKEN=abc123"), false); + assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); +}); + +test("Codex stdio protocol missing tools is a structured blocker, not a passed session", async () => { + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_missing_tool", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex"] }; + }, + async listTools() { + return ["codex"]; + }, + close() {} + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + }; + + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_missing_tool", + traceId: "trc_stdio_missing_tool", + message: "需要长会话" + }, + { + now: () => "2026-05-23T00:08:30.000Z", + codexStdioManager: manager, + env + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "codex_stdio_protocol_blocked"); + assert.deepEqual(payload.error.missingTools, ["codex-reply"]); + 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.availability.codexStdio.ready, true); + assert.deepEqual(payload.availability.codexStdio.protocol.toolsObserved, ["codex"]); + assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); +}); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index b78a52bb..dc6eff17 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -286,7 +286,7 @@ export function createCodexStdioSessionManager(options = {}) { }) ?? session; if (error.code && error.code.startsWith("codex_stdio")) { error.session = session; - error.availability = error.availability ?? availability; + error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox }); error.runnerTrace = runnerTrace({ traceId, workspace, @@ -691,6 +691,13 @@ export function longLivedSessionGate({ summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter." }); } + if (session && !["idle", "ready", "busy"].includes(session.status)) { + blockers.push({ + code: `session_${session.status || "inactive"}`, + sourceIssue: "pikasTech/HWLAB#317", + summary: `Long-lived Codex stdio session is not active: status=${session.status || "unknown"}.` + }); + } for (const blocker of codexStdioFeasibility?.blockers ?? []) { if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue; blockers.push({ @@ -710,6 +717,7 @@ export function longLivedSessionGate({ session?.codexStdio === true && session?.writeCapable === true && session?.durable === true && + ["idle", "ready", "busy"].includes(session?.status) && feasible && blockers.length === 0; const feasiblePass =