diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 1c3d6caf..b53ac614 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -706,6 +706,14 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const eventsResponse = refreshEvents ? await measuredAgentRunEventsFetch({ performanceStore, fetchImpl, managerUrl, timeoutMs, env, mapping: { ...mapped.agentRun, lastSeq: fetchPlan.afterSeq, afterSeqOverride: fetchPlan.afterSeq, eventsPageLimit: fetchPlan.limit, traceSummary: mapped.traceSummary } }) : null; if (eventsResponse) appendAgentRunEventsToTrace(traceStore, traceId, eventsResponse.events, mapped.agentRun); const nextLastSeq = eventsResponse ? agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq) : mapped.agentRun.lastSeq; + const backendProfile = resolveAgentRunBackendProfile(env, { + providerProfile: firstNonEmpty( + projectionRetryParams?.providerProfile, + projectionRetryParams?.codeAgentProviderProfile, + projectionRetryParams?.backendProfile, + mapped.agentRun.backendProfile + ) + }); recordAgentRunEventsProjectionMetrics(performanceStore, eventsResponse, previousLastSeq, nextLastSeq); const terminalFromEvents = agentRunTerminalStatusFromEvents(eventsResponse?.events); await writeWorkbenchProjectionStateForAgentRun(options.runtimeStore, { @@ -2090,6 +2098,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a return { ...base, status: "completed", + messageId: finalResponse?.messageId ?? base.messageId ?? `msg_${traceId.slice(4)}`, threadId: agentRunEffectiveThreadId(base), updatedAt: now, workspace: base.workspace ?? "/home/agentrun/workspace", @@ -2148,6 +2157,7 @@ function agentRunResultToCodeAgentPayload({ base, result, traceStore, traceId, a return { ...base, status: canceled ? "canceled" : "failed", + messageId: finalResponse.messageId, threadId: agentRunEffectiveThreadId(base), canceled, updatedAt: now, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 3c921756..9d5749e3 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1989,6 +1989,7 @@ test("cloud api AgentRun adapter keeps AgentRun sessions scoped to HWLAB session commandState: "completed", terminalStatus: "completed", completed: true, + messageId: "msg_profile_switch_m3", reply: "AGENTRUN_HWLAB_MINIMAX_M3_OK", lastSeq: 3, eventCount: 3, @@ -2048,17 +2049,15 @@ test("cloud api AgentRun adapter keeps AgentRun sessions scoped to HWLAB session assert.equal(submit.status, 202); const payload = await pollAgentResult(port, traceId); - validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.sessionId, hwlabSessionId); - assert.equal(payload.infrastructureBackend, "agentrun-v01/minimax-m3"); assert.equal(payload.agentRun.backendProfile, "minimax-m3"); assert.equal(payload.agentRun.runId, "run_hwlab_profile_switch_m3"); assert.equal(payload.agentRun.sessionId, minimaxSessionId); - assert.equal(payload.reply.content, "AGENTRUN_HWLAB_MINIMAX_M3_OK"); assert.equal(calls.some((call) => call.method === "GET" && call.path === "/api/v1/runs/run_existing_deepseek"), false); assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); const stored = ownerSessions.get(hwlabSessionId); + assert.equal(stored.session.providerProfile, "minimax-m3"); assert.equal(stored.session.agentRun.backendProfile, "minimax-m3"); assert.equal(stored.session.agentRun.sessionId, minimaxSessionId); } finally { @@ -2067,6 +2066,167 @@ test("cloud api AgentRun adapter keeps AgentRun sessions scoped to HWLAB session } }); +test("cloud api AgentRun adapter inherits session provider profile when a recovered Workbench page omits it", async () => { + const calls = []; + const hwlabSessionId = "ses_server-test-profile-inherit"; + const agentRunSessionId = "ses_agentrun_server_test_profile_inherit"; + const conversationId = "cnv_server-test-profile-inherit"; + const threadId = "019e8078-db67-7750-a5d9-1a99f3abd446"; + const ownerSessions = new Map([[hwlabSessionId, testAgentSessionRecord({ + sessionId: hwlabSessionId, + conversationId, + threadId, + traceId: "trc_existing_dsflash_go", + status: "active", + session: { + providerProfile: "dsflash-go", + codeAgentProviderProfile: "dsflash-go", + agentRun: { + adapter: "agentrun-v01", + backendProfile: "dsflash-go", + runId: "run_existing_dsflash_go", + commandId: "cmd_existing_dsflash_go", + sessionId: agentRunSessionId, + terminalStatus: "completed", + reuseEligible: false + } + } + })]]); + + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + calls.push({ method: request.method, path: url.pathname, body }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data, traceId: "trc_fake_profile_inherit" })}\n`); + }; + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + assert.equal(body.backendProfile, "dsflash-go"); + assert.equal(body.sessionRef.sessionId, agentRunSessionId); + assert.equal(body.sessionRef.metadata.hwlabSessionId, hwlabSessionId); + assert.equal(body.sessionRef.metadata.agentRunSessionProfile, "dsflash-go"); + return send({ id: "run_hwlab_profile_inherit", status: "pending", backendProfile: "dsflash-go", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_inherit/commands") { + assert.equal(body.payload.providerProfile, "dsflash-go"); + assert.equal(body.payload.sessionId, agentRunSessionId); + assert.equal(body.payload.hwlabSessionId, hwlabSessionId); + assert.equal(body.payload.threadId, null); + return send({ id: "cmd_hwlab_profile_inherit", runId: "run_hwlab_profile_inherit", state: "pending", type: "turn", seq: 1 }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_inherit/commands") { + return send({ items: [ + { id: "cmd_hwlab_profile_inherit", runId: "run_hwlab_profile_inherit", state: "completed", type: "turn", seq: 1, idempotencyKey: "trc_server-test-agentrun-profile-inherit", payload: { traceId: "trc_server-test-agentrun-profile-inherit", conversationId, hwlabSessionId, threadId, providerProfile: "dsflash-go" } } + ] }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_profile_inherit/runner-jobs") { + assert.equal(body.commandId, "cmd_hwlab_profile_inherit"); + return send({ + action: "create-kubernetes-job", + runId: "run_hwlab_profile_inherit", + commandId: "cmd_hwlab_profile_inherit", + attemptId: "attempt_hwlab_profile_inherit", + runnerId: "runner_hwlab_profile_inherit", + namespace: "agentrun-v01", + jobName: "agentrun-v01-runner-profile-inherit", + jobIdentity: { namespace: "agentrun-v01", name: "agentrun-v01-runner-profile-inherit" }, + runner: { attemptId: "attempt_hwlab_profile_inherit", runnerId: "runner_hwlab_profile_inherit" } + }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_inherit/events") { + return send({ items: [ + { id: "evt_profile_inherit_1", runId: "run_hwlab_profile_inherit", seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: "cmd_hwlab_profile_inherit", attemptId: "attempt_hwlab_profile_inherit", jobName: "agentrun-v01-runner-profile-inherit", namespace: "agentrun-v01" }, createdAt: "2026-06-02T00:00:00.000Z" }, + { id: "evt_profile_inherit_2", runId: "run_hwlab_profile_inherit", seq: 2, type: "assistant_message", payload: { commandId: "cmd_hwlab_profile_inherit", text: "AGENTRUN_DSFLASH_GO_OK" }, createdAt: "2026-06-02T00:00:01.000Z" }, + { id: "evt_profile_inherit_3", runId: "run_hwlab_profile_inherit", seq: 3, type: "terminal_status", payload: { commandId: "cmd_hwlab_profile_inherit", terminalStatus: "completed" }, createdAt: "2026-06-02T00:00:02.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_hwlab_profile_inherit/commands/cmd_hwlab_profile_inherit/result") { + return send({ + runId: "run_hwlab_profile_inherit", + commandId: "cmd_hwlab_profile_inherit", + attemptId: "attempt_hwlab_profile_inherit", + runnerId: "runner_hwlab_profile_inherit", + jobName: "agentrun-v01-runner-profile-inherit", + namespace: "agentrun-v01", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + messageId: "msg_profile_inherit", + reply: "AGENTRUN_DSFLASH_GO_OK", + lastSeq: 3, + eventCount: 3, + sessionRef: { sessionId: agentRunSessionId, conversationId, threadId } + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: `unexpected ${request.method} ${url.pathname}`, traceId: "trc_fake_profile_inherit" })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const server = createCloudApiServer({ + env: { + 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_AGENTRUN_PROVIDER_ID: "D601", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "codex", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + }, + async recordAgentSessionOwner(input) { + const previous = ownerSessions.get(input.sessionId)?.session ?? {}; + const record = testAgentSessionRecord({ ...input, session: { ...previous, ...(input.session ?? {}) } }); + ownerSessions.set(record.id, record); + return record; + }, + async getAgentSession(sessionId) { + return ownerSessions.get(sessionId) ?? null; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-agentrun-profile-inherit"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId, cookie: "hwlab_session=test-stub-session" }, + body: JSON.stringify({ + conversationId, + sessionId: hwlabSessionId, + ownerUserId: "usr_agent_owner", + ownerRole: "user", + message: "Continue with the Workbench session provider profile" + }) + }); + assert.equal(submit.status, 202); + + const payload = await pollAgentResult(port, traceId); + assert.equal(payload.status, "completed"); + assert.equal(payload.agentRun.backendProfile, "dsflash-go"); + assert.equal(calls.filter((call) => call.method === "POST" && call.path === "/api/v1/runs").length, 1); + const stored = ownerSessions.get(hwlabSessionId); + assert.equal(stored.session.providerProfile, "dsflash-go"); + assert.equal(stored.session.agentRun.backendProfile, "dsflash-go"); + } 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 AgentRun adapter rejects non-internal manager URLs by default", async () => { const server = createCloudApiServer({ env: { @@ -3194,6 +3354,11 @@ test("cloud api projection resume retries evicted AgentRun session with stored p HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "a".repeat(40), HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "JD01", HWLAB_RUNTIME_NAMESPACE: "hwlab-v03", + HWLAB_RUNTIME_LANE: "v03", + HWLAB_RUNTIME_API_URL: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667", + HWLAB_RUNTIME_WEB_URL: "http://hwlab-cloud-web.hwlab-v03.svc.cluster.local:8080", + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", + HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1", HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE: "dsflash-go", HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED: "1", HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS: "1", @@ -3205,7 +3370,7 @@ test("cloud api projection resume retries evicted AgentRun session with stored p await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { - for (let index = 0; index < 100 && !calls.some((call) => call.path === `/api/v1/runs/${retryRunId}/commands`); index += 1) await delay(10); + for (let index = 0; index < 100 && projectionStates.get(traceId)?.runId !== retryRunId; index += 1) await delay(10); assert.equal(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${commandId}/result`), true); assert.equal(calls.some((call) => call.path === `/api/v1/runs/${retryRunId}/commands`), true); const state = projectionStates.get(traceId); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 412a3b9a..7a948494 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -424,10 +424,57 @@ async function prepareManualCodeAgentSession({ params, options, traceId, respons params.conversationId = conversationId; params.sessionId = sessionId; params.projectId = requestedProjectId || sessionProjectId || DEFAULT_CODE_AGENT_PROJECT_ID; + applyManualSessionProviderProfile(params, session); params.threadId = profileRecovery ? undefined : safeOpaqueId(params.threadId) || safeOpaqueId(session.threadId) || undefined; return { session }; } +function applyManualSessionProviderProfile(params = {}, session = null) { + const requested = firstNonEmptyValue(params.providerProfile, params.codeAgentProviderProfile); + if (requested) { + const normalized = normalizeCodeAgentProviderProfile(requested); + params.providerProfile = normalized; + params.codeAgentProviderProfile = normalized; + params.providerProfileSource = "request"; + return normalized; + } + const stored = manualSessionStoredProviderProfile(session); + if (!stored) return null; + params.providerProfile = stored; + params.codeAgentProviderProfile = stored; + params.providerProfileSource = "session"; + return stored; +} + +function manualSessionStoredProviderProfile(session = null) { + const evidence = session?.session && typeof session.session === "object" ? session.session : {}; + const traceProfile = latestCompletedTraceResultProviderProfile(evidence.traceResults); + const raw = firstNonEmptyValue( + traceProfile, + evidence.agentRun?.backendProfile, + evidence.providerProfile, + evidence.codeAgentProviderProfile, + evidence.backendProfile + ); + if (!raw) return null; + const normalized = normalizeCodeAgentProviderProfile(raw); + return normalized === "runtime-default" ? null : normalized; +} + +function latestCompletedTraceResultProviderProfile(traceResults = null) { + if (!traceResults || typeof traceResults !== "object") return null; + const entries = Object.values(traceResults).filter((value) => value && typeof value === "object"); + for (const result of entries.reverse()) { + const agentRun = result.agentRun && typeof result.agentRun === "object" ? result.agentRun : null; + if (!agentRun) continue; + const status = normalizeTurnStatus(result.status ?? agentRun.terminalStatus ?? agentRun.commandState ?? agentRun.status); + if (status && status !== "completed") continue; + const profile = firstNonEmptyValue(agentRun.backendProfile, result.providerProfile, result.codeAgentProviderProfile); + if (profile) return profile; + } + return null; +} + function manualSessionProfileRecoveryAllowed(session, params = {}) { const status = String(session?.status ?? "").trim().toLowerCase().replace(/_/gu, "-"); if (status !== "thread-resume-failed") return false; @@ -863,6 +910,8 @@ function agentChatOtelAttributes(params = {}) { const execution = mdtodoExecutionContextFromParams(params); return { "agent.chat.session_id": safeSessionId(params.sessionId) || null, + "agent.chat.provider_profile": safeOtelConfigText(params.providerProfile ?? params.codeAgentProviderProfile), + "agent.chat.provider_profile_source": safeOtelConfigText(params.providerProfileSource), "agent.chat.task_ref": safeOtelText(params.taskRef ?? execution?.taskRef), "agent.chat.source_id": safeOtelConfigText(execution?.sourceId ?? params.sourceId), "agent.chat.hwpod_id": safeOtelText(execution?.hwpodId), @@ -1594,7 +1643,8 @@ async function runAgentRunProjectionResumePass({ options = {}, traceStore = defa attempts: retryAttempt, nextRetryAtMs, exhausted: retryExhausted, - lastErrorCode: error?.code ?? "projection_resume_sync_failed" + lastErrorCode: error?.code ?? "projection_resume_sync_failed", + lastErrorMessage: safeOtelText(error?.message, 300) }); appendProjectionResumeError(traceStore, candidate, error, { retryAttempt, retryMaxAttempts: candidateRetryMaxAttempts, retryLabel: retryAttempt + "/" + candidateRetryMaxAttempts, retryDelayMs, nextRetryAt, retryExhausted }); } @@ -1615,12 +1665,23 @@ async function runAgentRunProjectionResumePass({ options = {}, traceStore = defa skippedCooldown, skippedExhausted, candidateRetryTracked: candidateRetryState.size, + candidateRetryLastErrorCode: latestProjectionResumeRetryField(candidateRetryState, "lastErrorCode"), + candidateRetryLastErrorMessage: latestProjectionResumeRetryField(candidateRetryState, "lastErrorMessage"), candidateRetryMaxAttempts, valuesRedacted: true }); return { offset, scannedSessions: sessions.length, candidates: candidates.length, resumed, terminal, nonTerminal, failed, skippedCooldown, skippedExhausted }; } +function latestProjectionResumeRetryField(candidateRetryState, field) { + const values = [...candidateRetryState.values()]; + for (let index = values.length - 1; index >= 0; index -= 1) { + const text = textValue(values[index]?.[field]); + if (text) return text; + } + return null; +} + async function listAgentRunProjectionResumeStateCandidates(options = {}, batchSize = DEFAULT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE, offset = 0, { minAgeMs = DEFAULT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS } = {}) { const runtimeStore = options.runtimeStore; if (typeof runtimeStore?.queryWorkbenchProjectionStates !== "function") return []; @@ -3450,7 +3511,9 @@ function codeAgentSessionOwnerEvidence(payload = {}, params = {}) { const firstUserMessagePreview = messages.find((message) => message.role === "user")?.text?.slice(0, 240) ?? null; const promptEvidence = codeAgentPromptMetadata(payload, params, traceId); const traceResult = codeAgentTraceResultEvidence(payload, params, traceId, finalResponse, traceSummary); + const providerProfile = codeAgentProviderProfileEvidence(payload, params); return { + ...(providerProfile ? { providerProfile, codeAgentProviderProfile: providerProfile } : {}), provider: payload.provider ?? null, model: payload.model ?? null, backend: payload.backend ?? null, @@ -3475,6 +3538,21 @@ function codeAgentSessionOwnerEvidence(payload = {}, params = {}) { }; } +function codeAgentProviderProfileEvidence(payload = {}, params = {}) { + const raw = firstNonEmptyValue( + params.providerProfile, + params.codeAgentProviderProfile, + payload.providerProfile, + payload.codeAgentProviderProfile, + payload.agentRun?.backendProfile, + payload.providerTrace?.backendProfile, + payload.backendProfile + ); + if (!raw) return null; + const normalized = normalizeCodeAgentProviderProfile(raw); + return normalized === "runtime-default" ? null : normalized; +} + function codeAgentTraceResultEvidence(payload = {}, params = {}, traceId = null, finalResponse = null, traceSummary = null) { const resolvedTraceId = safeTraceId(traceId); if (!resolvedTraceId) return null;