From cee702726c0b47006f734004d54a0ea26541c6ff Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 03:29:55 +0800 Subject: [PATCH] fix: complete v03 AgentRun workbench projection --- internal/cloud/code-agent-agentrun-adapter.ts | 113 ++++++++++++++++- internal/cloud/server-agent-chat.test.ts | 118 ++++++++++++++++-- internal/cloud/server-code-agent-http.ts | 105 ++++++++++++++-- .../scripts/workbench-e2e-server.ts | 55 ++++++-- .../specs/live-backfill-race.spec.ts | 19 +++ 5 files changed, 378 insertions(+), 32 deletions(-) diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 966bb702..f52fcc54 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -922,6 +922,7 @@ function newSessionIdAfterEviction(baseSessionId, traceId) { } function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, sessionId, toolCapabilities = null }) { const commitId = requireAgentRunSourceCommit(env); + const runtimeAuthority = requireAgentRunRuntimeAuthority(env); const threadId = safeOpaqueId(params.threadId); const hwlabProjectId = firstNonEmpty(params.projectId); rejectRemovedResourceWorkspaceFiles(params.workspaceFiles ?? params.resourceWorkspaceFiles); @@ -940,8 +941,8 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile, ses workspaceRef: { kind: "opaque", repo: DEFAULT_PROJECT_ID, - branch: firstNonEmpty(env.HWLAB_BOOT_REF, env.HWLAB_CODE_AGENT_AGENTRUN_BRANCH, "v0.2"), - runtimeNamespace: firstNonEmpty(env.HWLAB_RUNTIME_NAMESPACE, env.POD_NAMESPACE, env.HWLAB_NAMESPACE, "hwlab-v02"), + branch: firstNonEmpty(env.HWLAB_BOOT_REF, env.HWLAB_CODE_AGENT_AGENTRUN_BRANCH, defaultAgentRunBranchForRuntimeLane(runtimeAuthority.lane), "v0.2"), + runtimeNamespace: runtimeAuthority.namespace, hwlabTraceId: traceId, valuesPrinted: false }, @@ -1196,7 +1197,9 @@ async function resolveOwnerApiKey({ params, options, now }) { return text(key.displaySecret ?? ""); } function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities = null, backendProfile }) { + const runtimeAuthority = requireAgentRunRuntimeAuthority(env); const baseTransient = buildAgentRunTransientEnv(env, { providerProfile: backendProfile, parentTraceId: traceId }); + assertAgentRunTransientRuntimeAuthority(baseTransient, runtimeAuthority); const hwpodAllowed = toolCapabilityAllowed(toolCapabilities, "hwpod"); const transientEnv = ownerApiKey && hwpodAllowed ? baseTransient.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }]) @@ -1236,6 +1239,112 @@ function buildAgentRunTransientEnv(env = process.env, options = {}) { return entries; } +function requireAgentRunRuntimeAuthority(env = process.env) { + const explicitLane = normalizeAgentRunRuntimeLane(firstNonEmpty( + env.HWLAB_RUNTIME_LANE, + env.HWLAB_GITOPS_LANE, + env.HWLAB_GITOPS_PROFILE, + env.HWLAB_ENVIRONMENT, + env.HWLAB_BOOT_REF + )); + const namespace = firstNonEmpty( + env.HWLAB_RUNTIME_NAMESPACE, + env.POD_NAMESPACE, + env.HWLAB_NAMESPACE, + namespaceForAgentRunRuntimeLane(explicitLane) + ); + if (!namespace) { + throw agentRunRuntimeAuthorityError("agentrun_runtime_namespace_required", "AgentRun dispatch requires HWLAB_RUNTIME_NAMESPACE/POD_NAMESPACE/HWLAB_NAMESPACE or a supported runtime lane"); + } + const namespaceLane = agentRunRuntimeLaneForNamespace(namespace); + if (!namespaceLane) { + throw agentRunRuntimeAuthorityError("agentrun_runtime_namespace_unsupported", `unsupported AgentRun HWLAB runtime namespace: ${namespace}`); + } + if (explicitLane && explicitLane !== namespaceLane) { + throw agentRunRuntimeAuthorityError("agentrun_runtime_authority_mismatch", `AgentRun HWLAB runtime lane ${explicitLane} does not match namespace ${namespace} (${namespaceLane})`); + } + assertAgentRunEndpointNamespace(env.HWLAB_RUNTIME_API_URL, namespace, "HWLAB_RUNTIME_API_URL"); + assertAgentRunEndpointNamespace(env.HWLAB_RUNTIME_WEB_URL, namespace, "HWLAB_RUNTIME_WEB_URL"); + return { namespace, lane: namespaceLane }; +} + +function assertAgentRunTransientRuntimeAuthority(entries, runtimeAuthority) { + const values = new Map((Array.isArray(entries) ? entries : []).map((entry) => [entry?.name, firstNonEmpty(entry?.value)])); + if (values.get("HWLAB_RUNTIME_NAMESPACE") !== runtimeAuthority.namespace) { + throw agentRunRuntimeAuthorityError("agentrun_transient_namespace_mismatch", `AgentRun transient HWLAB_RUNTIME_NAMESPACE must be ${runtimeAuthority.namespace}`); + } + if (normalizeAgentRunRuntimeLane(values.get("HWLAB_RUNTIME_LANE")) !== runtimeAuthority.lane) { + throw agentRunRuntimeAuthorityError("agentrun_transient_lane_mismatch", `AgentRun transient HWLAB_RUNTIME_LANE must be ${runtimeAuthority.lane}`); + } + for (const name of ["HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_WEB_URL"]) { + const value = values.get(name); + if (!value) { + throw agentRunRuntimeAuthorityError("agentrun_transient_endpoint_required", `AgentRun transient ${name} is required`); + } + assertAgentRunEndpointNamespace(value, runtimeAuthority.namespace, name); + } +} + +function assertAgentRunEndpointNamespace(value, namespace, fieldName) { + const endpointNamespace = agentRunRuntimeNamespaceForEndpoint(value); + if (endpointNamespace && endpointNamespace !== namespace) { + throw agentRunRuntimeAuthorityError("agentrun_runtime_endpoint_mismatch", `${fieldName} points at ${endpointNamespace}, not ${namespace}`); + } +} + +function agentRunRuntimeNamespaceForEndpoint(value) { + const match = String(value ?? "").match(/\.((?:hwlab-[a-z0-9-]+))\.svc\.cluster\.local(?::|\/|$)/iu); + return match?.[1] ?? null; +} + +function agentRunRuntimeLaneForNamespace(namespace) { + const value = String(namespace ?? "").trim().toLowerCase(); + if (value === "hwlab-prod") return "prod"; + if (value === "hwlab-dev") return "dev"; + const versionMatch = value.match(/^hwlab-v(\d+)$/u); + if (versionMatch) return versionAgentRunRuntimeLane(versionMatch[1]); + return null; +} + +function normalizeAgentRunRuntimeLane(value) { + const profile = String(value ?? "").trim().toLowerCase(); + if (!profile) return null; + if (profile === "prod" || profile === "production") return "prod"; + if (profile === "dev" || profile === "development" || profile === "local") return "dev"; + const dotted = profile.match(/^v?0+\.(\d+)$/u); + if (dotted) return versionAgentRunRuntimeLane(dotted[1]); + const compact = profile.match(/^v?0*(\d+)$/u); + if (compact) return versionAgentRunRuntimeLane(compact[1]); + throw agentRunRuntimeAuthorityError("agentrun_runtime_lane_unsupported", `unsupported AgentRun HWLAB runtime lane: ${String(value).trim()}`); +} + +function versionAgentRunRuntimeLane(value) { + const version = Number.parseInt(value, 10); + if (!Number.isFinite(version) || version <= 0) return null; + return `v${String(version).padStart(2, "0")}`; +} + +function namespaceForAgentRunRuntimeLane(lane) { + if (lane === "prod") return "hwlab-prod"; + if (lane === "dev") return "hwlab-dev"; + if (lane) return `hwlab-${lane}`; + return null; +} + +function defaultAgentRunBranchForRuntimeLane(lane) { + const match = String(lane ?? "").match(/^v(\d+)$/u); + if (!match) return null; + return `v0.${Number.parseInt(match[1], 10)}`; +} + +function agentRunRuntimeAuthorityError(code, message) { + return Object.assign(new Error(message), { + code, + statusCode: 500, + valuesPrinted: false + }); +} + function agentRunToolCredentials(env = process.env, toolCapabilities = null) { const namespace = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_TOOL_SECRET_NAMESPACE, env.HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE, DEFAULT_RUNNER_NAMESPACE); const githubSecretName = firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_GITHUB_TOOL_SECRET_NAME, "agentrun-v01-tool-github-pr"); diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index af8ab429..7301ada8 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -12,7 +12,7 @@ import { createCloudApiServer } from "./server.ts"; import { validateCodeAgentChatSchema } from "./code-agent-chat.ts"; import { createCodexStdioSessionManager } from "./codex-stdio-session.ts"; import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts"; -import { submitAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts"; +import { submitAgentRunChatTurn, syncAgentRunChatResult } from "./code-agent-agentrun-adapter.ts"; import { codexStdioChatFixture, codexStdioReadyFixture, @@ -370,7 +370,7 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async } }); -test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapter", async () => { +test("cloud api /v1/agent/chat delegates v0.3 turns to AgentRun v0.1 over adapter", async () => { const calls = []; const ownerSessions = new Map(); const conversationMessages = []; @@ -404,6 +404,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.tenantId, "hwlab"); assert.equal(body.projectId, "pikasTech/HWLAB"); assert.equal(body.backendProfile, "deepseek"); + assert.equal(body.workspaceRef.branch, "v0.3"); + assert.equal(body.workspaceRef.runtimeNamespace, "hwlab-v03"); assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567"); assert.equal(body.resourceBundleRef.kind, "gitbundle"); @@ -476,10 +478,10 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte const secondRunnerJob = body.commandId === "cmd_hwlab_adapter_second"; assert.ok(body.commandId === "cmd_hwlab_adapter" || secondRunnerJob); const transientEnv = Object.fromEntries(body.transientEnv.map((entry) => [entry.name, entry.value])); - assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667"); - assert.equal(transientEnv.HWLAB_RUNTIME_WEB_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080"); - assert.equal(transientEnv.HWLAB_RUNTIME_NAMESPACE, "hwlab-v02"); - assert.equal(transientEnv.HWLAB_RUNTIME_LANE, "v02"); + assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667"); + assert.equal(transientEnv.HWLAB_RUNTIME_WEB_URL, "http://hwlab-cloud-web.hwlab-v03.svc.cluster.local:8080"); + assert.equal(transientEnv.HWLAB_RUNTIME_NAMESPACE, "hwlab-v03"); + assert.equal(transientEnv.HWLAB_RUNTIME_LANE, "v03"); assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_LOCKED, "1"); assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1"); assert.equal(transientEnv.UNIDESK_MAIN_SERVER_IP, "http://74.48.78.17:18081"); @@ -575,8 +577,9 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek", HWLAB_CODE_AGENT_DEEPSEEK_MODEL: "deepseek-chat", - HWLAB_ENVIRONMENT: "v02", - HWLAB_GITOPS_PROFILE: "v02", + HWLAB_ENVIRONMENT: "v03", + HWLAB_GITOPS_PROFILE: "v03", + HWLAB_BOOT_REF: "v0.3", UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081" }, accessController: { @@ -829,6 +832,105 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte } }); +test("AgentRun sync converts terminal command result even when run remains claimed (#1555)", async () => { + const calls = []; + const traceId = "trc_issue1555_terminal_command"; + const runId = "run_issue1555_claimed"; + const commandId = "cmd_issue1555_completed"; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + calls.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, traceId: "trc_fake_issue1555" })}\n`); + }; + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) { + return send({ items: [ + { id: "evt_issue1555_terminal", runId, seq: 139, type: "terminal_status", payload: { commandId, terminalStatus: "completed" }, createdAt: "2026-06-18T00:00:00.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands`) { + return send({ items: [ + { id: commandId, runId, state: "completed", type: "turn", seq: 1, idempotencyKey: traceId, payload: { traceId, conversationId: "cnv_issue1555", hwlabSessionId: "ses_issue1555", threadId: "thread_issue1555" } } + ] }); + } + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) { + return send({ + runId, + commandId, + attemptId: "attempt_issue1555", + runnerId: "runner_issue1555", + jobName: "agentrun-v01-runner-issue1555", + namespace: "agentrun-v02", + status: "completed", + runStatus: "claimed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "issue 1555 command result is terminal even though the reusable run is still claimed.", + lastSeq: 139, + eventCount: 139, + sessionRef: { sessionId: "ses_issue1555", conversationId: "cnv_issue1555", threadId: "thread_issue1555" } + }); + } + 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 traceStore = createCodeAgentTraceStore(); + try { + const currentResult = { + ok: true, + accepted: true, + shortConnection: true, + status: "running", + traceId, + conversationId: "cnv_issue1555", + sessionId: "ses_issue1555", + threadId: "thread_issue1555", + agentRun: { + adapter: "agentrun-v01", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + runId, + commandId, + status: "pending", + runStatus: "claimed", + commandState: "pending", + terminalStatus: null, + lastSeq: 0, + valuesPrinted: false + }, + valuesPrinted: false + }; + const synced = await syncAgentRunChatResult({ + traceId, + currentResult, + traceStore, + options: { + env: { + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1" + } + } + }); + assert.equal(synced.resultSynced, true); + assert.equal(synced.result.status, "completed"); + assert.equal(synced.result.agentRun.runStatus, "claimed"); + assert.equal(synced.result.agentRun.commandState, "completed"); + assert.equal(synced.result.agentRun.terminalStatus, "completed"); + assert.match(synced.result.reply.content, /issue 1555 command result is terminal/u); + assert.deepEqual(calls.map((call) => call.path), [ + `/api/v1/runs/${runId}/commands`, + `/api/v1/runs/${runId}/events`, + `/api/v1/runs/${runId}/commands/${commandId}/result` + ]); + assert.ok(traceStore.snapshot(traceId).events.some((event) => event.label === "agentrun:result:completed")); + } finally { + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api AgentRun adapter reports persistent thread resume when a completed run needs a new runner", async () => { const calls = []; const hwlabSessionId = "ses_server-test-thread-resume"; diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 22f140c5..bd6acac7 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -483,8 +483,10 @@ async function codeAgentAuthEnv(options = {}, env = process.env, params = {}) { const key = ownerUserId && store?.findActiveDefaultApiKeyForUser ? await store.findActiveDefaultApiKeyForUser(ownerUserId) : null; + const envRuntimeLane = runtimeLaneForEnv(env); const runtimeNamespace = runtimeNamespaceForEnv(env); - const runtimeLane = runtimeLaneForNamespace(runtimeNamespace) ?? runtimeLaneForEnv(env); + const namespaceRuntimeLane = runtimeLaneForNamespace(runtimeNamespace); + const runtimeLane = resolveRuntimeLaneAuthority({ envRuntimeLane, runtimeNamespace, namespaceRuntimeLane }); const inClusterApiUrl = internalCodeAgentApiUrl(env, runtimeNamespace); const inClusterWebUrl = internalCodeAgentWebUrl(env, runtimeNamespace); const apiUrl = firstNonEmptyValue( @@ -529,16 +531,21 @@ function runtimeNamespaceForEnv(env = process.env) { } function runtimeLaneForEnv(env = process.env) { - const profile = String(firstNonEmptyValue(env.HWLAB_RUNTIME_LANE, env.HWLAB_GITOPS_LANE, env.HWLAB_GITOPS_PROFILE, env.HWLAB_ENVIRONMENT, env.HWLAB_BOOT_REF, "dev") ?? "dev").trim().toLowerCase(); - if (profile === "prod" || profile === "production") return "prod"; - if (profile === "v02" || profile === "v0.2" || profile === "0.2") return "v02"; - return "dev"; + const raw = firstNonEmptyValue(env.HWLAB_RUNTIME_LANE, env.HWLAB_GITOPS_LANE, env.HWLAB_GITOPS_PROFILE, env.HWLAB_ENVIRONMENT, env.HWLAB_BOOT_REF); + if (!raw) return null; + const lane = normalizeRuntimeLane(raw); + if (!lane) { + throw runtimeAuthorityError("unsupported_runtime_lane", `unsupported HWLAB runtime lane: ${String(raw).trim()}`); + } + return lane; } function runtimeLaneForNamespace(namespace) { - if (namespace === "hwlab-v02") return "v02"; - if (namespace === "hwlab-prod") return "prod"; - if (namespace === "hwlab-dev") return "dev"; + const value = String(namespace ?? "").trim().toLowerCase(); + if (value === "hwlab-prod") return "prod"; + if (value === "hwlab-dev") return "dev"; + const versionMatch = value.match(/^hwlab-v(\d+)$/u); + if (versionMatch) return versionRuntimeLane(versionMatch[1]); return null; } @@ -559,17 +566,57 @@ function runtimeNamespaceForEndpoint(value) { function runtimeLaneForEndpoint(value) { const text = String(value ?? "").toLowerCase(); - if (/hwlab-v02|:19666(?:\/|$)|:19667(?:\/|$)/u.test(text)) return "v02"; + const namespaceLane = runtimeLaneForNamespace(runtimeNamespaceForEndpoint(text)); + if (namespaceLane) return namespaceLane; if (/hwlab-prod|:18666(?:\/|$)|:18667(?:\/|$)/u.test(text)) return "prod"; if (/hwlab-dev|:17666(?:\/|$)|:17667(?:\/|$)|:16666(?:\/|$)|:16667(?:\/|$)/u.test(text)) return "dev"; return null; } +function normalizeRuntimeLane(value) { + const profile = String(value ?? "").trim().toLowerCase(); + if (!profile) return null; + if (profile === "prod" || profile === "production") return "prod"; + if (profile === "dev" || profile === "development" || profile === "local") return "dev"; + const dotted = profile.match(/^v?0+\.(\d+)$/u); + if (dotted) return versionRuntimeLane(dotted[1]); + const compact = profile.match(/^v?0*(\d+)$/u); + if (compact) return versionRuntimeLane(compact[1]); + return null; +} + +function versionRuntimeLane(value) { + const version = Number.parseInt(value, 10); + if (!Number.isFinite(version) || version <= 0) return null; + return `v${String(version).padStart(2, "0")}`; +} + +function resolveRuntimeLaneAuthority({ envRuntimeLane, runtimeNamespace, namespaceRuntimeLane }) { + if (!namespaceRuntimeLane) { + throw runtimeAuthorityError("unsupported_runtime_namespace", `unsupported HWLAB runtime namespace: ${runtimeNamespace}`); + } + if (envRuntimeLane && envRuntimeLane !== namespaceRuntimeLane) { + throw runtimeAuthorityError( + "runtime_authority_mismatch", + `HWLAB runtime lane ${envRuntimeLane} does not match namespace ${runtimeNamespace} (${namespaceRuntimeLane})` + ); + } + return namespaceRuntimeLane; +} + +function runtimeAuthorityError(code, message) { + return Object.assign(new Error(message), { + code, + statusCode: 500, + valuesPrinted: false + }); +} + function defaultRuntimeNamespace(env = process.env) { - const profile = runtimeLaneForEnv(env); + const profile = runtimeLaneForEnv(env) ?? "dev"; if (profile === "prod") return "hwlab-prod"; - if (profile === "v02") return "hwlab-v02"; - return "hwlab-dev"; + if (profile === "dev") return "hwlab-dev"; + return `hwlab-${profile}`; } function localCloudApiUrl(env = process.env) { @@ -807,7 +854,13 @@ function scheduleAgentRunProjectionSync({ traceId, params = {}, options = {}, tr isTerminal: isTraceCommandTerminalStatus, activitySignature: agentRunProjectionActivitySignature, sleep: sleepAgentRunProjectionSync, - syncResult: ({ result }) => syncAgentRunChatResult({ traceId, currentResult: result, options: refreshOptions, traceStore }), + syncResult: ({ result }) => syncAgentRunChatResult({ + traceId, + currentResult: result, + options: refreshOptions, + traceStore, + forceResultSync: agentRunProjectionObservedTerminal(result, traceStore?.snapshot?.(traceId)) + }), onTerminal: (payload, finalizerOptions = {}) => scheduleCodeAgentTerminalTurnStatusEffects({ payload, params, options, preserveLastTraceId: finalizerOptions.preserveLastTraceId === true }) }); } @@ -839,6 +892,32 @@ function agentRunProjectionActivitySignature(result, runnerTrace) { ].map((value) => value ?? "").join("|"); } +function agentRunProjectionObservedTerminal(result, runnerTrace) { + const agentRun = result?.agentRun && typeof result.agentRun === "object" ? result.agentRun : {}; + if ( + isTraceCommandTerminalStatus(result?.status) || + isTraceCommandTerminalStatus(agentRun.status) || + isTraceCommandTerminalStatus(agentRun.runStatus) || + isTraceCommandTerminalStatus(agentRun.commandState) || + isTraceCommandTerminalStatus(agentRun.terminalStatus) + ) { + return true; + } + const commandId = textValue(agentRun.commandId); + const trace = runnerTrace && typeof runnerTrace === "object" ? runnerTrace : result?.runnerTrace; + const events = Array.isArray(trace?.events) ? trace.events : []; + return events.some((event) => { + const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; + const eventCommandId = textValue(event?.commandId ?? payload.commandId); + if (commandId && eventCommandId && eventCommandId !== commandId) return false; + return event?.terminal === true || + isTraceCommandTerminalStatus(event?.status) || + isTraceCommandTerminalStatus(payload.status) || + isTraceCommandTerminalStatus(payload.terminalStatus) || + payload.phase === "command-terminal"; + }); +} + function sleepAgentRunProjectionSync(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))); } diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index f35faa7f..edcfc4bd 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -649,33 +649,70 @@ function acceptChatTurn(body: JsonRecord): JsonRecord { function liveBackfillEarlyTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord { const createdAt = new Date().toISOString(); + const agentRun = liveBackfillAgentRun("pending", null, 4); return { traceId, status: "running", + running: true, + terminal: false, sessionId, threadId, + agentRun, events: [ - { seq: 1, sourceSeq: 1, createdAt, label: "agentrun:request:accepted", type: "backend_status", status: "running" }, - { seq: 2, sourceSeq: 2, createdAt, label: "agentrun:run:created", type: "backend_status", status: "running" }, - { seq: 3, sourceSeq: 3, createdAt, label: "agentrun:command:created", type: "backend_status", status: "running" }, - { seq: 4, sourceSeq: 4, createdAt, label: "agentrun:runner-job:created", type: "backend_status", status: "running" } + { seq: 1, sourceSeq: 1, createdAt, label: "agentrun:request:accepted", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId } }, + { seq: 2, sourceSeq: 2, createdAt, label: "agentrun:run:created", type: "backend_status", status: "running", payload: { runId: agentRun.runId, runStatus: agentRun.runStatus } }, + { seq: 3, sourceSeq: 3, createdAt, label: "agentrun:command:created", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId, commandState: agentRun.commandState } }, + { seq: 4, sourceSeq: 4, createdAt, label: "agentrun:runner-job:created", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId, jobName: agentRun.jobName, namespace: agentRun.namespace } } ], eventCount: 4, fullTraceLoaded: true, - hasMore: false + hasMore: false, + traceSummary: { source: "agentrun-events", agentRun, valuesPrinted: false } }; } function liveBackfillCompletedTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord { const createdAt = new Date().toISOString(); const finalText = "fake AgentRun completed after REST backfill without terminal SSE."; + const agentRun = liveBackfillAgentRun("completed", "completed", 23); const events = [ ...(liveBackfillEarlyTrace(sessionId, threadId, traceId).events as JsonRecord[]), - { seq: 5, sourceSeq: 21, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", message: finalText }, - { seq: 6, sourceSeq: 22, createdAt, label: "item/commandExecution:completed", type: "commandExecution", status: "completed", stdout: "OK\n" }, - { seq: 7, sourceSeq: 23, createdAt, label: "agentrun:backend:turn/completed", type: "backend_status", status: "completed", terminal: true } + { seq: 5, sourceSeq: 21, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", message: finalText, payload: { commandId: agentRun.commandId } }, + { seq: 6, sourceSeq: 22, createdAt, label: "item/commandExecution:completed", type: "commandExecution", status: "completed", stdout: "OK\n", payload: { commandId: agentRun.commandId, exitCode: 0 } }, + { seq: 7, sourceSeq: 23, createdAt, label: "agentrun:backend:turn/completed", type: "backend_status", status: "completed", terminal: true, payload: { commandId: agentRun.commandId, phase: "command-terminal", terminalStatus: "completed" } } ]; - return { traceId, status: "completed", sessionId, threadId, events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, finalResponse: { text: finalText, status: "completed" } }; + return { + traceId, + status: "completed", + running: false, + terminal: true, + sessionId, + threadId, + agentRun, + events, + eventCount: events.length, + fullTraceLoaded: true, + hasMore: false, + finalResponse: { text: finalText, status: "completed" }, + traceSummary: { source: "agentrun-command-result", terminalStatus: "completed", agentRun, valuesPrinted: false } + }; +} + +function liveBackfillAgentRun(commandState: string, terminalStatus: string | null, lastSeq: number): JsonRecord { + return { + adapter: "agentrun-v01", + runId: "run_live_backfill_issue1555", + commandId: "cmd_live_backfill_issue1555", + status: commandState, + runStatus: "claimed", + commandState, + terminalStatus, + runnerStatus: "running", + namespace: "agentrun-v02", + jobName: "agentrun-v01-runner-live-backfill-issue1555", + lastSeq, + valuesPrinted: false + }; } function scheduleLiveBackfillProjection(sessionId: string, threadId: string | null, traceId: string, readyAtMs: number): void { diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/live-backfill-race.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/live-backfill-race.spec.ts index 796f7f04..0abfeca4 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/live-backfill-race.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/live-backfill-race.spec.ts @@ -20,6 +20,25 @@ test.describe("live REST backfill without terminal SSE", () => { return response.ok() ? ((await response.json()).turn?.status as string | undefined) : undefined; }).toBe("completed"); + const turnResponse = await page.request.get("/v1/workbench/turns/trc_live_backfill"); + expect(turnResponse.ok()).toBe(true); + const turnPayload = await turnResponse.json() as { + turn?: { + running?: boolean; + terminal?: boolean; + agentRun?: { runStatus?: string; commandState?: string; terminalStatus?: string | null; namespace?: string }; + traceSummary?: { source?: string; terminalStatus?: string }; + }; + }; + expect(turnPayload.turn?.running).toBe(false); + expect(turnPayload.turn?.terminal).toBe(true); + expect(turnPayload.turn?.agentRun?.runStatus).toBe("claimed"); + expect(turnPayload.turn?.agentRun?.commandState).toBe("completed"); + expect(turnPayload.turn?.agentRun?.terminalStatus).toBe("completed"); + expect(turnPayload.turn?.agentRun?.namespace).toBe("agentrun-v02"); + expect(turnPayload.turn?.traceSummary?.source).toBe("agentrun-command-result"); + expect(turnPayload.turn?.traceSummary?.terminalStatus).toBe("completed"); + const listResponse = await page.request.get("/v1/workbench/sessions?includeSessionId=ses_completed"); const messagesResponse = await page.request.get("/v1/workbench/sessions/ses_completed/messages?limit=100"); expect(listResponse.ok()).toBe(true);