diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 2788a21b..c82ba4a1 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -428,7 +428,7 @@ function isAgentRunCommandAlreadyClaimed(error) { return statusCode === 409 && /command\s+[^\s]+\s+is not pending:/u.test(message); } -export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore, appendResultEvent = true }) { +export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore, appendResultEvent = true, refreshEvents = true }) { const initial = currentResult ?? await loadPersistedAgentRunResult(traceId, options); const mapped = initial ? await resolveAgentRunTraceCommandMapping({ traceId, mapped: initial, options }) : initial; if (!mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return { result: currentResult, runnerTrace: traceStore.snapshot(traceId), found: Boolean(currentResult) }; @@ -436,9 +436,8 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const fetchImpl = options.fetchImpl ?? globalThis.fetch; const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl); const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); - const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } }); - const events = eventsResponse.events; - appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun); + const eventsResponse = refreshEvents ? await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } }) : null; + if (eventsResponse) appendAgentRunEventsToTrace(traceStore, traceId, eventsResponse.events, mapped.agentRun); const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, { method: "GET", timeoutMs @@ -446,7 +445,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const nextMapping = { ...mapped.agentRun, ...agentRunResultRefs(result), - lastSeq: agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq), + lastSeq: eventsResponse ? agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq) : mapped.agentRun.lastSeq, status: result?.status ?? mapped.agentRun.status ?? "running", runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? null, commandState: result?.commandState ?? mapped.agentRun.commandState ?? null, diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 1e44a7f5..406a1238 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1863,6 +1863,90 @@ test("cloud api trace replays an earlier AgentRun command after same-run lastSeq } }); +test("cloud api running trace refreshes AgentRun events without waiting for command result (#1000)", async () => { + const calls = []; + const traceId = "trc_issue1000_running_trace"; + const runId = "run_issue1000_running_trace"; + const commandId = "cmd_issue1000_running_trace"; + const codeAgentChatResults = new Map(); + const events = [ + { id: "evt_issue1000_created", runId, seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId, jobName: "agentrun-v01-runner-issue1000", namespace: "agentrun-v01" }, createdAt: "2026-06-06T08:34:30.000Z" }, + { id: "evt_issue1000_turn", runId, seq: 2, type: "backend_status", payload: { phase: "turn/started", commandId }, createdAt: "2026-06-06T08:34:31.000Z" }, + { id: "evt_issue1000_tool", runId, seq: 3, type: "tool_call", payload: { method: "item/completed", type: "commandExecution", toolName: "commandExecution", itemId: "call_issue1000_hwpod", command: "hwpod list --available", status: "completed", exitCode: 0, outputSummary: "available=1", commandId }, createdAt: "2026-06-06T08:34:32.000Z" }, + { id: "evt_issue1000_output", runId, seq: 4, type: "assistant_message", payload: { commandId, itemId: "msg_issue1000_running", text: "正在查看 HWPOD。" }, createdAt: "2026-06-06T08:34:33.000Z" } + ]; + 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_issue1000_running" })}\n`); + }; + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) return send({ items: events }); + if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) { + return send({ runId, commandId, status: "running", runStatus: "running", commandState: "running", terminalStatus: null, completed: false, reply: null, lastSeq: 3, eventCount: 3 }); + } + 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; + codeAgentChatResults.set(traceId, { + accepted: true, + status: "running", + shortConnection: true, + traceId, + conversationId: "cnv_issue1000_running_trace", + sessionId: "ses_issue1000_running_trace", + threadId: "thread-issue1000-running-trace", + ownerUserId: TEST_AGENT_ACTOR.id, + ownerRole: TEST_AGENT_ACTOR.role, + agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId, traceId, providerTrace: { traceId }, lastSeq: 0, status: "running", commandState: "running", valuesPrinted: false }, + valuesPrinted: false + }); + const server = createCloudApiServer({ + traceStore: createCodeAgentTraceStore(), + codeAgentChatResults, + 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_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + accessController: { + required: false, + async authenticate() { + return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`, { + headers: { cookie: "hwlab_session=test-stub-session" } + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, "running"); + assert.equal(body.eventCount, 4); + assert.ok(body.events.some((event) => event.label === "item/commandExecution:completed")); + assert.ok(body.events.some((event) => event.label === "agentrun:assistant:message")); + assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/events`)); + assert.equal(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${commandId}/result`), false); + } 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 repairs historical same-session AgentRun trace after lastTraceId advances (#955)", async () => { const calls = []; const ownerSessions = new Map(); @@ -1984,7 +2068,8 @@ test("cloud api repairs historical same-session AgentRun trace after lastTraceId assert.equal(body.status, "completed"); assert.equal(body.finalResponse.text, firstFinalText); assert.equal(body.agentRun.commandId, firstCommandId); - assert.equal(body.traceSummary.sourceEventCount, 4); + assert.equal(body.eventCount, 4); + assert.equal(body.traceSummary.sourceEventCount, body.eventCount); assert.ok(body.events.some((event) => event.commandId === firstCommandId)); assert.equal(body.events.some((event) => event.commandId === secondCommandId), false); assert.match(text, /d601-f103-v2/u); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 10ba6e7c..a94257c5 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -33,6 +33,7 @@ import { const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120; const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_hwpod_workbench"; const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek"; +const CODE_AGENT_TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled"]); const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "minimax-m3", "runtime-default"]); const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({ "deepseek": "DeepSeek", @@ -1863,9 +1864,13 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) } if (agentRunResult?.agentRun) { try { - const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false }); - agentRunResult = synced.result ?? agentRunResult; - if (agentRunResult?.status && agentRunResult.status !== "running") { + const snapshot = await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); + agentRunResult = options.codeAgentChatResults?.get?.(traceId) ?? agentRunResult; + if (traceNeedsCommandResultSync(agentRunResult, snapshot)) { + const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false }); + agentRunResult = synced.result ?? agentRunResult; + } + if (isTraceCommandTerminalStatus(agentRunResult?.status)) { recordCodeAgentConversationFact(agentRunResult, options); await recordCodeAgentSessionOwner({ payload: agentRunResult, params: agentRunResult, options, status: codeAgentOwnerStatusForResult(agentRunResult), preserveLastTraceId: true }); } @@ -1880,11 +1885,6 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) }); return; } - try { - await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); - } catch (error) { - refreshError = error; - } } if (parts[5] === "stream") { @@ -1896,6 +1896,23 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) sendJson(response, 200, traceSnapshotWithTerminalEvidence(snapshot, agentRunResult, traceId, refreshError)); } +function traceNeedsCommandResultSync(result, snapshot) { + if (!result?.agentRun) return false; + const status = String(result.status ?? result.agentRun.status ?? result.agentRun.commandState ?? result.agentRun.terminalStatus ?? "running"); + if (isTraceCommandTerminalStatus(status)) return true; + if (snapshotHasTerminalEvent(snapshot)) return true; + return false; +} + +function isTraceCommandTerminalStatus(status) { + return CODE_AGENT_TERMINAL_STATUSES.has(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + +function snapshotHasTerminalEvent(snapshot) { + const events = Array.isArray(snapshot?.events) ? snapshot.events : []; + return events.some((event) => event?.terminal === true || (event?.type === "result" && isTraceCommandTerminalStatus(event?.status))); +} + function traceSnapshotWithTerminalEvidence(snapshot, result, traceId, refreshError = null) { const evidence = agentRunTerminalTraceEvidence(result, traceId); if (snapshot?.status !== "missing") return traceSnapshotWithAttachedTerminalEvidence(snapshot, evidence, refreshError); diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts index c6a08dbe..c71ab46a 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -389,6 +389,71 @@ test("subscribeToTrace merges terminal result without text with trace assistant } }); +test("subscribeToTrace starts result and trace polls in the same refresh tick (#1000)", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.window?.setTimeout; + const originalClearTimeout = globalThis.window?.clearTimeout; + const fetches: string[] = []; + let traceStartedBeforeResultResolved = false; + let resolveResult: (() => void) | null = null; + const resultGate = new Promise((resolve) => { resolveResult = resolve; }); + globalThis.window = { + ...(globalThis.window ?? {}), + setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout, + clearTimeout: (() => undefined) as typeof window.clearTimeout + } as typeof window; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetches.push(url); + if (url.includes("/result/")) { + await resultGate; + return jsonResponse({ status: "completed", traceId: "trc_issue1000_parallel", assistantText: "最终回复" }); + } + if (url.includes("/trace/")) { + traceStartedBeforeResultResolved = true; + resolveResult?.(); + return jsonResponse({ + status: "running", + traceId: "trc_issue1000_parallel", + events: [{ seq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "trace 已更新" }], + eventCount: 1 + }); + } + return jsonResponse({}); + }) as typeof fetch; + + try { + const snapshots: TraceSnapshot[] = []; + const completed = await new Promise((resolve, reject) => { + void subscribeToTrace({ + traceId: "trc_issue1000_parallel", + initial: { status: "running", traceId: "trc_issue1000_parallel", resultUrl: "/v1/agent/chat/result/trc_issue1000_parallel" }, + onActivity: () => undefined, + onSnapshot: (snapshot) => snapshots.push(snapshot), + onComplete: resolve, + onInfrastructureError: (error) => reject(new Error(error)), + signal: new AbortController().signal, + inactivityTimeoutMs: 1000 + }).catch(reject); + }); + assert.equal(traceStartedBeforeResultResolved, true); + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].eventCount, 1); + assert.equal(completed.assistantText, "最终回复"); + assert.deepEqual(fetches, [ + "/v1/agent/chat/result/trc_issue1000_parallel", + "/v1/agent/chat/trace/trc_issue1000_parallel?projectId=prj_hwpod_workbench" + ]); + } finally { + resolveResult?.(); + globalThis.fetch = originalFetch; + if (globalThis.window) { + globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout; + globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout; + } + } +}); + test("replayFullTrace retries transient trace API failures before returning an empty replay", async () => { const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.window?.setTimeout; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index 6ea76900..18e3d9be 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -305,8 +305,10 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise await new Promise((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS)); if (signal.aborted) return; - // Step 1: poll the result endpoint for terminal status. - const resultPolled = await api.getAgentChatResult(resultUrl, inactivityTimeoutMs, null); + const [resultPolled, tracePolled] = await Promise.all([ + api.getAgentChatResult(resultUrl, inactivityTimeoutMs, null), + api.getAgentChatResult(traceUrl, inactivityTimeoutMs, null) + ]); if (signal.aborted) return; if (resultPolled.ok && resultPolled.data) { @@ -330,11 +332,6 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise return; } - // Step 2: poll the trace endpoint for live events. - if (signal.aborted) return; - const tracePolled = await api.getAgentChatResult(traceUrl, inactivityTimeoutMs, null); - if (signal.aborted) return; - if (tracePolled.ok && tracePolled.data) { onActivity(); const events = Array.isArray(tracePolled.data.events) ? tracePolled.data.events : [];