fix: keep workbench agent provider profile across recovery

This commit is contained in:
root
2026-06-29 18:02:56 +00:00
parent e02a339240
commit 4f0aef7751
3 changed files with 258 additions and 5 deletions
@@ -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,
+169 -4
View File
@@ -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);
+79 -1
View File
@@ -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;