fix: repair stale terminal workbench conversations

This commit is contained in:
Codex
2026-06-02 02:45:00 +08:00
parent bf2bdee5f6
commit 66ffd2e171
2 changed files with 160 additions and 7 deletions
+129
View File
@@ -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 = {
+31 -7
View File
@@ -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);