diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 4f6e9c08..c553d353 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -1409,11 +1409,12 @@ function toolCapabilityAllowed(toolCapabilities = null, toolId) { async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) { const hwlabSessionId = hwlabSessionIdForParams(params, traceId); - if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") { + if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId)) { return null; } - const session = await options.accessController.getAgentSession(hwlabSessionId); - if (!canReuseAgentRunSessionForOwner(session, params, options)) { + const hasSessionAccessController = typeof options.accessController?.getAgentSession === "function"; + const session = hasSessionAccessController ? await options.accessController.getAgentSession(hwlabSessionId) : null; + if (hasSessionAccessController && !canReuseAgentRunSessionForOwner(session, params, options)) { traceStore.append(traceId, agentRunTraceEvent({ type: "backend", status: "running", @@ -1425,7 +1426,57 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces }, backendProfile)); return null; } - const mapping = session?.session?.agentRun; + let mapping = session?.session?.agentRun; + if (!isReusableAgentRunMapping(mapping, backendProfile)) { + const agentRunScopedSessionId = scopedAgentRunSessionIdForParams(params, traceId); + try { + const durableSession = agentRunSessionRecordFromEnsure(await agentRunDispatchJson({ + fetchImpl, + managerUrl, + path: `/api/v1/sessions/${encodeURIComponent(agentRunScopedSessionId)}`, + method: "GET", + timeoutMs, + env, + traceStore, + traceId, + backendProfile, + stage: "session-run-reuse-discovery", + terminalOnFailure: false + })); + const lastRunId = safeOpaqueId(durableSession?.lastRunId); + const durableProfile = firstNonEmpty(durableSession?.backendProfile, durableSession?.metadata?.agentRunSessionProfile); + if (lastRunId && (!durableProfile || durableProfile === backendProfile)) { + mapping = { + adapter: ADAPTER_ID, + managerUrl, + backendProfile, + providerId: resolveAgentRunProviderId(env), + runId: lastRunId, + commandId: safeOpaqueId(durableSession?.lastCommandId) || null, + sessionId: agentRunScopedSessionId, + conversationId: safeConversationId(durableSession?.conversationId) || safeConversationId(params.conversationId) || null, + threadId: safeOpaqueId(durableSession?.threadId) || null, + durableDispatch: true, + reuseEligible: true, + runnerJobCount: 1, + valuesPrinted: false + }; + traceStore.append(traceId, agentRunTraceEvent({ + type: "backend", + status: "running", + label: "agentrun:run:reuse-discovered-from-session", + message: `AgentRun durable session ${agentRunScopedSessionId} identified run ${lastRunId}; local HWLAB session projection was not required for runner reuse.`, + sessionId: hwlabSessionId, + agentRunSessionId: agentRunScopedSessionId, + runId: lastRunId, + waitingFor: "agentrun-run-reuse-check", + valuesPrinted: false + }, backendProfile)); + } + } catch { + mapping = null; + } + } if (!isReusableAgentRunMapping(mapping, backendProfile)) { traceStore.append(traceId, agentRunTraceEvent({ type: "backend", diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 71e05d41..c18d7e7d 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -58,6 +58,10 @@ export function traceDisplayRows(trace: Record = {}, events: Tr renderedSourceEvents.add(sourceEventKey); } if (!event) continue; + if (isSemanticRuntimePreparationEvent(event)) { + rows.push(traceSemanticRuntimePreparationRow(event, displayOptions)); + continue; + } if (isSemanticFailureRetryEvent(event)) { rows.push(traceSemanticFailureRetryRow(event, displayOptions)); continue; @@ -116,6 +120,30 @@ export function traceDisplayRows(trace: Record = {}, events: Tr .map((event) => traceDisplayRow(event, displayOptions)); } +function isSemanticRuntimePreparationEvent(event: TraceEvent): boolean { + const code = nonEmptyString(event.failureCode ?? event.code); + return code === "git-mirror-fetch-in-progress" || code === "git-mirror-fetch-completed"; +} + +function traceSemanticRuntimePreparationRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow { + const eventKey = traceEventIdentityToken(event, options.sequenceAuthority); + const code = nonEmptyString(event.failureCode ?? event.code); + const completed = code === "git-mirror-fetch-completed"; + const attempt = numberOrNull(event.retryAttempt ?? event.attempt); + const maxAttempts = numberOrNull(event.retryMaxAttempts ?? event.maxAttempts); + const progress = attempt !== null && maxAttempts !== null ? `第 ${attempt}/${maxAttempts} 次` : "本次"; + const summary = nonEmptyString(event.summary ?? event.message) ?? (completed ? "Runner 源码获取完成" : "正在获取 Runner 源码"); + return { + rowId: `event:runtime-preparation:${eventKey ?? code ?? "source-fetch"}`, + seq: traceEventDisplaySeq(event, options.sequenceAuthority), + sequenceAuthority: options.sequenceAuthority, + tone: completed ? "ok" : "source", + header: `${traceEventClock(event, options)} 运行环境准备 · ${completed ? "源码获取完成" : "源码获取中"}`.trim(), + body: `${summary}\n${completed ? "正在启动 Runner" : `${progress}源码获取进行中`}`, + bodyFormat: "text" + }; +} + function isSemanticFailureRetryEvent(event: TraceEvent): boolean { if (!nonEmptyString(event.failureDomain)) return false; const phase = normalizedRetryPhase(event.retryPhase); @@ -309,7 +337,8 @@ function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOp const command = cleanShellCommand(event.command); const toolState = traceToolState(event); const toolName = traceToolName(event, command); - const body = isFileChangeTraceEvent(event) ? traceFileChangeBody(event) : traceToolCallBody(event, command); + const fileChange = isFileChangeTraceEvent(event); + const body = fileChange ? traceFileChangeBody(event) : traceToolCallBody(event, command); const eventKey = traceEventIdentityToken(event, options.sequenceAuthority); return { rowId: `tool:${event.itemId ?? eventKey ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, @@ -320,8 +349,8 @@ function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOp statusLabel: traceToolStatusLabel(toolState), header: traceEventClock(event, options), body, - preview: isFileChangeTraceEvent(event) ? traceFileChangePreview(event) : traceToolPreview(command, toolName), - bodyFormat: "text" + preview: fileChange ? traceFileChangePreview(event) : traceToolPreview(command, toolName), + bodyFormat: fileChange && body ? "markdown" : "text" }; } @@ -330,22 +359,12 @@ function isFileChangeTraceEvent(event: TraceEvent): boolean { } function traceFileChangePreview(event: TraceEvent): string { - const paths = (Array.isArray(event.changes) ? event.changes : []) - .filter(isRecord) - .map((change) => nonEmptyString(change.path)) - .filter((path): path is string => Boolean(path)); - return compactTraceOneLine(paths.length > 0 ? `fileChange ${paths.join(", ")}` : "fileChange", 140); + const summary = traceDiffSummary(traceDiffFiles(event)); + return compactTraceOneLine(summary ?? "fileChange", 140); } function traceFileChangeBody(event: TraceEvent): string | null { - const changes = (Array.isArray(event.changes) ? event.changes : []).filter(isRecord); - if (changes.length === 0) return null; - return changes.map((change) => { - const path = nonEmptyString(change.path) ?? "(path missing)"; - const kind = nonEmptyString(change.kind) ?? "update"; - const diff = nonEmptyString(change.diff) ?? ""; - return [`path: ${path}`, `kind: ${kind}`, "diff:", diff].filter(Boolean).join("\n"); - }).join("\n\n"); + return traceDiffMarkdown(traceDiffFiles(event)); } function isDiffTraceEvent(event: TraceEvent): boolean { @@ -354,6 +373,8 @@ function isDiffTraceEvent(event: TraceEvent): boolean { function traceDiffRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow { const eventKey = traceEventIdentityToken(event, options.sequenceAuthority); + const files = traceDiffFiles(event); + const diff = nonEmptyString(event.diff ?? event.message); return { rowId: `tool:diff:${eventKey ?? event.createdAt ?? "updated"}`, seq: traceEventDisplaySeq(event, options.sequenceAuthority), @@ -362,12 +383,139 @@ function traceDiffRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOption toolState: "success", statusLabel: "文件 diff 已更新", header: traceEventClock(event, options), - body: nonEmptyString(event.diff ?? event.message), - preview: "turn/diff/updated", - bodyFormat: "text" + body: traceDiffMarkdown(files) ?? diff, + preview: traceDiffSummary(files) ?? "turn/diff/updated", + bodyFormat: files.length > 0 ? "markdown" : "text" }; } +interface TraceDiffFile { + path: string | null; + kind: string | null; + diff: string; + additions: number; + deletions: number; +} + +function traceDiffFiles(event: TraceEvent): TraceDiffFile[] { + const changes = (Array.isArray(event.changes) ? event.changes : []).filter(isRecord); + if (changes.length > 0) { + return changes.map((change) => traceDiffFile( + traceDiffPath(nonEmptyString(change.path)), + traceDiffKind(change.kind), + nonEmptyString(change.diff) ?? "" + )); + } + const diff = nonEmptyString(event.diff ?? event.message); + return diff ? traceUnifiedDiffFiles(diff) : []; +} + +function traceUnifiedDiffFiles(diff: string): TraceDiffFile[] { + const sections: Array<{ path: string | null; lines: string[] }> = []; + let current: { path: string | null; lines: string[] } = { path: null, lines: [] }; + const flush = () => { + if (current.lines.length === 0) return; + sections.push(current); + }; + for (const line of diff.replace(/\r\n|\r/gu, "\n").split("\n")) { + if (line.startsWith("diff --git ") && current.lines.length > 0) { + flush(); + current = { path: null, lines: [] }; + } + current.lines.push(line); + if (line.startsWith("+++ ")) current.path = traceDiffHeaderPath(line.slice(4)) ?? current.path; + if (line.startsWith("rename to ")) current.path = line.slice("rename to ".length).trim() || current.path; + } + flush(); + return sections.map((section) => traceDiffFile(section.path, null, section.lines.join("\n"))); +} + +function traceDiffHeaderPath(value: string): string | null { + const path = value.trim().replace(/^"|"$/gu, ""); + if (!path || path === "/dev/null") return null; + return traceDiffPath(path.startsWith("b/") ? path.slice(2) : path); +} + +function traceDiffFile(path: string | null, kind: string | null, diff: string): TraceDiffFile { + const normalizedDiff = traceNormalizedFileDiff(path, kind, diff); + let additions = 0; + let deletions = 0; + for (const line of normalizedDiff.replace(/\r\n|\r/gu, "\n").split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + if (line.startsWith("-") && !line.startsWith("---")) deletions += 1; + } + return { path, kind, diff: normalizedDiff, additions, deletions }; +} + +function traceDiffKind(value: unknown): string | null { + if (isRecord(value)) return nonEmptyString(value.type ?? value.kind); + return nonEmptyString(value); +} + +function traceDiffPath(value: string | null): string | null { + if (!value) return null; + const marker = "/agentrun-workspace/"; + const markerIndex = value.indexOf(marker); + return markerIndex >= 0 ? value.slice(markerIndex + marker.length) : value; +} + +function traceNormalizedFileDiff(path: string | null, kind: string | null, diff: string): string { + const normalized = diff.replace(/\r\n|\r/gu, "\n"); + if (!normalized || /^(?:diff --git |@@ |--- |\+\+\+ )/mu.test(normalized)) return normalized; + const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n"); + if (kind === "add" || kind === "added" || kind === "create") { + return [`--- /dev/null`, `+++ b/${path ?? "file"}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)].join("\n"); + } + if (kind === "delete" || kind === "deleted" || kind === "remove") { + return [`--- a/${path ?? "file"}`, `+++ /dev/null`, `@@ -1,${lines.length} +0,0 @@`, ...lines.map((line) => `-${line}`)].join("\n"); + } + return normalized; +} + +function traceDiffSummary(files: TraceDiffFile[]): string | null { + if (files.length === 0) return null; + const additions = files.reduce((total, file) => total + file.additions, 0); + const deletions = files.reduce((total, file) => total + file.deletions, 0); + const paths = files.map((file) => file.path).filter((path): path is string => Boolean(path)); + const stats = `+${additions} -${deletions}`; + if (files.length === 1) return `${paths[0] ?? "文件变更"} · ${stats}`; + const visiblePaths = paths.slice(0, 2).join(", "); + const remaining = Math.max(0, paths.length - 2); + const pathSummary = visiblePaths ? ` · ${visiblePaths}${remaining > 0 ? ` 等 ${paths.length} 个文件` : ""}` : ""; + return `${files.length} 个文件 · ${stats}${pathSummary}`; +} + +function traceDiffMarkdown(files: TraceDiffFile[]): string | null { + if (files.length === 0) return null; + return files.map((file, index) => { + const title = traceMarkdownInlineCode(file.path ?? `文件变更 ${index + 1}`); + const kind = traceDiffKindLabel(file.kind); + const stats = `${kind} · +${file.additions} -${file.deletions}`; + const diff = file.diff.trimEnd(); + return [`#### ${title}`, stats, diff ? traceMarkdownCodeFence("diff", diff) : "无可显示 diff"].join("\n\n"); + }).join("\n\n"); +} + +function traceDiffKindLabel(value: string | null): string { + if (value === "add" || value === "added" || value === "create") return "新增"; + if (value === "delete" || value === "deleted" || value === "remove") return "删除"; + if (value === "rename" || value === "renamed") return "重命名"; + return "修改"; +} + +function traceMarkdownInlineCode(value: string): string { + const text = value.replace(/\r\n|\r|\n/gu, " ").trim(); + const longestRun = Math.max(0, ...([...text.matchAll(/`+/gu)].map((match) => match[0].length))); + const fence = "`".repeat(longestRun + 1); + return `${fence}${text}${fence}`; +} + +function traceMarkdownCodeFence(language: string, body: string): string { + const longestRun = Math.max(0, ...([...body.matchAll(/`+/gu)].map((match) => match[0].length))); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return `${fence}${language}\n${body}\n${fence}`; +} + function traceToolPreview(command: string | null, fallbackToolName: string): string { return compactTraceOneLine(command ?? fallbackToolName, 140); } diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 5e593c8c..e4765c6b 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -1,6 +1,6 @@