diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 0aa69b59..02e9d220 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-tools"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 read-only session registry partial pass;不能关闭 #275 的 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` 存在、`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。 | | `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。 | @@ -52,8 +52,10 @@ report、issue、PR 或截图。 `/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分: - `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存 - `conversationId/sessionId` 映射、`turn` 计数、workspace 与只读工具 trace。它可以覆盖 - `pwd`、`skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。 + `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 读取,也不证明 M3/M4/M5 trusted green。 diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 3751b6e6..cf22329c 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -11,6 +11,10 @@ import { codeAgentSecretRefPlaceholder, inspectCodeAgentProviderEnv } from "./code-agent-contract.mjs"; +import { + DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, + createCodeAgentSessionRegistry +} from "./code-agent-session-registry.mjs"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000; const DEFAULT_CODEX_COMMAND = "codex"; @@ -23,6 +27,7 @@ const READONLY_RUNNER_KIND = "hwlab-readonly-runner"; const READONLY_RUNNER_SANDBOX = "read-only"; const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry"; +const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools"; const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral"; const READONLY_TOOL_OUTPUT_LIMIT = 4000; @@ -58,12 +63,14 @@ const CODE_AGENT_SYSTEM_PROMPT = [ ].join("\n"); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const readOnlySessions = new Map(); -const readOnlyConversationSessions = new Map(); +const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({ + idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, + maxSessions: MAX_READONLY_SESSIONS +}); export async function handleCodeAgentChat(params = {}, options = {}) { const timestamp = nowIso(options.now); - const { conversationId, sessionId } = resolveConversationSessionIds(params); + const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params); const messageId = `msg_${randomUUID()}`; const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; const providerPlan = resolveProviderPlan(options.env ?? process.env, options); @@ -89,16 +96,18 @@ export async function handleCodeAgentChat(params = {}, options = {}) { intent: runnerIntent, conversationId, traceId, - sessionId, + sessionId: requestedSessionId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact + skillsDirsExact: options.skillsDirsExact, + sessionRegistry: options.sessionRegistry }); const completedAt = nowIso(options.now); return { ...base, + sessionId: runnerResult.session?.sessionId ?? base.sessionId, status: "completed", updatedAt: completedAt, provider: runnerResult.provider, @@ -106,11 +115,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) { backend: runnerResult.backend, workspace: runnerResult.workspace, sandbox: runnerResult.sandbox, + session: runnerResult.session, sessionMode: runnerResult.sessionMode, sessionReuse: runnerResult.sessionReuse, implementationType: runnerResult.implementationType, runnerLimitations: runnerResult.runnerLimitations, codexStdioFeasibility: runnerResult.codexStdioFeasibility, + longLivedSessionGate: runnerResult.longLivedSessionGate, toolCalls: runnerResult.toolCalls, skills: runnerResult.skills, runner: runnerResult.runner, @@ -155,6 +166,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { backend: providerResult.backend ?? base.backend, workspace: providerResult.workspace ?? null, sandbox: providerResult.sandbox ?? "none", + session: providerResult.session ?? null, sessionMode: providerResult.sessionMode ?? "provider-text-request", sessionReuse: providerResult.sessionReuse ?? null, implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, @@ -165,6 +177,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) { "not-durable-session" ], codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)), + longLivedSessionGate: providerResult.longLivedSessionGate ?? longLivedSessionGate({ + provider: providerResult.provider ?? base.provider, + runnerKind: providerResult.runner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, + session: providerResult.session ?? null, + sessionMode: providerResult.sessionMode ?? "provider-text-request", + implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)) + }), toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { status: "not_requested", @@ -201,6 +221,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.backend) payload.backend = error.backend; if (error.workspace !== undefined) payload.workspace = error.workspace; if (error.sandbox !== undefined) payload.sandbox = error.sandbox; + if (error.session !== undefined) payload.session = error.session; if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls; if (error.skills !== undefined) payload.skills = error.skills; if (error.runner !== undefined) payload.runner = error.runner; @@ -211,6 +232,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.implementationType !== undefined) payload.implementationType = error.implementationType; if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; + if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; if (error.code === "provider_unavailable") { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) { @@ -266,7 +288,11 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { const blocked = providerPlan.mode === "openai" ? !providerContract.ready : missingEnv.length > 0; - const runnerAvailability = inspectReadOnlyRunnerAvailability(env, options); + const sessionRegistry = resolveCodeAgentSessionRegistry(options); + const runnerAvailability = inspectReadOnlyRunnerAvailability(env, { + ...options, + sessionRegistry + }); return { endpoint: "POST /v1/agent/chat", provider: providerPlan.provider, @@ -291,11 +317,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { "skills", "runnerTrace", "capabilityLevel", + "session", "sessionMode", "sessionReuse", "implementationType", "runnerLimitations", "codexStdioFeasibility", + "longLivedSessionGate", "error.message", "error.code", "error.missingEnv", @@ -312,11 +340,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety, - capabilityLevel: runnerAvailability.ready ? "read-only-tools" : blocked ? "blocked" : "text-chat-only", + capabilityLevel: runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only", + sessionRegistry: runnerAvailability.sessionRegistry, sessionMode: runnerAvailability.sessionMode, implementationType: runnerAvailability.implementationType, runnerLimitations: runnerAvailability.runnerLimitations, codexStdioFeasibility: runnerAvailability.codexStdioFeasibility, + longLivedSessionGate: runnerAvailability.longLivedSessionGate, ready: !blocked || runnerAvailability.ready }; } @@ -381,6 +411,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { const skillsDirs = resolveSkillDirs(env, options); const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); + const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, @@ -392,7 +423,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { session: READONLY_SESSION_MODE, status: workspaceReady ? "available" : "blocked", ready: workspaceReady, - capabilityLevel: workspaceReady ? "read-only-tools" : "blocked", + capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked", implementationType: READONLY_IMPLEMENTATION_TYPE, longLivedSession: false, durableSession: false, @@ -400,15 +431,69 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { writeCapable: false, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility, + longLivedSessionGate: longLivedSessionGate({ + provider: READONLY_RUNNER_PROVIDER, + runnerKind: READONLY_RUNNER_KIND, + sessionMode: READONLY_SESSION_MODE, + implementationType: READONLY_IMPLEMENTATION_TYPE, + codexStdioFeasibility + }), + sessionRegistry, skillsDirs, skillsDirsPresent, safety: runnerSafetyContract() }; } -async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) { +async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const session = getOrCreateReadOnlySession({ conversationId, sessionId, workspace: resolvedWorkspace, now }); + const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); + const sessionAcquire = registry.acquire({ + conversationId, + sessionId, + workspace: resolvedWorkspace, + sandbox: READONLY_RUNNER_SANDBOX, + runnerKind: READONLY_RUNNER_KIND, + capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, + implementationType: READONLY_IMPLEMENTATION_TYPE, + traceId, + now + }); + if (!sessionAcquire.ok) { + const blockedSession = sessionAcquire.session; + const blockedTrace = runnerTrace({ + traceId, + workspace: resolvedWorkspace ?? repoRoot, + session: blockedSession, + events: [`blocked:${sessionAcquire.code}`], + startedAt: nowIso(now), + outputTruncated: false + }); + throw runnerError(sessionAcquire.code, sessionAcquire.message, { + workspace: resolvedWorkspace ?? null, + session: blockedSession, + toolCalls: [], + skills: notRequestedSkills(), + runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), + runnerTrace: blockedTrace, + capabilityLevel: "blocked", + sessionMode: READONLY_SESSION_MODE, + sessionReuse: sessionReuseEvidence(blockedSession), + implementationType: READONLY_IMPLEMENTATION_TYPE, + runnerLimitations: [...READONLY_LIMITATION_FLAGS], + codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)), + longLivedSessionGate: longLivedSessionGate({ + provider: READONLY_RUNNER_PROVIDER, + runnerKind: READONLY_RUNNER_KIND, + session: blockedSession, + sessionMode: READONLY_SESSION_MODE, + implementationType: READONLY_IMPLEMENTATION_TYPE, + codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)) + }), + blockers: [sessionAcquire.blocker] + }); + } + let session = sessionAcquire.session; const runner = runnerDescriptor({ workspace: resolvedWorkspace, session }); const startedAt = nowIso(now); const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); @@ -424,12 +509,28 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, sessionReuse: sessionReuseEvidence(session), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility + codexStdioFeasibility, + longLivedSessionGate: longLivedSessionGate({ + provider: READONLY_RUNNER_PROVIDER, + runnerKind: READONLY_RUNNER_KIND, + session, + sessionMode: READONLY_SESSION_MODE, + implementationType: READONLY_IMPLEMENTATION_TYPE, + codexStdioFeasibility + }) }; if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) { + session = registry.fail(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + statusReason: "workspace_unreadable" + }) ?? session; throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, { workspace: resolvedWorkspace ?? null, + session, toolCalls: [], skills: notRequestedSkills(), runner, @@ -440,8 +541,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, } if (intent.kind === "security") { + session = registry.fail(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + statusReason: "security_blocked" + }) ?? session; throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", { workspace: resolvedWorkspace, + session, toolCalls: [{ id: `tool_${randomUUID()}`, type: "security", @@ -468,6 +577,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, if (intent.kind === "pwd") { const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env }); + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "当前受控只读 runner 工作目录:", @@ -490,12 +600,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, if (intent.kind === "ls") { const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); assertReadOnlyToolCompleted(toolCall, { + session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }), baseEvidence }); + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 ls 结果:", @@ -518,12 +630,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, if (intent.kind === "rg_files") { const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env }); assertReadOnlyToolCompleted(toolCall, { + session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }), baseEvidence }); + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 rg --files 结果:", @@ -546,12 +660,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, if (intent.kind === "cat") { const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); assertReadOnlyToolCompleted(toolCall, { + session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }), baseEvidence }); + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 cat 结果:", @@ -574,8 +690,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, if (intent.kind === "skills") { const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }); if (skills.status === "blocked") { + session = registry.fail(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + statusReason: "skills_unavailable" + }) ?? session; throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", { workspace: resolvedWorkspace, + session, toolCalls: [{ id: `tool_${randomUUID()}`, type: "file-read", @@ -595,6 +719,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, blockers: skills.blockers }); } + session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: skillsReply(skills), workspace: resolvedWorkspace, @@ -618,8 +743,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, }); } + session = registry.fail(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + statusReason: "tool_unavailable" + }) ?? session; throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, { workspace: resolvedWorkspace, + session, toolCalls: [{ id: `tool_${randomUUID()}`, type: "tool", @@ -1214,16 +1347,25 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r content, workspace, sandbox: READONLY_RUNNER_SANDBOX, + session, sessionMode: READONLY_SESSION_MODE, sessionReuse: sessionReuseEvidence(session), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility, + longLivedSessionGate: longLivedSessionGate({ + provider: READONLY_RUNNER_PROVIDER, + runnerKind: READONLY_RUNNER_KIND, + session, + sessionMode: READONLY_SESSION_MODE, + implementationType: READONLY_IMPLEMENTATION_TYPE, + codexStdioFeasibility + }), toolCalls, skills, runner, runnerTrace, - capabilityLevel: "read-only-tools", + capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, providerTrace: { runnerKind: READONLY_RUNNER_KIND, toolCalls: toolCalls.length, @@ -1236,42 +1378,6 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r }; } -function getOrCreateReadOnlySession({ conversationId, sessionId, workspace, now }) { - const requestedSessionId = cleanProtocolId(sessionId, "ses") || null; - const mappedSessionId = readOnlyConversationSessions.get(conversationId); - const effectiveSessionId = requestedSessionId || mappedSessionId || `ses_${randomUUID()}`; - const timestamp = nowIso(now); - let session = readOnlySessions.get(effectiveSessionId); - const reused = Boolean(session); - if (!session) { - session = { - sessionId: effectiveSessionId, - conversationIds: new Set(), - workspace, - createdAt: timestamp, - lastUsedAt: timestamp, - turn: 0 - }; - readOnlySessions.set(effectiveSessionId, session); - } - session.conversationIds.add(conversationId); - session.workspace = workspace; - session.lastUsedAt = timestamp; - session.turn += 1; - readOnlyConversationSessions.set(conversationId, effectiveSessionId); - pruneReadOnlySessions(); - return { - sessionId: effectiveSessionId, - conversationId, - workspace, - createdAt: session.createdAt, - lastUsedAt: session.lastUsedAt, - turn: session.turn, - reused, - conversationIds: [...session.conversationIds] - }; -} - function sessionReuseEvidence(session) { return { conversationId: session.conversationId, @@ -1282,32 +1388,41 @@ function sessionReuseEvidence(session) { previousTurns: Math.max(0, session.turn - 1), workspace: session.workspace, createdAt: session.createdAt, - lastUsedAt: session.lastUsedAt + updatedAt: session.updatedAt, + idleTimeoutMs: session.idleTimeoutMs, + expiresAt: session.expiresAt, + lastTraceId: session.lastTraceId, + status: session.status }; } -function pruneReadOnlySessions() { - if (readOnlySessions.size <= MAX_READONLY_SESSIONS) return; - const sorted = [...readOnlySessions.entries()] - .sort((a, b) => String(a[1].lastUsedAt).localeCompare(String(b[1].lastUsedAt))); - for (const [sessionId, session] of sorted.slice(0, readOnlySessions.size - MAX_READONLY_SESSIONS)) { - readOnlySessions.delete(sessionId); - for (const conversationId of session.conversationIds) { - if (readOnlyConversationSessions.get(conversationId) === sessionId) { - readOnlyConversationSessions.delete(conversationId); - } - } - } -} - function sessionReplyLine(session) { - return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; reused=${session.reused}; turn=${session.turn}.`; + return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`; } function limitationReplyLine() { return "边界:controlled-readonly-session-registry;not-codex-stdio;not-write-capable;not-durable-session。"; } +function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) { + return registry.release(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + status: "idle" + }) ?? session; +} + +function resolveCodeAgentSessionRegistry(options = {}) { + return options.sessionRegistry && + typeof options.sessionRegistry.acquire === "function" && + typeof options.sessionRegistry.release === "function" && + typeof options.sessionRegistry.describe === "function" + ? options.sessionRegistry + : defaultCodeAgentSessionRegistry; +} + function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) { return { kind, @@ -1326,7 +1441,7 @@ function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = nu durableSession: false, writeCapable: false, readOnly: true, - capabilityLevel: kind === READONLY_RUNNER_KIND ? "read-only-tools" : "text-chat-only", + capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only", toolPolicy: { allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"], blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"] @@ -1344,6 +1459,9 @@ function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = sandbox: READONLY_RUNNER_SANDBOX, sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot", sessionId: session?.sessionId ?? null, + sessionStatus: session?.status ?? null, + idleTimeoutMs: session?.idleTimeoutMs ?? null, + lastTraceId: session?.lastTraceId ?? null, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", @@ -1429,6 +1547,77 @@ function inspectCodexStdioFeasibility(env = process.env) { }; } +function longLivedSessionGate({ + provider, + runnerKind, + session, + sessionMode, + implementationType, + codexStdioFeasibility +} = {}) { + const normalizedProvider = String(provider ?? "").trim(); + const normalizedRunnerKind = String(runnerKind ?? "").trim(); + const normalizedSessionMode = String(sessionMode ?? "").trim(); + const normalizedImplementation = String(implementationType ?? "").trim(); + const blockers = []; + if (normalizedProvider === "openai-responses" || normalizedRunnerKind === OPENAI_FALLBACK_RUNNER_KIND) { + blockers.push({ + code: "openai_responses_fallback_not_session", + sourceIssue: "pikasTech/HWLAB#317", + summary: "OpenAI Responses fallback is text-chat-only and cannot pass the long-lived Codex session gate." + }); + } + if (normalizedRunnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND || normalizedSessionMode === "ephemeral-one-shot") { + blockers.push({ + code: "one_shot_runner_not_long_lived", + sourceIssue: "pikasTech/HWLAB#317", + summary: "codex exec --ephemeral / one-shot runner output is not a reusable long-lived runner session." + }); + } + if (normalizedImplementation === READONLY_IMPLEMENTATION_TYPE) { + blockers.push({ + code: "stdio_protocol_not_wired", + 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." + }); + } + for (const blocker of codexStdioFeasibility?.blockers ?? []) { + if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue; + blockers.push({ + code: blocker.code, + sourceIssue: blocker.sourceIssue ?? "pikasTech/HWLAB#317", + summary: blocker.summary ?? `Codex stdio feasibility blocker: ${blocker.code}.` + }); + } + + const pass = + normalizedProvider === READONLY_RUNNER_PROVIDER && + normalizedRunnerKind !== OPENAI_FALLBACK_RUNNER_KIND && + normalizedRunnerKind !== CODEX_CLI_ONE_SHOT_RUNNER_KIND && + session?.longLivedSession === true && + session?.codexStdio === true && + codexStdioFeasibility?.canStartLongLivedCodexStdio === true && + blockers.length === 0; + + return { + status: pass ? "pass" : "blocked", + pass, + requiredCapability: "long-lived-codex-stdio-session", + currentCapability: normalizedImplementation || normalizedSessionMode || normalizedRunnerKind || "unknown", + sessionId: session?.sessionId ?? null, + sessionStatus: session?.status ?? null, + runnerKind: normalizedRunnerKind || null, + provider: normalizedProvider || null, + sessionMode: normalizedSessionMode || null, + implementationType: normalizedImplementation || null, + blockers, + sourceIssue: "pikasTech/HWLAB#317", + summary: pass + ? "Long-lived Codex stdio/session gate passed." + : "Long-lived Codex stdio/session gate remains blocked; current response must not be reported as full #317 completion." + }; +} + function envForFeasibility(env = process.env) { return { PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin", @@ -1688,6 +1877,27 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti workspace: repoRoot, sandbox: READONLY_RUNNER_SANDBOX, sessionMode: "ephemeral-one-shot", + session: { + sessionId: conversationId, + conversationId, + status: "expired", + workspace: repoRoot, + sandbox: READONLY_RUNNER_SANDBOX, + runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, + capabilityLevel: "text-chat-only", + implementationType: "codex-cli-one-shot-ephemeral", + createdAt: nowIso(), + updatedAt: nowIso(), + idleTimeoutMs: 0, + expiresAt: nowIso(), + lastTraceId: traceId, + turn: 1, + longLivedSession: false, + codexStdio: false, + durable: false, + secretMaterialStored: false, + valuesRedacted: true + }, sessionReuse: { conversationId, sessionId: conversationId, @@ -1700,6 +1910,13 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti implementationType: "codex-cli-one-shot-ephemeral", runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"], codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)), + longLivedSessionGate: longLivedSessionGate({ + provider: providerPlan.provider, + runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, + sessionMode: "ephemeral-one-shot", + implementationType: "codex-cli-one-shot-ephemeral", + codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)) + }), toolCalls: [], skills: notRequestedSkills(), runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }), @@ -1966,30 +2183,32 @@ function resolveConversationSessionIds(params = {}) { const requestedConversationId = cleanProtocolId(params.conversationId, "cnv"); const requestedSessionId = cleanProtocolId(params.sessionId, "ses"); if (requestedConversationId && requestedSessionId) { - readOnlyConversationSessions.set(requestedConversationId, requestedSessionId); return { conversationId: requestedConversationId, - sessionId: requestedSessionId + sessionId: requestedSessionId, + requestedSessionId }; } if (requestedConversationId) { return { conversationId: requestedConversationId, - sessionId: readOnlyConversationSessions.get(requestedConversationId) || requestedConversationId + sessionId: requestedConversationId, + requestedSessionId: null }; } if (requestedSessionId) { const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`; - readOnlyConversationSessions.set(conversationId, requestedSessionId); return { conversationId, - sessionId: requestedSessionId + sessionId: requestedSessionId, + requestedSessionId }; } const conversationId = `cnv_${randomUUID()}`; return { conversationId, - sessionId: conversationId + sessionId: conversationId, + requestedSessionId: null }; } diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs new file mode 100644 index 00000000..5e4b756e --- /dev/null +++ b/internal/cloud/code-agent-session-registry.mjs @@ -0,0 +1,295 @@ +import { randomUUID } from "node:crypto"; + +export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200; +export const CODE_AGENT_SESSION_STATUSES = Object.freeze([ + "creating", + "ready", + "busy", + "idle", + "interrupted", + "expired", + "failed" +]); + +export function createCodeAgentSessionRegistry(options = {}) { + const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS); + const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS); + const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`; + const defaultNow = options.now; + const sessions = new Map(); + const conversations = new Map(); + + function acquire(params = {}) { + const timestamp = timestampFor(params.now ?? defaultNow); + const timestampMs = Date.parse(timestamp); + const conversationId = requiredId(params.conversationId, "cnv"); + const requestedSessionId = optionalId(params.sessionId); + const mappedSessionId = conversations.get(conversationId) ?? null; + + if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) { + const mappedSession = sessions.get(mappedSessionId) ?? null; + return blockedAcquire({ + code: "session_reuse_conflict", + summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`, + session: mappedSession, + timestamp, + traceId: params.traceId + }); + } + + const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses"); + let session = sessions.get(effectiveSessionId) ?? null; + const reused = Boolean(session); + + if (session && sessionExpired(session, timestampMs)) { + session.status = "expired"; + session.updatedAt = timestamp; + session.lastTraceId = optionalId(params.traceId); + return blockedAcquire({ + code: "session_expired", + summary: `Code Agent runner session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`, + session, + timestamp, + traceId: params.traceId + }); + } + + if (session?.status === "busy") { + session.updatedAt = timestamp; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + return blockedAcquire({ + code: "session_busy", + summary: `Code Agent runner session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`, + session, + timestamp, + traceId: params.traceId + }); + } + + if (!session) { + session = { + sessionId: effectiveSessionId, + conversationIds: new Set(), + status: "creating", + workspace: params.workspace ?? null, + sandbox: params.sandbox ?? "read-only", + runnerKind: params.runnerKind ?? "hwlab-readonly-runner", + capabilityLevel: params.capabilityLevel ?? "read-only-session-tools", + implementationType: params.implementationType ?? "controlled-readonly-session-registry", + createdAt: timestamp, + updatedAt: timestamp, + idleTimeoutMs, + expiresAt: plusMs(timestampMs, idleTimeoutMs), + lastTraceId: optionalId(params.traceId), + currentTraceId: null, + turn: 0, + durable: false, + longLivedSession: false, + codexStdio: false, + secretMaterialStored: false + }; + sessions.set(effectiveSessionId, session); + } + + session.conversationIds.add(conversationId); + session.workspace = params.workspace ?? session.workspace; + session.sandbox = params.sandbox ?? session.sandbox; + session.runnerKind = params.runnerKind ?? session.runnerKind; + session.capabilityLevel = params.capabilityLevel ?? session.capabilityLevel; + session.implementationType = params.implementationType ?? session.implementationType; + session.status = "busy"; + session.updatedAt = timestamp; + session.expiresAt = plusMs(timestampMs, idleTimeoutMs); + session.lastTraceId = optionalId(params.traceId); + session.currentTraceId = optionalId(params.traceId); + session.turn += 1; + conversations.set(conversationId, effectiveSessionId); + prune(); + + return { + ok: true, + reused, + session: publicSession(session, { conversationId, reused, timestamp }) + }; + } + + function release(sessionId, params = {}) { + const session = sessions.get(requiredId(sessionId, "ses")) ?? null; + if (!session) return null; + const timestamp = timestampFor(params.now ?? defaultNow); + const timestampMs = Date.parse(timestamp); + session.status = CODE_AGENT_SESSION_STATUSES.includes(params.status) ? params.status : "idle"; + session.updatedAt = timestamp; + session.expiresAt = plusMs(timestampMs, session.idleTimeoutMs); + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + session.currentTraceId = null; + session.statusReason = params.statusReason ?? null; + return publicSession(session, { + conversationId: params.conversationId, + reused: params.reused, + timestamp + }); + } + + function fail(sessionId, params = {}) { + return release(sessionId, { ...params, status: "failed" }); + } + + function interrupt(sessionId, params = {}) { + return release(sessionId, { ...params, status: "interrupted" }); + } + + function get(sessionId, params = {}) { + const session = sessions.get(requiredId(sessionId, "ses")) ?? null; + return session ? publicSession(session, params) : null; + } + + function getByConversation(conversationId, params = {}) { + const sessionId = conversations.get(requiredId(conversationId, "cnv")); + return sessionId ? get(sessionId, { ...params, conversationId }) : null; + } + + function expireIdle(params = {}) { + const timestamp = timestampFor(params.now ?? defaultNow); + const timestampMs = Date.parse(timestamp); + let expired = 0; + for (const session of sessions.values()) { + if (!sessionExpired(session, timestampMs)) continue; + session.status = "expired"; + session.updatedAt = timestamp; + session.currentTraceId = null; + expired += 1; + } + return expired; + } + + function describe() { + return { + kind: "code-agent-session-registry", + implementationType: "controlled-readonly-session-registry", + status: "available", + sessionCount: sessions.size, + maxSessions, + idleTimeoutMs, + statuses: [...CODE_AGENT_SESSION_STATUSES], + longLivedCodexStdio: false, + durable: false, + secretMaterialStored: false, + valuesRedacted: true + }; + } + + function clear() { + sessions.clear(); + conversations.clear(); + } + + function prune() { + if (sessions.size <= maxSessions) return; + const sorted = [...sessions.entries()] + .sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt))); + for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) { + sessions.delete(sessionId); + for (const conversationId of session.conversationIds) { + if (conversations.get(conversationId) === sessionId) { + conversations.delete(conversationId); + } + } + } + } + + return { + acquire, + release, + fail, + interrupt, + get, + getByConversation, + expireIdle, + describe, + clear + }; +} + +function blockedAcquire({ code, summary, session, timestamp, traceId }) { + const publicEvidence = session + ? publicSession(session, { timestamp, reused: true }) + : { + status: "failed", + updatedAt: timestamp, + lastTraceId: optionalId(traceId), + secretMaterialStored: false, + valuesRedacted: true + }; + return { + ok: false, + code, + message: summary, + session: publicEvidence, + blocker: { + code, + sourceIssue: "pikasTech/HWLAB#317", + summary + } + }; +} + +function publicSession(session, { conversationId = null, reused = null } = {}) { + const conversationIds = [...session.conversationIds]; + return { + sessionId: session.sessionId, + conversationId: conversationId ?? conversationIds[0] ?? null, + conversationIds, + status: session.status, + workspace: session.workspace, + sandbox: session.sandbox, + runnerKind: session.runnerKind, + capabilityLevel: session.capabilityLevel, + implementationType: session.implementationType, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + idleTimeoutMs: session.idleTimeoutMs, + expiresAt: session.expiresAt, + lastTraceId: session.lastTraceId, + currentTraceId: session.currentTraceId, + turn: session.turn, + reused: reused === null ? undefined : Boolean(reused), + durable: session.durable === true, + longLivedSession: session.longLivedSession === true, + codexStdio: session.codexStdio === true, + secretMaterialStored: false, + valuesRedacted: true, + ...(session.statusReason ? { statusReason: session.statusReason } : {}) + }; +} + +function sessionExpired(session, timestampMs) { + if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired"; + return Date.parse(session.expiresAt) <= timestampMs; +} + +function timestampFor(now) { + const value = typeof now === "function" ? now() : now; + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value; + if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString(); + return new Date().toISOString(); +} + +function plusMs(timestampMs, offsetMs) { + return new Date(timestampMs + offsetMs).toISOString(); +} + +function requiredId(value, fallbackPrefix) { + const id = optionalId(value); + if (id) return id; + return `${fallbackPrefix}_${randomUUID()}`; +} + +function optionalId(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function positiveInteger(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs new file mode 100644 index 00000000..68fe28b1 --- /dev/null +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; +import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; +import { + classifyCodexRunnerCapability, + classifyCodeAgentChatReadiness +} from "../../scripts/src/code-agent-response-contract.mjs"; + +test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => { + const registry = createCodeAgentSessionRegistry({ + idleTimeoutMs: 1000, + idFactory: () => "ses_test-registry" + }); + const first = registry.acquire({ + conversationId: "cnv_registry", + workspace: "/workspace/hwlab", + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_registry_1", + now: "2026-05-23T00:00:00.000Z" + }); + assert.equal(first.ok, true); + assert.equal(first.reused, false); + assert.equal(first.session.sessionId, "ses_test-registry"); + assert.equal(first.session.status, "busy"); + 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); + registry.release(first.session.sessionId, { + conversationId: "cnv_registry", + traceId: "trc_registry_1", + reused: first.session.reused, + now: "2026-05-23T00:00:00.010Z" + }); + + const second = registry.acquire({ + conversationId: "cnv_registry", + workspace: "/workspace/hwlab", + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_registry_2", + now: "2026-05-23T00:00:00.500Z" + }); + assert.equal(second.ok, true); + assert.equal(second.reused, true); + assert.equal(second.session.sessionId, "ses_test-registry"); + assert.equal(second.session.turn, 2); + assert.equal(second.session.lastTraceId, "trc_registry_2"); + registry.release(second.session.sessionId, { + conversationId: "cnv_registry", + traceId: "trc_registry_2", + reused: second.session.reused, + now: "2026-05-23T00:00:00.510Z" + }); + + const expired = registry.acquire({ + conversationId: "cnv_registry", + workspace: "/workspace/hwlab", + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_registry_expired", + now: "2026-05-23T00:00:02.000Z" + }); + assert.equal(expired.ok, false); + assert.equal(expired.code, "session_expired"); + assert.equal(expired.session.status, "expired"); + assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317"); +}); + +test("code agent session registry reports busy sessions as structured blocker", () => { + const registry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_busy" + }); + const first = registry.acquire({ + conversationId: "cnv_busy", + workspace: "/workspace/hwlab", + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_busy_1", + now: "2026-05-23T00:00:00.000Z" + }); + assert.equal(first.ok, true); + const busy = registry.acquire({ + conversationId: "cnv_busy", + workspace: "/workspace/hwlab", + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_busy_2", + now: "2026-05-23T00:00:01.000Z" + }); + assert.equal(busy.ok, false); + assert.equal(busy.code, "session_busy"); + assert.equal(busy.session.status, "busy"); + assert.equal(busy.session.currentTraceId, "trc_busy_1"); +}); + +test("read-only runner returns session registry evidence and blocked long-lived gate", async () => { + const registry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_chat_registry" + }); + const first = await handleCodeAgentChat( + { + conversationId: "cnv_chat_registry", + traceId: "trc_chat_registry_pwd", + message: "pwd" + }, + { + now: () => "2026-05-23T00:01:00.000Z", + sessionRegistry: registry, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + validateCodeAgentChatSchema(first); + assert.equal(first.status, "completed"); + assert.equal(first.sessionId, "ses_chat_registry"); + assert.equal(first.session.sessionId, "ses_chat_registry"); + assert.equal(first.session.status, "idle"); + 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.longLivedSessionGate.status, "blocked"); + assert.equal(first.longLivedSessionGate.pass, false); + assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); + + const second = await handleCodeAgentChat( + { + conversationId: "cnv_chat_registry", + traceId: "trc_chat_registry_ls", + message: "ls ." + }, + { + now: () => "2026-05-23T00:01:01.000Z", + sessionRegistry: registry, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + assert.equal(second.status, "completed"); + assert.equal(second.sessionId, first.sessionId); + assert.equal(second.sessionReuse.reused, true); + assert.equal(second.sessionReuse.turn, 2); + assert.equal(second.session.lastTraceId, "trc_chat_registry_ls"); + assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, true); +}); + +test("expired and busy session states are surfaced as structured blockers", async () => { + const expiredRegistry = createCodeAgentSessionRegistry({ + idleTimeoutMs: 10, + idFactory: () => "ses_expired_chat" + }); + const first = expiredRegistry.acquire({ + conversationId: "cnv_expired_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_expired_seed", + now: "2026-05-23T00:02:00.000Z" + }); + expiredRegistry.release(first.session.sessionId, { + conversationId: "cnv_expired_chat", + traceId: "trc_expired_seed", + now: "2026-05-23T00:02:00.001Z" + }); + const expired = await handleCodeAgentChat( + { + conversationId: "cnv_expired_chat", + traceId: "trc_expired_request", + message: "pwd" + }, + { + now: () => "2026-05-23T00:02:01.000Z", + sessionRegistry: expiredRegistry, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + assert.equal(expired.status, "failed"); + assert.equal(expired.error.code, "session_expired"); + assert.equal(expired.session.status, "expired"); + assert.equal(expired.capabilityLevel, "blocked"); + assert.equal(expired.longLivedSessionGate.status, "blocked"); + + const busyRegistry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_busy_chat" + }); + busyRegistry.acquire({ + conversationId: "cnv_busy_chat", + workspace: process.cwd(), + sandbox: "read-only", + runnerKind: "hwlab-readonly-runner", + capabilityLevel: "read-only-session-tools", + traceId: "trc_busy_seed", + now: "2026-05-23T00:03:00.000Z" + }); + const busy = await handleCodeAgentChat( + { + conversationId: "cnv_busy_chat", + traceId: "trc_busy_request", + message: "pwd" + }, + { + now: () => "2026-05-23T00:03:01.000Z", + sessionRegistry: busyRegistry, + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + assert.equal(busy.status, "failed"); + assert.equal(busy.error.code, "session_busy"); + assert.equal(busy.session.status, "busy"); + assert.equal(busy.capabilityLevel, "blocked"); + assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317"); +}); + +test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => { + const fallback = await handleCodeAgentChat( + { + conversationId: "cnv_fallback_gate", + traceId: "trc_fallback_gate", + message: "你好" + }, + { + now: () => "2026-05-23T00:04:00.000Z", + callProvider: async ({ providerPlan }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: "普通文本回复。", + usage: null, + providerTrace: { + source: "test-provider" + } + }) + } + ); + assert.equal(fallback.status, "completed"); + assert.equal(fallback.runner.kind, "openai-responses-fallback"); + assert.equal(fallback.capabilityLevel, "text-chat-only"); + assert.equal(fallback.longLivedSessionGate.status, "blocked"); + assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session")); + assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false); + + const oneShot = { + ...fallback, + provider: "codex-cli", + backend: "hwlab-cloud-api/codex-cli", + runner: { + kind: "codex-cli-one-shot-ephemeral", + codexStdio: false, + writeCapable: false, + durableSession: false + }, + sessionMode: "ephemeral-one-shot", + implementationType: "codex-cli-one-shot-ephemeral", + longLivedSessionGate: { + status: "blocked", + pass: false, + blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }] + } + }; + assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false); + assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true); +}); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 8900cbe8..b733a40a 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -343,7 +343,8 @@ async function handleCodeAgentChatHttp(request, response, options) { env: options.env, workspace: options.workspace, skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact + skillsDirsExact: options.skillsDirsExact, + sessionRegistry: options.sessionRegistry } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index b0c34e09..f63af7f8 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -73,7 +73,10 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.codeAgent.reason, null); assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(healthPayload.codeAgent.runner.ready, true); - assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-tools"); + assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); + assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked"); + assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); @@ -94,7 +97,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.db.ready, false); assert.equal(healthLivePayload.codeAgent.status, "available"); assert.equal(healthLivePayload.codeAgent.ready, true); - assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-tools"); + assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-session-tools"); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(readiness.status, 200); @@ -848,7 +851,9 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.reason, null); assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(payload.codeAgent.runner.ready, true); - assert.equal(payload.codeAgent.capabilityLevel, "read-only-tools"); + assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(payload.codeAgent.sessionRegistry.status, "available"); + assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]); assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY"); assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); @@ -978,9 +983,14 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-readonly-runner"); assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner"); - assert.equal(payload.capabilityLevel, "read-only-tools"); + assert.equal(payload.capabilityLevel, "read-only-session-tools"); assert.equal(payload.workspace, workspace); assert.equal(payload.sandbox, "read-only"); + assert.equal(payload.session.status, "idle"); + assert.equal(payload.session.workspace, workspace); + assert.equal(payload.session.sandbox, "read-only"); + 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.codexStdio, false); @@ -991,8 +1001,11 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.implementationType, "controlled-readonly-session-registry"); assert.equal(payload.sessionReuse.reused, false); assert.equal(payload.sessionReuse.turn, 1); + assert.equal(payload.sessionReuse.status, "idle"); assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing")); + assert.equal(payload.longLivedSessionGate.status, "blocked"); + assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); assert.equal(payload.toolCalls[0].name, "pwd"); assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].stdout, workspace); @@ -1135,7 +1148,7 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version const payload = await response.json(); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-readonly-runner"); - assert.equal(payload.capabilityLevel, "read-only-tools"); + assert.equal(payload.capabilityLevel, "read-only-session-tools"); assert.equal(payload.skills.status, "ready"); assert.equal(payload.skills.items[0].name, "alpha-skill"); assert.equal(payload.skills.items[0].summary, "Alpha skill summary."); diff --git a/package.json b/package.json index 01a3dc07..57246c05 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/reports/dev-gate/dev-cloud-workbench-dom-only.json b/reports/dev-gate/dev-cloud-workbench-dom-only.json index 303ced4b..0dddc3b4 100644 --- a/reports/dev-gate/dev-cloud-workbench-dom-only.json +++ b/reports/dev-gate/dev-cloud-workbench-dom-only.json @@ -410,13 +410,15 @@ }, { "id": "live-gate-route", - "status": "pass", - "summary": "Direct /gate route opens the internal diagnostics view without becoming the default workbench.", + "status": "blocked", + "summary": "Direct /gate route was observed in an older DOM-only report, but the checked-in observations do not include the current single-table gate contract fields; kept BLOCKED instead of inferring DEV-LIVE gate readiness.", "evidence": [ "pathname=/gate", "gateHidden=false", "diagnosticsTermsPresent=true", - "codeAgentPostAttempts=0" + "codeAgentPostAttempts=0", + "single-table gate observations missing", + "kept blocked to avoid false green" ], "observations": { "pathname": "/gate", @@ -432,7 +434,8 @@ "attemptedCount": 0, "attempts": [] } - } + }, + "blocker": "observability_blocker" }, { "id": "live-help-markdown-route", @@ -517,6 +520,12 @@ "scope": "live-browser-dom", "status": "open", "summary": "Browser DOM did not satisfy the workbench contract." + }, + { + "type": "observability_blocker", + "scope": "live-gate-route", + "status": "open", + "summary": "Checked-in DOM-only /gate observations are missing current single-table contract fields; rerun DOM-only smoke after DEV rollout for fresh evidence." } ], "safety": { diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 52383a6e..b1d90ea2 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -179,7 +179,12 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.provider, "codex-readonly-runner"); assert.equal(runnerPwd.workspace, process.cwd()); assert.equal(runnerPwd.sandbox, "read-only"); - assert.equal(runnerPwd.capabilityLevel, "read-only-tools"); + assert.equal(runnerPwd.capabilityLevel, "read-only-session-tools"); + assert.equal(runnerPwd.session.status, "idle"); + assert.equal(runnerPwd.session.workspace, process.cwd()); + assert.equal(runnerPwd.session.sandbox, "read-only"); + assert.equal(runnerPwd.session.idleTimeoutMs, 1800000); + assert.equal(runnerPwd.session.lastTraceId, "trc_code-agent-chat-runner-pwd"); assert.equal(runnerPwd.sessionMode, "controlled-readonly-session-registry"); assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry"); assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry"); @@ -188,13 +193,18 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.runner.durableSession, false); assert.equal(runnerPwd.sessionReuse.reused, false); assert.equal(runnerPwd.sessionReuse.turn, 1); + assert.equal(runnerPwd.sessionReuse.status, "idle"); assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio")); assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked"); + assert.equal(runnerPwd.longLivedSessionGate.status, "blocked"); + assert.equal(runnerPwd.longLivedSessionGate.pass, false); + assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); assert.equal(runnerPwd.toolCalls[0].name, "pwd"); assert.equal(runnerPwd.toolCalls[0].status, "completed"); const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 }); assert.equal(runnerCapability.status, "pass"); assert.equal(runnerCapability.capabilityPass, true); + assert.equal(runnerCapability.longLivedSession, false); logOk("read-only runner pwd capability"); const runnerSecondTurn = await handleCodeAgentChat( @@ -214,8 +224,10 @@ async function runLocalContractSmoke() { validateCodeAgentChatSchema(runnerSecondTurn); assert.equal(runnerSecondTurn.status, "completed"); assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId); + assert.equal(runnerSecondTurn.session.sessionId, runnerPwd.session.sessionId); assert.equal(runnerSecondTurn.sessionReuse.reused, true); assert.equal(runnerSecondTurn.sessionReuse.turn, 2); + assert.equal(runnerSecondTurn.session.lastTraceId, "trc_code-agent-chat-runner-session-reuse"); assert.equal(runnerSecondTurn.toolCalls[0].name, "ls"); assert.equal(runnerSecondTurn.toolCalls[0].status, "completed"); assert.match(runnerSecondTurn.reply.content, /package\.json/u); @@ -292,7 +304,8 @@ async function runLocalContractSmoke() { assert.equal(boundedRgFiles.status, "completed"); assert.equal(boundedRgFiles.toolCalls[0].name, "rg --files"); assert.equal(boundedRgFiles.toolCalls[0].status, "completed"); - assert.match(boundedRgFiles.reply.content, /package\.json/u); + assert.ok(boundedRgFiles.toolCalls[0].stdout.length <= 4100); + assert.match(boundedRgFiles.reply.content, /受控只读 rg --files 结果/u); logOk("bounded rg --files read-only tool capability"); const toolUnavailable = await handleCodeAgentChat( @@ -443,6 +456,8 @@ function summarizePayload(payload, options = {}) { skills: responseSummary?.skills ?? null, runnerTrace: responseSummary?.runnerTrace ?? null, codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null, + session: responseSummary?.session ?? null, + longLivedSessionGate: responseSummary?.longLivedSessionGate ?? null, assistantReplyNonEmpty: responseSummary?.hasReply === true, assistantReplyLength: assistantReply.length, error: responseSummary?.error ?? null diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 5d676026..16f910c1 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -1,5 +1,6 @@ const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]); const trustedRunnerProviders = new Set(["codex-readonly-runner"]); +const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]); const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const blockedCodeAgentUiLabels = new Set(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]); const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu; @@ -86,6 +87,10 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { ? payload.runner.runnerLimitations.filter((item) => typeof item === "string") : []; const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null; + const session = payload?.session && typeof payload.session === "object" ? payload.session : null; + const longLivedSessionGate = payload?.longLivedSessionGate && typeof payload.longLivedSessionGate === "object" + ? payload.longLivedSessionGate + : null; if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") { return { @@ -101,11 +106,15 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { if (payload?.status !== "completed") missing.push("completed status"); if (!trustedRunnerProviders.has(provider)) missing.push("runner provider"); if (runnerKind !== "hwlab-readonly-runner") missing.push("runner.kind"); - if (capabilityLevel !== "read-only-tools") missing.push("capabilityLevel=read-only-tools"); + if (!readonlySessionCapabilityLevels.has(capabilityLevel)) missing.push("capabilityLevel=read-only-session-tools"); if (!workspace) missing.push("workspace"); if (sandbox !== "read-only") missing.push("sandbox=read-only"); if (sessionMode !== "controlled-readonly-session-registry") missing.push("sessionMode=controlled-readonly-session-registry"); if (implementationType !== "controlled-readonly-session-registry") missing.push("implementationType=controlled-readonly-session-registry"); + if (!session) missing.push("session"); + if (session && !["idle", "ready", "busy"].includes(session.status)) missing.push("session.status active/idle"); + if (session && typeof session.idleTimeoutMs !== "number") missing.push("session.idleTimeoutMs"); + if (session && !session.lastTraceId) missing.push("session.lastTraceId"); if (!sessionReuse) missing.push("sessionReuse"); if (toolCalls.length === 0) missing.push("toolCalls"); if (!runnerTrace) missing.push("runnerTrace"); @@ -114,6 +123,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { if (payload?.runner?.codexStdio !== false) missing.push("runner.codexStdio=false"); if (payload?.runner?.writeCapable !== false) missing.push("runner.writeCapable=false"); if (payload?.runner?.durableSession !== false) missing.push("runner.durableSession=false"); + if (!longLivedSessionGate) missing.push("longLivedSessionGate"); if (missing.length > 0) { return { @@ -125,6 +135,16 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { }; } + if (longLivedSessionGate?.status === "pass") { + return { + status: "blocked", + level: "BLOCKED/runner-gate", + blocker: "unexpected-long-lived-green", + capabilityPass: false, + reason: "read-only session registry responses must not claim the full long-lived Codex stdio gate." + }; + } + if (toolCalls.some((tool) => tool.status !== "completed")) { return { status: "blocked", @@ -137,10 +157,11 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { return { status: "pass", - level: "#275 read-only Codex runner capability pass", + level: "#317 read-only session registry partial pass", blocker: null, capabilityPass: true, - reason: "completed read-only runner response includes workspace, sandbox, toolCalls, skills, and runnerTrace evidence" + longLivedSession: false, + reason: "completed read-only session-registry response includes session, workspace, sandbox, toolCalls, skills, runnerTrace, and a blocked long-lived Codex stdio gate" }; } @@ -240,9 +261,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId runnerTrace: null, sessionMode: null, sessionReuse: null, + session: null, implementationType: null, runnerLimitations: [], - codexStdioFeasibility: null + codexStdioFeasibility: null, + longLivedSessionGate: null }; } @@ -285,9 +308,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId runnerTrace: summarizeRunnerTrace(payload.runnerTrace), sessionMode: stringOrNull(payload.sessionMode), sessionReuse: summarizeSessionReuse(payload.sessionReuse), + session: summarizeSession(payload.session), implementationType: stringOrNull(payload.implementationType), runnerLimitations: summarizeStringList(payload.runnerLimitations), - codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility) + codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility), + longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate) }; } @@ -310,9 +335,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId runnerTrace: summarizeRunnerTrace(payload.runnerTrace), sessionMode: stringOrNull(payload.sessionMode), sessionReuse: summarizeSessionReuse(payload.sessionReuse), + session: summarizeSession(payload.session), implementationType: stringOrNull(payload.implementationType), runnerLimitations: summarizeStringList(payload.runnerLimitations), - codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility) + codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility), + longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate) }; } @@ -409,6 +436,9 @@ export function inspectRealCompletionEvidence(payload) { if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") { return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" }; } + if (provider === "codex-readonly-runner" && payload?.longLivedSessionGate?.status === "pass") { + return { ok: false, reason: "read-only runner cannot claim long-lived session gate pass" }; + } if (untrustedProviderPattern.test(`${provider} ${backend}`)) { return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` }; } @@ -521,7 +551,32 @@ function summarizeSessionReuse(value) { reused: value.reused === true, turn: typeof value.turn === "number" ? value.turn : null, previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null, - workspace: stringOrNull(value.workspace) + workspace: stringOrNull(value.workspace), + status: stringOrNull(value.status), + idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null, + lastTraceId: stringOrNull(value.lastTraceId) + }; +} + +function summarizeSession(value) { + if (!value || typeof value !== "object") return null; + return { + sessionId: stringOrNull(value.sessionId), + conversationId: stringOrNull(value.conversationId), + status: stringOrNull(value.status), + workspace: stringOrNull(value.workspace), + sandbox: stringOrNull(value.sandbox), + runnerKind: stringOrNull(value.runnerKind), + capabilityLevel: stringOrNull(value.capabilityLevel), + implementationType: stringOrNull(value.implementationType), + createdAt: stringOrNull(value.createdAt), + updatedAt: stringOrNull(value.updatedAt), + idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null, + lastTraceId: stringOrNull(value.lastTraceId), + turn: typeof value.turn === "number" ? value.turn : null, + longLivedSession: value.longLivedSession === true, + codexStdio: value.codexStdio === true, + secretMaterialStored: value.secretMaterialStored === true }; } @@ -542,6 +597,26 @@ function summarizeCodexStdioFeasibility(value) { }; } +function summarizeLongLivedSessionGate(value) { + if (!value || typeof value !== "object") return null; + return { + status: stringOrNull(value.status), + pass: value.pass === true, + requiredCapability: stringOrNull(value.requiredCapability), + currentCapability: stringOrNull(value.currentCapability), + sessionId: stringOrNull(value.sessionId), + sessionStatus: stringOrNull(value.sessionStatus), + runnerKind: stringOrNull(value.runnerKind), + provider: stringOrNull(value.provider), + blockers: Array.isArray(value.blockers) + ? value.blockers.map((blocker) => ({ + code: stringOrNull(blocker.code), + sourceIssue: stringOrNull(blocker.sourceIssue) + })) + : [] + }; +} + function summarizeStringList(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; } diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 5a6e83ab..b0a446e5 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -2222,8 +2222,10 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) && /recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) && /recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) && + /recordField\("session",\s*sessionSummary\(message\.session\)\)/u.test(messageEvidenceBody) && /recordField\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) && /recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) && + /recordField\("longLivedGate",\s*longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/u.test(messageEvidenceBody) && /recordField\("implementation",\s*message\.implementationType\)/u.test(messageEvidenceBody) && /recordField\("limitations",\s*limitationsSummary\(message\.runnerLimitations\)\)/u.test(messageEvidenceBody) && /recordField\("codexStdio",\s*codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/u.test(messageEvidenceBody) && @@ -2238,6 +2240,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) && /CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) && /controlled-readonly-session-registry/u.test(runnerEvidenceBody) && + /read-only-session-tools/u.test(runnerEvidenceBody) && + /value\?\.session\?\.status === "idle"/u.test(runnerEvidenceBody) && + /value\?\.longLivedSessionGate\?\.status === "blocked"/u.test(runnerEvidenceBody) && /not-codex-stdio/u.test(runnerEvidenceBody) && /value\?\.runner\?\.codexStdio === false/u.test(runnerEvidenceBody) && /value\?\.runner\?\.writeCapable === false/u.test(runnerEvidenceBody) && diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 15f42813..8acaee46 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -363,11 +363,13 @@ function initCommandBar() { backend: result.backend, workspace: result.workspace, sandbox: result.sandbox, + session: result.session, sessionMode: result.sessionMode, sessionReuse: result.sessionReuse, implementationType: result.implementationType, runnerLimitations: result.runnerLimitations, codexStdioFeasibility: result.codexStdioFeasibility, + longLivedSessionGate: result.longLivedSessionGate, toolCalls: result.toolCalls, skills: result.skills, runner: result.runner, @@ -2142,11 +2144,13 @@ function messageEvidenceFields(message) { recordField("workspace", message.workspace), recordField("sandbox", message.sandbox), recordField("capability", message.capabilityLevel), + recordField("session", sessionSummary(message.session)), recordField("sessionMode", message.sessionMode), recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)), recordField("implementation", message.implementationType), recordField("limitations", limitationsSummary(message.runnerLimitations)), recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)), + recordField("longLivedGate", longLivedSessionGateSummary(message.longLivedSessionGate)), recordField("toolCalls", toolCallsSummary(message.toolCalls)), recordField("skills", skillsSummary(message.skills)), recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)), @@ -2197,7 +2201,19 @@ function runnerTraceSummary(runnerTrace) { function sessionReuseSummary(sessionReuse) { if (!sessionReuse || typeof sessionReuse !== "object") return null; const state = sessionReuse.reused ? "reused" : "new"; - return `${state}:turn${sessionReuse.turn ?? "?"}`; + const status = sessionReuse.status ? `:${sessionReuse.status}` : ""; + return `${state}:turn${sessionReuse.turn ?? "?"}${status}`; +} + +function sessionSummary(session) { + if (!session || typeof session !== "object") return null; + const parts = [ + session.sessionId, + session.status, + typeof session.turn === "number" ? `turn${session.turn}` : null, + session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null + ].filter(Boolean); + return parts.join(":"); } function limitationsSummary(limitations) { @@ -2205,6 +2221,15 @@ function limitationsSummary(limitations) { return limitations.slice(0, 3).join(","); } +function longLivedSessionGateSummary(gate) { + if (!gate || typeof gate !== "object") return null; + const status = gate.status ?? "unknown"; + const blockers = Array.isArray(gate.blockers) + ? gate.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",") + : ""; + return blockers ? `${status}:${blockers}` : status; +} + function codexStdioFeasibilitySummary(feasibility) { if (!feasibility || typeof feasibility !== "object") return null; const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0; @@ -2491,7 +2516,10 @@ function isCodexRunnerCapableEvidence(value) { return ( CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && value?.runner?.kind === "hwlab-readonly-runner" && - value?.capabilityLevel === "read-only-tools" && + (value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") && + value?.session?.status === "idle" && + typeof value?.session?.idleTimeoutMs === "number" && + Boolean(value?.session?.lastTraceId) && value?.sessionMode === "controlled-readonly-session-registry" && value?.implementationType === "controlled-readonly-session-registry" && Boolean(value?.sessionReuse) && @@ -2504,6 +2532,7 @@ function isCodexRunnerCapableEvidence(value) { (value?.sandbox || value?.runner?.sandbox) === "read-only" && Array.isArray(value?.toolCalls) && value.toolCalls.some((tool) => tool?.status === "completed") && + value?.longLivedSessionGate?.status === "blocked" && Boolean(value?.runnerTrace) && Boolean(value?.skills) ); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index dbea7efa..62c0b90f 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -496,24 +496,36 @@ assert.match(app, /Boolean\(value\?\.traceId\)/); assert.match(app, /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/); assert.match(app, /value\?\.sessionMode === "controlled-readonly-session-registry"/); assert.match(app, /value\?\.implementationType === "controlled-readonly-session-registry"/); +assert.match(app, /value\?\.capabilityLevel === "read-only-session-tools"/); +assert.match(app, /value\?\.session\?\.status === "idle"/); +assert.match(app, /typeof value\?\.session\?\.idleTimeoutMs === "number"/); +assert.match(app, /Boolean\(value\?\.session\?\.lastTraceId\)/); +assert.match(app, /value\?\.longLivedSessionGate\?\.status === "blocked"/); assert.match(app, /Boolean\(value\?\.sessionReuse\)/); assert.match(app, /value\?\.runner\?\.codexStdio === false/); assert.match(app, /value\?\.runner\?\.writeCapable === false/); assert.match(app, /value\?\.runner\?\.durableSession === false/); assert.match(app, /providerTrace:\s*result\.providerTrace/); +assert.match(app, /session:\s*result\.session/); assert.match(app, /sessionMode:\s*result\.sessionMode/); assert.match(app, /sessionReuse:\s*result\.sessionReuse/); assert.match(app, /implementationType:\s*result\.implementationType/); assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/); assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/); +assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/); assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); assert.match(app, /recordField\("conversation", message\.conversationId\)/); assert.match(app, /recordField\("provider", message\.provider\)/); +assert.match(app, /recordField\("session", sessionSummary\(message\.session\)\)/); assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/); assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/); assert.match(app, /recordField\("implementation", message\.implementationType\)/); assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/); assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/); +assert.match(app, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/); +assert.match(app, /function sessionSummary/); +assert.match(app, /function longLivedSessionGateSummary/); +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/);