From 666395f0565fc8cd274d80a503fe95ef22396d70 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 08:35:12 +0800 Subject: [PATCH] fix: ignore stale workbench active trace --- internal/cloud/access-control.test.ts | 77 ++++++++++++++++++++++++ internal/cloud/server-code-agent-http.ts | 21 ++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 0d3b9fe1..25a4ff88 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; import { createServer } from "node:http"; import { test } from "bun:test"; @@ -324,6 +325,82 @@ test("workbench workspace permits a new turn after AgentRun active trace reaches } }); +test("workbench workspace ignores stale active trace when selected conversation already failed", async () => { + const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-"); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + OPENAI_API_KEY: "test-openai-key-material" + }, + codexStdioManager: { + describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), + probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }), + chat: async (params = {}) => ({ + status: "completed", + traceId: params.traceId, + conversationId: params.conversationId, + sessionId: params.sessionId, + reply: { role: "assistant", content: "ok" }, + runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 }, + session: { sessionId: params.sessionId, conversationId: params.conversationId, status: "idle" } + }), + cancel() {}, + reapIdle() {} + } + }); + 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 workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_failed", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_stale_active_failed", + threadId: "thread-stale-active", + status: "failed", + sessionStatus: "failed", + lastTraceId: "trc_stale_active_missing", + messages: [{ role: "agent", text: "failed", status: "failed", traceId: "trc_stale_active_missing" }] + }, adminLogin.cookie); + assert.equal(conversation.status, 200); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + selectedConversationId: "cnv_stale_active_failed", + selectedAgentSessionId: "ses_stale_active_failed", + activeTraceId: "trc_stale_active_missing", + sessionStatus: "running", + updatedByClient: "test-suite" + }, adminLogin.cookie); + assert.equal(update.status, 200); + + const next = await postJson(port, "/v1/agent/chat", { + message: "new turn after stale active trace", + traceId: "trc_stale_active_next", + conversationId: "cnv_stale_active_failed", + sessionId: "ses_stale_active_failed", + workspaceId: workspace.body.workspace.workspaceId, + expectedWorkspaceRevision: 2, + shortConnection: true + }, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_next" }); + + assert.equal(next.status, 202); + assert.equal(next.body.traceId, "trc_stale_active_next"); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_next"); + } 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-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 71d253cb..c4b7a723 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -902,7 +902,17 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, } const activeTraceId = safeTraceId(workspace.activeTraceId); const activeResult = activeTraceId ? await resolveWorkbenchActiveTraceResult(activeTraceId, options) : null; - if (activeTraceId && (!activeResult || activeResult.status === "running")) { + const selectedConversationId = safeConversationId(workspace.selectedConversationId); + const shouldInspectSelectedConversation = Boolean( + activeTraceId && !activeResult && 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))) + ); + if (activeTraceBlocksTurn) { sendJson(response, 409, { ok: false, status: "workspace_agent_busy", @@ -941,6 +951,15 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, return { workspace: updated ?? workspace }; } +function isActiveWorkbenchConversation(conversation = null) { + if (!conversation) return false; + const values = [conversation.status, conversation.session?.status, conversation.snapshot?.sessionStatus]; + for (const message of Array.isArray(conversation.messages) ? conversation.messages.slice(-5) : []) { + values.push(message?.status, message?.runnerTrace?.sessionStatus, message?.sessionStatus); + } + return values.some((value) => ["running", "busy", "pending", "active", "queued"].includes(textValue(value).toLowerCase())); +} + async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { if (!safeTraceId(traceId)) return null; const cached = options.codeAgentChatResults?.get?.(traceId) ?? null;