From 48492ba2674fda80e4e6138da996c7a7016f153e Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 08:42:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=8A=E6=94=BE=E5=AD=A4=E5=84=BF=20a?= =?UTF-8?q?ctive=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v02-hwlab-cloud-api.md | 1 + internal/cloud/access-control.test.ts | 66 ++++++++++++++++++++++ internal/cloud/server-agent-chat.test.ts | 2 +- internal/cloud/server-code-agent-http.ts | 40 ++++++++++++- 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index a58b0244..c1bac533 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -16,6 +16,7 @@ - `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 +- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 25a4ff88..59aa7e14 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import { test } from "bun:test"; import { createAccessController } from "./access-control.ts"; +import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { createCloudApiServer } from "./server.ts"; const INTERNAL_TOKEN = "test-internal-token"; @@ -401,6 +402,71 @@ test("workbench workspace ignores stale active trace when selected conversation } }); +test("workbench workspace releases orphan cancel-blocked active trace on new turn", async () => { + const traceStore = createCodeAgentTraceStore(); + traceStore.append("trc_issue664_orphan_active", { + type: "cancel", + stage: "cancel", + status: "blocked", + label: "cancel:not_cancelable", + message: "Cancel request did not include a bound Codex stdio sessionId.", + errorCode: "cancel_session_missing", + waitingFor: "session-binding" + }); + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: "http://127.0.0.1:9", + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" + }, + traceStore, + now: () => "2026-06-01T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-orphan", password: "alice-pass" }, adminLogin.cookie); + assert.equal(alice.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-orphan", password: "alice-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + activeTraceId: "trc_issue664_orphan_active", + providerProfile: "deepseek", + sessionStatus: "running", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(update.status, 200); + assert.equal(update.body.workspace.activeTraceId, "trc_issue664_orphan_active"); + + const next = await postJson(port, "/v1/agent/chat", { + message: "new turn should replace orphan active trace", + traceId: "trc_issue664_after_orphan", + conversationId: "cnv_issue664_after_orphan", + workspaceId: workspace.body.workspace.workspaceId, + expectedWorkspaceRevision: 2, + shortConnection: true + }, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue664_after_orphan" }); + assert.equal(next.status, 202); + assert.equal(next.body.status, "running"); + assert.equal(next.body.traceId, "trc_issue664_after_orphan"); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(restored.body.workspace.activeTraceId, "trc_issue664_after_orphan"); + assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("workbench workspace status clears completed AgentRun active trace on read", async () => { const agentRunCalls = []; const agentSessions = new Map(); diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 1eba39e0..60d9f61b 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -210,7 +210,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_LOCKED, "1"); assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1"); assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token"); - assert.ok(body.transientEnv.length <= 8); + assert.ok(body.transientEnv.length >= 7); return send({ action: "create-kubernetes-job", runId: "run_hwlab_adapter", diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 6a21541d..7da87b5a 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -906,16 +906,44 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, } const activeTraceId = safeTraceId(workspace.activeTraceId); const activeResult = activeTraceId ? await resolveWorkbenchActiveTraceResult(activeTraceId, options) : null; + const activeTraceSnapshot = activeTraceId ? (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(activeTraceId) : null; + const activeTraceOrphaned = Boolean(activeTraceId && !activeResult && isWorkbenchOrphanActiveTrace(activeTraceSnapshot)); const selectedConversationId = safeConversationId(workspace.selectedConversationId); const shouldInspectSelectedConversation = Boolean( - activeTraceId && !activeResult && selectedConversationId && options.accessController?.visibleConversationForActor + activeTraceId && !activeResult && !activeTraceOrphaned && selectedConversationId && options.accessController?.visibleConversationForActor ); const selectedConversation = shouldInspectSelectedConversation ? await options.accessController.visibleConversationForActor(options.actor, selectedConversationId, workspace.projectId) : null; const activeTraceBlocksTurn = Boolean( - activeTraceId && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation))) + activeTraceId && !activeTraceOrphaned && (activeResult?.status === "running" || (!activeResult && isActiveWorkbenchConversation(selectedConversation))) ); + if (activeTraceOrphaned) { + await store.updateWorkspace?.({ + workspaceId, + ownerUserId: options.actor.id, + actorRole: options.actor.role, + selectedConversationId: workspace.selectedConversationId, + selectedAgentSessionId: workspace.selectedAgentSessionId, + selectedDevicePodId: workspace.selectedDevicePodId, + activeTraceId: null, + providerProfile: workspace.providerProfile, + patch: { + ...(workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {}), + activeTraceId: null, + staleActiveTraceId: activeTraceId, + staleActiveTraceReason: "orphan-cancel-session-missing", + sessionStatus: "failed", + updatedAt: new Date().toISOString(), + source: "code-agent-submit-active-trace-repair", + valuesRedacted: true, + secretMaterialStored: false + }, + updatedBySessionId: options.authSession?.id ?? null, + updatedByClient: "code-agent-submit-active-trace-repair", + now: new Date().toISOString() + }); + } if (activeTraceBlocksTurn) { sendJson(response, 409, { ok: false, @@ -964,6 +992,14 @@ function isActiveWorkbenchConversation(conversation = null) { return values.some((value) => ["running", "busy", "pending", "active", "queued"].includes(textValue(value).toLowerCase())); } +function isWorkbenchOrphanActiveTrace(snapshot) { + if (!snapshot || typeof snapshot !== "object") return false; + const last = snapshot.lastEvent && typeof snapshot.lastEvent === "object" ? snapshot.lastEvent : null; + if (!last) return false; + if (last.errorCode !== "cancel_session_missing" && last.label !== "cancel:not_cancelable") return false; + return !snapshot.sessionId && !snapshot.runId && !snapshot.commandId && !snapshot.sessionStatus && !snapshot.turn; +} + async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { if (!safeTraceId(traceId)) return null; const cached = options.codeAgentChatResults?.get?.(traceId) ?? null;