feat: expose reusable readonly code agent sessions

This commit is contained in:
Code Queue Review
2026-05-23 19:32:57 +00:00
parent 8e2a281782
commit 84c05c350d
19 changed files with 212 additions and 64 deletions
+6 -5
View File
@@ -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。
+20 -8
View File
@@ -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-registrynot-codex-stdionot-write-capablenot-durable-session。";
return "边界:controlled-readonly-session-registrylong-lived read-only sessionnot-codex-stdionot-write-capableprocess-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",
+19 -1
View File
@@ -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 } : {})
@@ -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,
+2 -2
View File
@@ -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)) {
+2 -2
View File
@@ -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,
+7 -5
View File
@@ -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);
+3 -2
View File
@@ -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(
{
+2 -2
View File
@@ -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");
+3 -3
View File
@@ -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",
@@ -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
},
+12 -9
View File
@@ -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"
);
+3 -3
View File
@@ -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-cliSkill CLI 受控路径";
if (classification.kind === "codex-stdio-long-lived") return "真实 runnerCodex stdio 长会话";
if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话";
@@ -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);
+1 -1
View File
@@ -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)) {
@@ -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,
+4 -3
View File
@@ -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 }
});
+2 -2
View File
@@ -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/);
@@ -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"]
}
}
},