diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 4ea6503c..e80625b1 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -18,7 +18,7 @@ - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 - AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 - AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读上下文,必须以脱敏、限长的 partial context 写入 conversation facts,供后续 `inspect`/`--from-trace` 和同一 thread 的下一轮使用。 -- 传给 AgentRun runner Job 的 `transientEnv` 是短期运行上下文,数量不设固定 8 项上限;新增项必须仍满足最小必要、脱敏和 Secret 值不打印原则。 +- 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;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 - 同 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 88591e9e..de5b3cdd 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 permits continuation when stale active trace has idle active conversation", async () => { const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-"); const server = createCloudApiServer({ diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 7100c09b..e437b8a7 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -224,6 +224,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 >= 7); assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false); assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false); return send({ diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 9035ddf3..aaf69822 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1064,16 +1064,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) { const conversationId = safeConversationId(params.conversationId) || selectedConversationId || activeResult?.conversationId || null; const sessionId = safeSessionId(params.sessionId) || safeSessionId(workspace.selectedAgentSessionId) || activeResult?.sessionId || null; @@ -1129,6 +1157,14 @@ function isActiveWorkbenchConversation(conversation = null) { return values.some((value) => ["running", "busy", "pending", "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;