diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs index b5962036..95699fab 100644 --- a/web/hwlab-cloud-web/live-status.mjs +++ b/web/hwlab-cloud-web/live-status.mjs @@ -158,7 +158,6 @@ function classifyApiProbe(probe, context) { if (codeAgentProbe?.kind === "readonly") { return { ...codeAgentProbe, - apiPath: context.apiPath, rawStatus: rawStatusFrom(payload) }; } @@ -302,6 +301,17 @@ function classifyCodeAgentProbe(live = {}) { meta: { provider, backend, capability } }); } + if (capability === "read-only-session-tools") { + return readOnlyProbe({ + serviceId, + apiPath, + reasonCode: "code_agent_read_only_session_tools", + reason: codeAgentReadOnlySessionToolsReason(availability), + traceId: traceIdFrom(availability), + evidenceSummary: evidenceSummaryFrom(availability), + meta: { provider, backend, capability } + }); + } if (availability.status === "blocked" || availability.ready === false) { return errorProbe({ serviceId, @@ -472,20 +482,22 @@ function classifyM3StatusProbe(live = {}) { function durableRuntimeReason(payload) { const runtime = payload?.runtime; const durability = payload?.readiness?.durability; - const reasonCode = firstNonEmpty( - runtime?.blocker, - durability?.blocker, - runtime?.connection?.queryResult, - durability?.queryResult, - runtime?.durabilityContract?.blockedLayer, - durability?.blockedLayer, - blockerCodeStartingWith(payload, "runtime_durable_adapter_") - ); - const blockedLayer = firstNonEmpty(runtime?.durabilityContract?.blockedLayer, durability?.blockedLayer); const runtimeDurable = runtime?.durable === true || durability?.durable === true; const runtimeExplicitlyNonDurable = runtime?.durable === false || durability?.durable === false; const runtimeReady = runtime?.ready === true || durability?.ready === true; + const durabilityReady = durability?.ready === true || normalizedStatus(durability?.status) === "ready"; const liveRuntimeEvidence = runtime?.liveRuntimeEvidence === true || durability?.liveRuntimeEvidence === true; + if (runtimeDurable && runtimeReady && durabilityReady && !hasDurableRuntimeBlockedSignal(payload)) return null; + const reasonCode = firstNonEmpty( + runtime?.blocker, + durability?.blocker, + runtime?.durabilityContract?.blockedLayer, + durability?.blockedLayer, + blockedDurabilityQueryResult(runtime?.connection?.queryResult), + blockedDurabilityQueryResult(durability?.queryResult), + blockerCodeStartingWith(payload, "runtime_durable_adapter_") + ); + const blockedLayer = firstNonEmpty(runtime?.durabilityContract?.blockedLayer, durability?.blockedLayer); if (!reasonCode && runtimeDurable && runtimeReady && liveRuntimeEvidence) return null; if (runtimeExplicitlyNonDurable) { return { @@ -511,6 +523,27 @@ function durableRuntimeReason(payload) { return null; } +function hasDurableRuntimeBlockedSignal(payload) { + const runtime = payload?.runtime; + const durability = payload?.readiness?.durability; + return [ + runtime?.blocker, + durability?.blocker, + runtime?.durabilityContract?.blockedLayer, + durability?.blockedLayer, + blockedDurabilityQueryResult(runtime?.connection?.queryResult), + blockedDurabilityQueryResult(durability?.queryResult), + blockerCodeStartingWith(payload, "runtime_durable_adapter_") + ].some(Boolean); +} + +function blockedDurabilityQueryResult(value) { + const text = normalizedStatus(value); + if (!text || text === "durable_readiness_ready" || text === "ready") return ""; + if (text.endsWith("_blocked") || text.includes("blocked")) return String(value).trim(); + return ""; +} + function firstBlocker(payload) { const blockers = [ ...(Array.isArray(payload?.blockers) ? payload.blockers : []), @@ -609,6 +642,15 @@ function isReadOnlyReason(value) { return READ_ONLY_REASON_CODES.some((code) => text.includes(code)); } +function codeAgentReadOnlySessionToolsReason(availability) { + const flags = [ + ...(Array.isArray(availability?.runnerLimitations) ? availability.runnerLimitations : []), + ...(Array.isArray(availability?.runner?.limitations) ? availability.runner.limitations : []) + ].filter(Boolean); + const issue = firstNonEmpty(availability?.issue, availability?.trackingIssue, "#317"); + return `capability=read-only-session-tools; ${issue} full pass not complete${flags.length > 0 ? `; ${flags.join(",")}` : ""}`; +} + function traceIdFrom(value) { return firstNonEmpty( value?.traceId, diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs index 524052fb..54c60f1e 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -365,6 +365,88 @@ test("classifies Code Agent provider timeout or fallback with concrete attributi assert.match(fallback.detail, /capability=text-chat-only/u); }); +test("keeps ready durable runtime green while attributing read-only mode to Code Agent session-tools", () => { + const status = classifyWorkbenchLiveStatus({ + healthLive: { + ok: true, + status: 200, + data: { + serviceId: "hwlab-cloud-api", + status: "ok", + ready: true, + runtime: { + status: "ready", + ready: true, + durable: true, + liveRuntimeEvidence: true, + blocker: null, + connection: { + queryResult: "durable_readiness_ready" + } + }, + readiness: { + status: "ready", + ready: true, + durability: { + status: "ready", + ready: true, + runtimeStatus: "ready", + blockedLayer: null, + blocker: null, + queryResult: "durable_readiness_ready" + } + }, + codeAgent: { + endpoint: "POST /v1/agent/chat", + status: "available", + ready: true, + provider: "codex-readonly-runner", + 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"] + } + } + }, + restIndex: { + ok: true, + status: 200, + data: { + serviceId: "hwlab-cloud-api", + status: "ok", + ready: true, + codeAgent: { + endpoint: "POST /v1/agent/chat", + status: "available", + ready: true, + provider: "codex-readonly-runner", + 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"] + } + } + }, + health: okApi, + adapter: okApi, + m3Control: okM3Control, + m3Status: okM3Status + }); + + assert.equal(status.kind, "readonly"); + assert.equal(status.label, "只读模式"); + assert.equal(status.serviceId, "hwlab-cloud-api"); + assert.equal(status.apiPath, "POST /v1/agent/chat"); + assert.equal(status.reasonCode, "code_agent_read_only_session_tools"); + assert.match(status.detail, /capability=read-only-session-tools/u); + assert.match(status.detail, /#317 full pass not complete/u); + assert.doesNotMatch(status.detail, /runtime durable blocked/u); + assert.doesNotMatch(status.detail, /blocked at durability/u); + const healthLiveProbe = status.probes.find((probe) => probe.apiPath === "/health/live"); + assert.equal(healthLiveProbe.kind, "pass"); + assert.doesNotMatch(healthLiveProbe.reason, /blocked/u); +}); + test("classifies M3 control blockers by cloud-api/gateway/box/patch-panel layer", () => { const status = classifyWorkbenchLiveStatus({ healthLive: okApi,