From 66ffd2e1715524839d19b7eb59297d76e802a115 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 02:45:00 +0800 Subject: [PATCH] fix: repair stale terminal workbench conversations --- internal/cloud/access-control.test.ts | 129 ++++++++++++++++++++++++++ internal/cloud/access-control.ts | 38 ++++++-- 2 files changed, 160 insertions(+), 7 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index b43ac5b1..b8c3f2aa 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -454,6 +454,135 @@ test("workbench workspace status clears completed AgentRun active trace on read" } }); +test("workbench workspace status repairs terminal selected conversation after active trace was cleared", async () => { + const agentRunCalls = []; + const agentSessions = new Map(); + const agentRunServer = createServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data })}\n`); + }; + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/events") { + return send({ items: [] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result") { + return send({ + runId: "run_workspace_repair", + commandId: "cmd_workspace_repair", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "repair completed", + lastSeq: 2, + sessionRef: { sessionId: "ses_issue664_repair", conversationId: "cnv_issue664_repair", threadId: "thread-issue-664-repair" } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const accessController = createAccessController({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-01T00:00:00.000Z" + }); + accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; + 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:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" + }, + accessController, + 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-repair", password: "alice-pass" }, adminLogin.cookie); + assert.equal(alice.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-repair", password: "alice-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_repair", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_issue664_repair", + threadId: "thread-issue-664-repair", + sessionStatus: "running", + lastTraceId: "trc_issue664_repair_done", + messages: [] + }, aliceLogin.cookie); + assert.equal(conversation.status, 200); + + agentSessions.set("trc_issue664_repair_done", { + id: "ses_issue664_repair", + ownerUserId: alice.body.user.id, + conversationId: "cnv_issue664_repair", + threadId: "thread-issue-664-repair", + status: "active", + session: { + messageId: "msg_workspace_repair_done", + agentRun: { + runId: "run_workspace_repair", + commandId: "cmd_workspace_repair", + backendProfile: "deepseek", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + lastSeq: 0, + sessionId: "ses_issue664_repair", + conversationId: "cnv_issue664_repair", + threadId: "thread-issue-664-repair" + } + }, + updatedAt: "2026-06-01T00:00:00.000Z" + }); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + selectedConversationId: "cnv_issue664_repair", + selectedAgentSessionId: "ses_issue664_repair", + activeTraceId: null, + providerProfile: "deepseek", + sessionStatus: "idle", + lastTraceId: "trc_issue664_repair_done", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(update.status, 200); + assert.equal(update.body.workspace.activeTraceId, null); + assert.equal(update.body.workspace.selectedConversation.status, "running"); + assert.equal(update.body.workspace.selectedConversation.messages.length, 0); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(restored.status, 200); + assert.equal(restored.body.workspace.activeTraceId, null); + assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done"); + assert.equal(restored.body.workspace.workspace.sessionStatus, "idle"); + assert.equal(restored.body.workspace.selectedConversation.status, "idle"); + assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_repair_done"); + assert.equal(restored.body.workspace.selectedConversation.messages.length, 1); + assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "idle"); + assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "repair completed"); + assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result")); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api access control grants visible device pods and requires device-pod executor", async () => { let directGatewayDispatches = 0; const gatewayRegistry = { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index e5cff7c0..bfe193fc 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -847,7 +847,11 @@ class AccessController { async syncTerminalWorkbenchWorkspace(workspace, actor) { const activeTraceId = safeTraceIdLocal(workspace?.activeTraceId); const codeAgentEnv = this.codeAgentEnv ?? this.env; - if (!workspace || !activeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace; + if (!workspace || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace; + const workspaceJson = normalizeObject(workspace.workspace); + const repairTraceId = activeTraceId ? "" : await this.terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson); + const traceId = activeTraceId || repairTraceId; + if (!traceId) return workspace; const options = { env: codeAgentEnv, fetchImpl: this.fetchImpl, @@ -857,25 +861,24 @@ class AccessController { }; try { - const cached = this.codeAgentChatResults?.get?.(activeTraceId) ?? null; - const persisted = cached ? null : await loadPersistedAgentRunResult(activeTraceId, options); + const cached = this.codeAgentChatResults?.get?.(traceId) ?? null; + const persisted = cached ? null : await loadPersistedAgentRunResult(traceId, options); const current = cached ?? persisted ?? null; if (!current || !canActorReadWorkspaceTrace(current, actor)) return workspace; const synced = current?.agentRun?.runId && current.status === "running" - ? await syncAgentRunChatResult({ traceId: activeTraceId, currentResult: current, options, traceStore: options.traceStore }) + ? await syncAgentRunChatResult({ traceId, currentResult: current, options, traceStore: options.traceStore }) : { result: current }; const result = synced.result ?? current; if (!isTerminalCodeAgentResult(result)) return workspace; const now = this.now(); - const workspaceJson = normalizeObject(workspace.workspace); const selectedConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId; const selectedAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId; await this.syncTerminalWorkbenchConversation({ workspace, actor, result, - activeTraceId, + activeTraceId: traceId, selectedConversationId, selectedAgentSessionId, now @@ -895,7 +898,7 @@ class AccessController { selectedAgentSessionId, activeTraceId: null, sessionStatus: terminalWorkbenchSessionStatus(result), - lastTraceId: activeTraceId, + lastTraceId: traceId, syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null, updatedAt: now, source: "workbench-status-sync", @@ -912,6 +915,17 @@ class AccessController { } } + async terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson) { + try { + const traceId = safeTraceIdLocal(workspaceJson?.lastTraceId); + if (!traceId || !safeConversationIdLocal(workspace?.selectedConversationId)) return ""; + const conversation = await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId); + return conversationNeedsTerminalRepair(conversation, traceId) ? traceId : ""; + } catch { + return ""; + } + } + async publicWorkbenchWorkspace(workspace, actor) { const conversation = workspace?.selectedConversationId ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId) @@ -1985,6 +1999,16 @@ function canActorReadWorkspaceTrace(result = {}, actor = null) { if (!ownerUserId || actor?.role === "admin") return true; return ownerUserId === actor?.id; } +function conversationNeedsTerminalRepair(conversation, traceId) { + if (!conversation || !safeTraceIdLocal(traceId)) return false; + const status = textOr(conversation.status, "").toLowerCase(); + if (["running", "busy", "pending", "active"].includes(status)) return true; + const messages = Array.isArray(conversation.messages) ? conversation.messages : []; + const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null; + if (!traceMessage) return true; + const messageStatus = textOr(traceMessage.status, "").toLowerCase(); + return ["", "running", "busy", "pending", "active"].includes(messageStatus); +} function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) { const messages = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : []; const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);