diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 02e9d220..781e4fad 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -38,7 +38,7 @@ report、issue、PR 或截图。 | 观测结果 | readiness | | --- | --- | | `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 | -| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 #317 read-only session registry partial pass;不能关闭 long-lived Codex stdio/session blocker。 | +| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`session.longLivedSession: true`、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `process-local-session-registry` | 可标为 #317 read-only long-lived session registry pass;不能关闭 full Codex stdio/session capability blocker。 | | `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | | `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 | | 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 | @@ -54,10 +54,11 @@ report、issue、PR 或截图。 - `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存 `conversationId/sessionId` 映射、`status`、workspace、sandbox、`createdAt`、`updatedAt`、 `idleTimeoutMs`、`lastTraceId`、`turn` 计数与只读工具 trace。它可以覆盖 `pwd`、 - `skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式的 - `longLivedSessionGate` 必须保持 `blocked`,直到 Codex stdio 或等价长期通道真实接通。 -- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`not-durable-session`。它不是 - long-lived Codex stdio session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL + `skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式是 + read-only long-lived session,但 `longLivedSessionGate` 必须保持 `blocked`,直到 Codex + stdio 或等价 full Code Agent 协议通道真实接通。 +- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`process-local-session-registry`。它不是 + Codex stdio / workspace-write session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL 读取,也不证明 M3/M4/M5 trusted green。 - OpenAI Responses fallback 只能标记为 `openai-responses-fallback` / `text-chat-only`,不得满足 Codex runner capability gate。 diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 58d9abe9..edfe6725 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -76,7 +76,7 @@ const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275"; const READONLY_LIMITATION_FLAGS = Object.freeze([ "not-codex-stdio", "not-write-capable", - "not-durable-session" + "process-local-session-registry" ]); const SKIPPED_READONLY_DIRS = new Set([ ".git", @@ -255,7 +255,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { "text-chat-only", "not-codex-stdio", "not-workspace-tools", - "not-durable-session" + "not-session-runner" ], codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(options.env ?? process.env, options), longLivedSessionGate: providerLongLivedGate, @@ -613,7 +613,7 @@ function primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, code function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) { if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].code; if (blocked && !runnerAvailability.ready) return "provider_unavailable"; - if (runnerAvailability.ready) return "controlled_readonly_not_long_lived_stdio"; + if (runnerAvailability.ready) return "codex_stdio_blocked_readonly_session_available"; return null; } @@ -714,7 +714,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { ready: workspaceReady, capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked", implementationType: READONLY_IMPLEMENTATION_TYPE, - longLivedSession: false, + longLivedSession: true, durableSession: false, codexStdio: false, writeCapable: false, @@ -2449,7 +2449,7 @@ function sessionReplyLine(session) { } function limitationReplyLine() { - return "边界:controlled-readonly-session-registry;not-codex-stdio;not-write-capable;not-durable-session。"; + return "边界:controlled-readonly-session-registry;long-lived read-only session;not-codex-stdio;not-write-capable;process-local-session-registry。"; } function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) { @@ -2498,7 +2498,7 @@ function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = nu sessionReused: session?.reused ?? false, implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", codexStdio: false, - longLivedSession: false, + longLivedSession: kind === READONLY_RUNNER_KIND, durableSession: false, writeCapable: false, readOnly: true, @@ -2534,7 +2534,7 @@ function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = valuesPrinted: false, note: runnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session." - : "controlled-readonly-session-registry stores conversation/session mapping and turn counters only; it is not Codex stdio, write-capable, or durable." + : "controlled-readonly-session-registry stores conversation/session mapping, idle timeout, status, trace id, and turn counters for a reusable read-only session; it is not Codex stdio, write-capable, or DB-durable." }; } @@ -2623,7 +2623,7 @@ function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, "text-chat-only", "not-codex-stdio", "not-workspace-tools", - "not-durable-session" + "not-session-runner" ], traceId }; @@ -3093,6 +3093,18 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "当前 conversation 与 session 绑定不一致,可新建会话后重试。" }, + session_failed: { + layer: "session", + category: "session_blocked", + retryable: true, + userMessage: "Code Agent session 已失败,可重新发送建立新的 session。" + }, + session_interrupted: { + layer: "session", + category: "session_blocked", + retryable: true, + userMessage: "Code Agent session 已中断,可重新发送建立新的 session。" + }, runner_unavailable: { layer: "runner", category: "runner_blocked", diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index 0287fbd2..f9cd66fa 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -67,6 +67,18 @@ export function createCodeAgentSessionRegistry(options = {}) { }); } + if (session && ["failed", "interrupted"].includes(session.status)) { + session.updatedAt = timestamp; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + return blockedAcquire({ + code: `session_${session.status}`, + summary: `Code Agent runner session ${effectiveSessionId} is ${session.status}; create a new session before retrying.`, + session, + timestamp, + traceId: params.traceId + }); + } + if (!session) { session = { sessionId: effectiveSessionId, @@ -86,8 +98,10 @@ export function createCodeAgentSessionRegistry(options = {}) { currentTraceId: null, turn: 0, durable: false, - longLivedSession: false, + longLivedSession: true, codexStdio: false, + readOnly: true, + writeCapable: false, secretMaterialStored: false }; sessions.set(effectiveSessionId, session); @@ -181,6 +195,7 @@ export function createCodeAgentSessionRegistry(options = {}) { .slice(0, 10) .map((session) => publicSession(session)), longLivedCodexStdio: false, + longLivedSession: true, durable: false, secretMaterialStored: false, valuesRedacted: true @@ -264,8 +279,11 @@ function publicSession(session, { conversationId = null, reused = null } = {}) { turn: session.turn, reused: reused === null ? undefined : Boolean(reused), durable: session.durable === true, + durableSession: session.durable === true, longLivedSession: session.longLivedSession === true, codexStdio: session.codexStdio === true, + readOnly: session.readOnly === true, + writeCapable: session.writeCapable === true, secretMaterialStored: false, valuesRedacted: true, ...(session.statusReason ? { statusReason: session.statusReason } : {}) diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 76d47655..8987b0fc 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -40,12 +41,19 @@ test("code agent session registry creates, reuses, and expires sessions without assert.equal(first.session.runnerKind, "hwlab-readonly-runner"); assert.equal(first.session.sessionMode, "controlled-readonly-session-registry"); assert.equal(first.session.capabilityLevel, "read-only-session-tools"); + assert.equal(first.session.longLivedSession, true); + assert.equal(first.session.durableSession, false); + assert.equal(first.session.codexStdio, false); + assert.equal(first.session.writeCapable, false); + assert.equal(first.session.readOnly, true); assert.equal(first.session.idleTimeoutMs, 1000); assert.equal(first.session.lastTraceId, "trc_registry_1"); assert.equal(first.session.secretMaterialStored, false); assert.equal(first.session.valuesRedacted, true); const describedCreating = registry.describe(); assert.equal(describedCreating.sessionMode, "controlled-readonly-session-registry"); + assert.equal(describedCreating.longLivedSession, true); + assert.equal(describedCreating.longLivedCodexStdio, false); assert.equal(describedCreating.recentSessions[0].sessionId, "ses_test-registry"); assert.equal(describedCreating.recentSessions[0].sessionMode, "controlled-readonly-session-registry"); assert.equal(describedCreating.recentSessions[0].workspace, "/workspace/hwlab"); @@ -122,6 +130,59 @@ 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 without reusing them", () => { + const failedRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_failed" + }); + const failedSeed = failedRegistry.acquire({ + conversationId: "cnv_failed", + workspace: "/workspace/hwlab", + traceId: "trc_failed_1", + now: "2026-05-23T00:00:00.000Z" + }); + assert.equal(failedSeed.ok, true); + failedRegistry.fail(failedSeed.session.sessionId, { + conversationId: "cnv_failed", + traceId: "trc_failed_1", + now: "2026-05-23T00:00:00.010Z" + }); + const failed = failedRegistry.acquire({ + conversationId: "cnv_failed", + workspace: "/workspace/hwlab", + 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.session.status, "failed"); + assert.equal(failed.blocker.sourceIssue, "pikasTech/HWLAB#317"); + + const interruptedRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_interrupted" + }); + const interruptedSeed = interruptedRegistry.acquire({ + conversationId: "cnv_interrupted", + workspace: "/workspace/hwlab", + traceId: "trc_interrupted_1", + now: "2026-05-23T00:00:00.000Z" + }); + assert.equal(interruptedSeed.ok, true); + interruptedRegistry.interrupt(interruptedSeed.session.sessionId, { + conversationId: "cnv_interrupted", + traceId: "trc_interrupted_1", + now: "2026-05-23T00:00:00.010Z" + }); + const interrupted = interruptedRegistry.acquire({ + conversationId: "cnv_interrupted", + workspace: "/workspace/hwlab", + 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"); +}); + test("read-only runner returns session registry evidence and blocked long-lived gate", async () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_chat_registry" @@ -150,9 +211,11 @@ test("read-only runner returns session registry evidence and blocked long-lived assert.equal(first.session.workspace, process.cwd()); assert.equal(first.session.lastTraceId, "trc_chat_registry_pwd"); assert.equal(first.capabilityLevel, "read-only-session-tools"); + assert.equal(first.session.longLivedSession, true); + assert.equal(first.runner.longLivedSession, true); assert.equal(first.longLivedSessionGate.status, "blocked"); assert.equal(first.longLivedSessionGate.pass, false); - assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); + assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); const second = await handleCodeAgentChat( { @@ -177,7 +240,7 @@ test("read-only runner returns session registry evidence and blocked long-lived assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, false); }); -test("expired and busy session states are surfaced as structured blockers", async () => { +test("expired, busy, and failed session states are surfaced as structured blockers", async () => { const expiredRegistry = createCodeAgentSessionRegistry({ idleTimeoutMs: 10, idFactory: () => "ses_expired_chat" @@ -258,6 +321,47 @@ test("expired and busy session states are surfaced as structured blockers", asyn assert.equal(busy.session.status, "busy"); assert.equal(busy.capabilityLevel, "blocked"); assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); + + const failedRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_failed_chat" + }); + const failedSeed = failedRegistry.acquire({ + conversationId: "cnv_failed_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_failed_seed", + now: "2026-05-23T00:04:00.000Z" + }); + failedRegistry.fail(failedSeed.session.sessionId, { + conversationId: "cnv_failed_chat", + traceId: "trc_failed_seed", + now: "2026-05-23T00:04:00.001Z" + }); + const failed = await handleCodeAgentChat( + { + conversationId: "cnv_failed_chat", + traceId: "trc_failed_request", + message: "pwd" + }, + { + now: () => "2026-05-23T00:04:01.000Z", + sessionRegistry: failedRegistry, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + assert.equal(failed.status, "failed"); + assert.equal(failed.error.code, "session_failed"); + assert.equal(failed.error.layer, "session"); + assert.equal(failed.error.retryable, true); + assert.match(failed.error.userMessage, /session 已失败/u); + assert.equal(failed.error.blocker.code, "session_failed"); + assert.equal(failed.session.status, "failed"); + assert.equal(failed.capabilityLevel, "blocked"); }); test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => { @@ -300,7 +404,8 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate kind: "codex-cli-one-shot-ephemeral", codexStdio: false, writeCapable: false, - durableSession: false + durableSession: false, + longLivedSession: false }, sessionMode: "ephemeral-one-shot", implementationType: "codex-cli-one-shot-ephemeral", @@ -937,7 +1042,8 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-")); try { const manager = createCodexStdioSessionManager(); - const codexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex"); + const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex"); + const codexCommand = existsSync(localCodexCommand) ? localCodexCommand : "codex"; const availability = manager.describe({ env: { PATH: process.env.PATH, diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 497569db..2c030863 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -747,9 +747,9 @@ export function longLivedSessionGate({ } if (normalizedImplementation === "controlled-readonly-session-registry") { blockers.push({ - code: "controlled_readonly_not_long_lived_stdio", + code: "codex_stdio_blocked_readonly_session_available", sourceIssue: "pikasTech/HWLAB#317", - summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter." + 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)) { diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index 23e9575c..db98fb8f 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -231,7 +231,7 @@ function buildSessionRunnerReadiness(codeAgent = {}) { const stdioReady = stdio.ready === true || stdio.canStartLongLivedCodexStdio === true; const runner = codeAgent?.runner ?? {}; const runnerReady = stdioReady || runner.ready === true || codeAgent?.sessionRegistry?.status === "available"; - const status = stdioReady ? "long_lived_ready" : runnerReady ? "read_only_ready" : "blocked"; + const status = stdioReady ? "codex_stdio_ready" : runnerReady ? "read_only_long_lived_ready" : "blocked"; const blocker = runnerReady ? null : firstCode( runner.blocker, codeAgent?.reason, @@ -247,7 +247,7 @@ function buildSessionRunnerReadiness(codeAgent = {}) { backend: stdioReady ? stdio.backend ?? "hwlab-cloud-api/codex-mcp-stdio" : runner.backend ?? codeAgent?.backend ?? "unknown", mode: stdioReady ? "codex-mcp-stdio-long-lived" : runner.sessionMode ?? runner.mode ?? codeAgent?.sessionMode ?? "unknown", capabilityLevel: stdioReady ? "long-lived-codex-stdio-session" : runner.capabilityLevel ?? codeAgent?.capabilityLevel ?? "unknown", - longLivedSession: stdioReady || runner.longLivedSession === true, + longLivedSession: stdioReady || runner.longLivedSession === true || runner.sessionMode === "controlled-readonly-session-registry", durableSession: stdioReady || runner.durableSession === true || runner.durable === true, codexStdio: stdioReady || runner.codexStdio === true, writeCapable: stdioReady || runner.writeCapable === true, diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index aa64a1c1..66da77a7 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -89,10 +89,11 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.readiness.provider.blocker, "provider_config_blocked"); assert.equal(healthPayload.readiness.dbDurable.status, "blocked"); assert.equal(healthPayload.readiness.dbDurable.ready, false); - assert.equal(healthPayload.readiness.sessionRunner.status, "read_only_ready"); + assert.equal(healthPayload.readiness.sessionRunner.status, "read_only_long_lived_ready"); assert.equal(healthPayload.readiness.sessionRunner.ready, true); assert.equal(healthPayload.readiness.sessionRunner.codexStdio, false); assert.equal(healthPayload.readiness.sessionRunner.durableSession, false); + assert.equal(healthPayload.readiness.sessionRunner.longLivedSession, true); assert.equal(healthPayload.readiness.codexStdio.status, "blocked"); assert.equal(healthPayload.readiness.codexStdio.ready, false); assertCodexStdioFeasibilityBlocker(healthPayload.readiness.codexStdio.blocker); @@ -344,7 +345,7 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.readiness.dbDurable.status, "blocked"); assert.equal(payload.readiness.dbDurable.dbLiveEvidenceObserved, true); assert.equal(payload.readiness.dbDurable.blocker, "runtime_durable_adapter_missing"); - assert.equal(payload.readiness.sessionRunner.status, "read_only_ready"); + assert.equal(payload.readiness.sessionRunner.status, "read_only_long_lived_ready"); assert.equal(payload.readiness.codexStdio.status, "blocked"); assert.equal(payload.db.redaction.valuesRedacted, true); assert.equal(payload.db.redaction.endpointRedacted, true); @@ -1178,7 +1179,7 @@ test("cloud api health reports provider and durable DB ready while codex stdio r assert.equal(payload.readiness.provider.blocker, null); assert.equal(payload.readiness.dbDurable.status, "ready"); assert.equal(payload.readiness.dbDurable.ready, true); - assert.equal(payload.readiness.sessionRunner.status, "read_only_ready"); + assert.equal(payload.readiness.sessionRunner.status, "read_only_long_lived_ready"); assert.equal(payload.readiness.sessionRunner.ready, true); assert.equal(payload.readiness.codexStdio.status, "blocked"); assertCodexStdioFeasibilityBlocker(payload.readiness.codexStdio.blocker); @@ -1323,7 +1324,7 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd"); assert.equal(typeof payload.session.idleTimeoutMs, "number"); assert.equal(payload.runner.kind, "hwlab-readonly-runner"); - assert.equal(payload.runner.longLivedSession, false); + assert.equal(payload.runner.longLivedSession, true); assert.equal(payload.runner.codexStdio, false); assert.equal(payload.runner.writeCapable, false); assert.equal(payload.runner.durableSession, false); @@ -1334,12 +1335,13 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.sessionReuse.turn, 1); assert.equal(payload.sessionReuse.status, "idle"); assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); + assert.ok(payload.runnerLimitations.includes("process-local-session-registry")); assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "blocked"); assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); + assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); assert.equal(payload.toolCalls[0].name, "pwd"); assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].stdout, workspace); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index ce9bd13a..7c80e7b4 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -198,6 +198,7 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry"); assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry"); assert.equal(runnerPwd.runner.codexStdio, false); + assert.equal(runnerPwd.runner.longLivedSession, true); assert.equal(runnerPwd.runner.writeCapable, false); assert.equal(runnerPwd.runner.durableSession, false); assert.equal(runnerPwd.sessionReuse.reused, false); @@ -210,14 +211,14 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.pass, false); - assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); + assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); assert.equal(runnerPwd.toolCalls[0].name, "pwd"); assert.equal(runnerPwd.toolCalls[0].status, "completed"); const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 }); assert.equal(runnerCapability.status, "blocked"); assert.equal(runnerCapability.capabilityPass, false); assert.equal(runnerCapability.blocker, "controlled-readonly-not-long-lived"); - logOk("read-only runner pwd remains partial capability"); + logOk("read-only runner pwd uses a reusable session but remains partial capability"); const runnerSecondTurn = await handleCodeAgentChat( { diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index c52c60f4..091cfa81 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -219,7 +219,7 @@ async function smokeCloudApi() { assertCodexStdioFeasibilityBlocker(health.body.codeAgent.reason, "health codeAgent reason"); assert.equal(health.body.readiness.provider.status, "blocked"); assert.equal(health.body.readiness.provider.blocker, "provider_config_blocked"); - assert.equal(health.body.readiness.sessionRunner.status, "read_only_ready"); + assert.equal(health.body.readiness.sessionRunner.status, "read_only_long_lived_ready"); assert.equal(health.body.readiness.sessionRunner.ready, true); assert.equal(health.body.readiness.codexStdio.status, "blocked"); assertCodexStdioFeasibilityBlocker(health.body.readiness.codexStdio.blocker, "health readiness codexStdio blocker"); @@ -252,7 +252,7 @@ async function smokeCloudApi() { assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli"); assert.equal(adapter.body.codeAgent.ready, false); assert.equal(adapter.body.readiness.provider.status, "blocked"); - assert.equal(adapter.body.readiness.sessionRunner.status, "read_only_ready"); + assert.equal(adapter.body.readiness.sessionRunner.status, "read_only_long_lived_ready"); assert.equal(adapter.body.readiness.codexStdio.status, "blocked"); assert.ok(adapter.body.codeAgent.missingEnv.includes("OPENAI_API_KEY")); assert.equal(adapter.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 5dd1cfc6..80c00985 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -146,10 +146,10 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { if (partialRunnerProviders.has(provider) && runnerKind === "hwlab-readonly-runner") { return { status: "blocked", - level: "BLOCKED/controlled-readonly-session-registry", + level: "BLOCKED/controlled-readonly-long-lived-session", blocker: "controlled-readonly-not-long-lived", capabilityPass: false, - reason: "controlled-readonly-session-registry can expose bounded read-only tools, but it cannot pass the long-lived Codex stdio capability gate." + reason: "controlled-readonly-session-registry exposes a reusable read-only session, but it is not Codex stdio, workspace-write, or a full Code Agent capability pass." }; } return { @@ -471,7 +471,7 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { }; } - if (["session_expired", "session_reuse_conflict"].includes(errorCode)) { + if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted"].includes(errorCode)) { return { blocked: true, status: "blocked", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 1369cb2f..5fbd9137 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -310,10 +310,10 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "codex-mcp-stdio-long-lived", "repo-owned-codex-mcp-stdio-session", "controlled-readonly-session-registry", - "read-only-session-tools:只读工具", + "read-only-session-tools:只读长会话", "not-codex-stdio", "not-write-capable", - "not-durable-session", + "process-local-session-registry", "echo/mock/stub", "untrusted_completion", "SOURCE fixture", @@ -2475,7 +2475,7 @@ function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { /isRealCompletedChatResult/u.test(app) && /hasRealCodeAgentEvidence/u.test(app) && /codeAgentBlockerDetail/u.test(app) && - /codex_stdio_blocked_readonly_available/u.test(liveStatus) && + /codex_stdio_blocked_readonly_session_available/u.test(liveStatus) && /codex_stdio_supervisor_disabled/u.test(app) && !/status === "failed" \? "dev-live"/u.test(app) && !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && @@ -5836,6 +5836,7 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} runner: { kind: "openai-responses-fallback", codexStdio: false, + longLivedSession: false, writeCapable: false, durableSession: false }, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index d83395c5..9a7d256a 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -80,7 +80,7 @@ const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ "codex_stdio_supervisor_disabled", "not-codex-stdio", "not-write-capable", - "not-durable-session" + "process-local-session-registry" ]); const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1"; @@ -2409,11 +2409,11 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) }; } - if (["session_expired", "session_reuse_conflict"].includes(code)) { + if (["session_expired", "session_reuse_conflict", "session_failed", "session_interrupted"].includes(code)) { return { category: "session_blocked", title: "Code Agent Session 受阻", - text: `当前 session 已过期或与 conversation 绑定不一致;输入已保留,可重新发送建立新的受控只读 session。${traceSuffix}` + text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的受控只读 session。${traceSuffix}` }; } @@ -2504,7 +2504,7 @@ function messageAttributionPanel(message) { section.className = `message-attribution tone-border-${toneClass(attribution.tone)}`; section.setAttribute( "aria-label", - "消息归因:字段缺失:证据不足;fallback 只是文本回答;blocker 当前不能执行;text-chat-only / 只是文本回答;read-only-session-tools:只读工具。" + "消息归因:字段缺失:证据不足;fallback 只是文本回答;blocker 当前不能执行;text-chat-only / 只是文本回答;read-only-session-tools:只读长会话。" ); section.dataset.codeAgentAttributionKind = attribution.kind; @@ -3011,8 +3011,8 @@ function codeAgentStatusMessage(availability) { if (availability?.runner?.ready === true) { return { role: "system", - title: "Code Agent 状态:只读 session registry 可用,Codex stdio 受阻", - text: `pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;它不是完整 Code Agent。${codeAgentBlockerDetail(availability)}`, + title: "Code Agent 状态:只读长会话 registry 可用,Codex stdio 受阻", + text: `pwd、skills、ls、rg --files 和 cat 会走可复用 controlled-readonly-session-registry;它不是完整 Codex stdio Code Agent。${codeAgentBlockerDetail(availability)}`, status: "source" }; } @@ -3068,7 +3068,7 @@ function codeAgentControlSummary(availability) { return codeAgentBlockedSummary(availability); } if (availability?.partialReady === true || availability?.runner?.ready === true) { - return `输入区会调用受控接口;当前只有只读 registry 或 text fallback,不能标成完整 Code Agent。${codeAgentBlockerDetail(availability)}`; + return `输入区会调用受控接口;当前只有只读长会话 registry 或 text fallback,不能标成完整 Codex stdio Code Agent。${codeAgentBlockerDetail(availability)}`; } return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。"; } @@ -3111,7 +3111,8 @@ function codeAgentBlockerChineseLabel(code) { runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", stdio_protocol_not_wired: "Codex stdio 协议尚未接入", codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", - controlled_readonly_not_long_lived_stdio: "当前只是只读 session registry", + codex_stdio_blocked_readonly_session_available: "只读长会话 registry 可用但 Codex stdio 受阻", + controlled_readonly_not_long_lived_stdio: "只读 registry 不是 Codex stdio", openai_responses_fallback_not_session: "文本 fallback 不是长会话", one_shot_runner_not_long_lived: "一次性执行不是可复用 session", provider_token_boundary: "token 边界未配置", @@ -3133,7 +3134,7 @@ function codeAgentAvailabilitySummary() { return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。"; } if (state.codeAgentAvailability?.runner?.ready === true) { - return `同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; + return `同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读长会话 registry,但它不是 Codex stdio、不可写、非 DB 持久 session。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; } if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; @@ -3236,6 +3237,8 @@ function isReadOnlyRunnerPartialEvidence(value) { value?.implementationType === "controlled-readonly-session-registry" && value?.runner?.codexStdio === false && value?.runner?.writeCapable === false && + value?.session?.longLivedSession === true && + value?.runner?.longLivedSession === true && value?.runner?.durableSession === false && value?.longLivedSessionGate?.status === "blocked" ); diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index c11e415e..eda56a6a 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -129,9 +129,9 @@ export function classifyCodeAgentFacts(message) { if (provider === "codex-readonly-runner" || runnerKind === "hwlab-readonly-runner" || sessionMode === READONLY_SESSION_MODE) { return { kind: READONLY_SESSION_MODE, - statusLabel: "只读工具:controlled-readonly-session-registry", + statusLabel: "只读长会话:controlled-readonly-session-registry", tone: "source", - summary: "只读 session registry 可用;只能使用 read-only-session-tools,不能写工作区,也不能冒充完整 Codex stdio 长会话。", + summary: "只读 session registry 可复用 conversation/session;只能使用 read-only-session-tools,不能写工作区,也不能冒充完整 Codex stdio Code Agent。", fullCodeAgent: false, codexSessionGatePass: false }; @@ -302,7 +302,7 @@ function hasCodeAgentFields(message) { function messageAttributionLabel(message, classification) { if (message?.status === "failed" || message?.sourceKind === "BLOCKED") return "BLOCKED:当前不能执行"; if (classification.kind === "text-chat-only") return "OpenAI fallback:只是文本回答"; - if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读工具"; + if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读长会话"; if (classification.kind === "hwlab-skill-cli") return "hwlab-skill-cli:Skill CLI 受控路径"; if (classification.kind === "codex-stdio-long-lived") return "真实 runner:Codex stdio 长会话"; if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话"; diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.mjs index 69619360..f427a5ed 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.mjs @@ -33,12 +33,13 @@ test("codex-readonly-runner is shown as partial read-only session registry", () kind: "hwlab-readonly-runner", codexStdio: false, writeCapable: false, - durableSession: false + durableSession: false, + longLivedSession: true }, longLivedSessionGate: { status: "blocked", pass: false, - blockers: [{ code: "controlled_readonly_not_long_lived_stdio" }] + blockers: [{ code: "codex_stdio_blocked_readonly_session_available" }] }, toolCalls: [{ name: "pwd", status: "completed" }], skills: { status: "not_requested", count: 0 }, @@ -54,6 +55,8 @@ test("codex-readonly-runner is shown as partial read-only session registry", () assert.equal(facts.kind, "controlled-readonly-session-registry"); assert.equal(facts.fullCodeAgent, false); assert.equal(facts.codexSessionGatePass, false); + assert.match(facts.statusLabel, /只读长会话/u); + assert.match(facts.summary, /可复用/u); assert.match(facts.summary, /Codex stdio/u); assert.match(facts.rows.find((row) => row.label === "runner/provider/backend").value, /runner=hwlab-readonly-runner/u); assert.match(facts.rows.find((row) => row.label === "toolCalls").value, /1\/1 pwd\(completed\)/u); diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index f3cecac7..912ff97a 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -134,7 +134,7 @@ function classifyPayload(payload, blockers) { } if (isReadOnlySessionTools(payload)) { - return status("read-only-session-tools", "只读会话工具", "warn", "◇", "只读 runner 工具可用,不能写工作区或冒充完整长会话。"); + return status("read-only-session-tools", "只读长会话工具", "warn", "◇", "只读 runner 会话可复用,不能写工作区或冒充完整 Codex stdio。"); } if (isStatelessOneShot(payload)) { diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 6e0488ef..4d5f3013 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -91,7 +91,7 @@ test("maps degraded readonly session tools to warning, not green", () => { }); assert.equal(summary.kind, "read-only-session-tools"); - assert.equal(summary.label, "只读会话工具"); + assert.equal(summary.label, "只读长会话工具"); assert.equal(summary.tone, "warn"); assert.equal(summary.sessionId, "ses_readonly"); assert.equal(summary.sessionIdStatus, "ses_readonly / idle"); @@ -272,12 +272,12 @@ test("uses structured readiness current blockers and ignores stale provider bloc queryResult: "durable_readiness_ready" }, sessionRunner: { - status: "read_only_ready", + status: "read_only_long_lived_ready", ready: true, kind: "hwlab-readonly-runner", mode: "controlled-readonly-session-registry", capabilityLevel: "read-only-session-tools", - longLivedSession: false, + longLivedSession: true, durableSession: false, codexStdio: false, writeCapable: false, diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs index e310ac09..65c84220 100644 --- a/web/hwlab-cloud-web/live-status.mjs +++ b/web/hwlab-cloud-web/live-status.mjs @@ -39,10 +39,11 @@ const READ_ONLY_REASON_CODES = Object.freeze([ "persistence", "not-codex-stdio", "not-write-capable", - "not-durable-session", + "process-local-session-registry", "text-chat-only", "fallback:text-chat-only", "codex_stdio_blocked_readonly_available", + "codex_stdio_blocked_readonly_session_available", "controlled_readonly_not_long_lived_stdio", "codex_cli_binary_missing", "runner_lifecycle_missing", @@ -329,7 +330,7 @@ function classifyCodeAgentProbe(live = {}) { return readOnlyProbe({ serviceId, apiPath, - reasonCode: "codex_stdio_blocked_readonly_available", + reasonCode: "codex_stdio_blocked_readonly_session_available", reason: `${availability.summary ?? "controlled-readonly-session-registry available; long-lived Codex stdio blocked"}; blockers=${blockerCodes(availability).join(",") || "not_observed"}`, traceId: traceIdFrom(availability), evidenceSummary: evidenceSummaryFrom(availability), @@ -409,7 +410,7 @@ function classifyStructuredCodeAgentProbe(readiness = {}) { return readOnlyProbe({ serviceId, apiPath, - reasonCode: readiness.codexStdio?.blocker ?? "codex_stdio_blocked_readonly_available", + reasonCode: readiness.codexStdio?.blocker ?? "codex_stdio_blocked_readonly_session_available", reason: `providerReady=${Boolean(readiness.providerReady)}; durableDbReady=${Boolean(readiness.durableDbReady)}; sessionRunner=${readiness.sessionRunner?.status ?? "unknown"}; codexStdio=${readiness.codexStdio?.status ?? "unknown"}; current=${currentStructuredBlockers(readiness).join(",") || "not_observed"}`, meta: { provider, backend, capability } }); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2e3fdbb8..37a93bd4 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -774,7 +774,7 @@ assert.match(app, /read-only-session-tools/); assert.match(app, /controlled-readonly-session-registry/); assert.match(app, /not-codex-stdio/); assert.match(app, /not-write-capable/); -assert.match(app, /not-durable-session/); +assert.match(app, /process-local-session-registry/); assert.match(app, /Code Agent 完成证据不足/); assert.match(app, /本次不会标记为真实完成/); assert.match(app, /untrusted_completion/); @@ -804,7 +804,7 @@ assert.match(codeAgentFacts, /字段缺失:证据不足/); assert.match(codeAgentFacts, /codeAgentAttributionFromMessage/); assert.match(codeAgentFacts, /stateless-one-shot/); assert.match(codeAgentFacts, /controlled-readonly-session-registry/); -assert.match(codeAgentFacts, /read-only-session-tools:只读工具/); +assert.match(codeAgentFacts, /read-only-session-tools:只读长会话/); assert.match(codeAgentFacts, /hwlab-m3-io-skill-cli/); assert.match(codeAgentFacts, /route", fact\.route/); assert.match(codeAgentFacts, /operationId", fact\.operationId/); diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs index 1531c245..74e6521b 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -224,7 +224,7 @@ test("classifies controlled readonly Code Agent as read-only, not full pass", () assert.equal(status.kind, "readonly"); assert.equal(status.label, "只读模式"); - assert.equal(status.reasonCode, "codex_stdio_blocked_readonly_available"); + assert.equal(status.reasonCode, "codex_stdio_blocked_readonly_session_available"); assert.match(status.detail, /controlled-readonly-session-registry/u); }); @@ -264,7 +264,7 @@ test("structured readiness shows current codex stdio blocker instead of stale pr blocker: null }, sessionRunner: { - status: "read_only_ready", + status: "read_only_long_lived_ready", ready: true, kind: "hwlab-readonly-runner", mode: "controlled-readonly-session-registry", @@ -729,7 +729,7 @@ test("keeps ready durable runtime green while attributing read-only mode to Code backend: "hwlab-cloud-api/controlled-readonly-session-registry", capabilityLevel: "read-only-session-tools", trackingIssue: "#317", - runnerLimitations: ["not-codex-stdio", "not-write-capable", "not-durable-session"] + runnerLimitations: ["not-codex-stdio", "not-write-capable", "process-local-session-registry"] } } }, @@ -748,7 +748,7 @@ test("keeps ready durable runtime green while attributing read-only mode to Code backend: "hwlab-cloud-api/controlled-readonly-session-registry", capabilityLevel: "read-only-session-tools", trackingIssue: "#317", - runnerLimitations: ["not-codex-stdio", "not-write-capable", "not-durable-session"] + runnerLimitations: ["not-codex-stdio", "not-write-capable", "process-local-session-registry"] } } },