From 16c93f62845fc43d439070f20b3a8f0eb0c3998c Mon Sep 17 00:00:00 2001 From: Clivia <132346501+Yanyutin753@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:23:42 +0800 Subject: [PATCH 1/9] =?UTF-8?q?test(download):=20=E4=BF=AE=E5=A4=8D=20Down?= =?UTF-8?q?loadPage=20=E5=81=B6=E5=8F=91=E5=A4=B1=E8=B4=A5=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=AD=89=E5=BE=85=E9=94=9A=E7=82=B9=E4=BB=8E=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=B9=B3=E5=8F=B0=E5=90=8D=E6=8D=A2=E6=88=90=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A9=B1=E5=8A=A8=E5=86=85=E5=AE=B9=20(#504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台分区卡不等 versionApi 就渲染(空 links 也画 Windows/macOS 标签), 而 curl 一键安装 / xattr Gatekeeper 块要资产到齐才挂载。原测试把等待 锚在静态平台名上、再同步 getByText 数据驱动内容,数据晚到时撞进空窗 (CI 偶发失败,本地难复现)。延迟 mock 80ms 可确定性复现(RED), 锚点换成资产文件名 / 命令本身后同条件下 7/7 通过(GREEN)。 --- .../download/__tests__/DownloadPage.test.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/download/__tests__/DownloadPage.test.tsx b/frontend/src/components/download/__tests__/DownloadPage.test.tsx index 6fbeb25aa..b097f6456 100644 --- a/frontend/src/components/download/__tests__/DownloadPage.test.tsx +++ b/frontend/src/components/download/__tests__/DownloadPage.test.tsx @@ -93,8 +93,12 @@ test("renders desktop and daemon downloads from the latest release assets", asyn render(); - // 平台分区:Windows / macOS / Linux - expect(await screen.findByText("Windows")).toBeInTheDocument(); + // 等待锚点必须是数据驱动内容(资产文件名):平台卡标签(Windows/macOS) + // 不等 versionApi 就渲染,锚在静态标签上会在数据晚到时撞进空窗 + expect( + await screen.findByText("LambChat-v2.8.1-Windows.msi"), + ).toBeInTheDocument(); + expect(screen.getByText("Windows")).toBeInTheDocument(); expect(screen.getByText("macOS")).toBeInTheDocument(); expect(screen.getByText("Linux")).toBeInTheDocument(); @@ -127,9 +131,10 @@ test("macOS card shows the Gatekeeper first-launch note with the xattr command", render(); - await screen.findByText("macOS"); + // 等数据驱动的命令本身(Gatekeeper 说明块要 links 到齐才挂载), + // 不锚在静态平台名上(见 test 1 注释) expect( - screen.getByText(/xattr -cr \/Applications\/LambChat\.app/), + await screen.findByText(/xattr -cr \/Applications\/LambChat\.app/), ).toBeInTheDocument(); // 「已损坏」说明同时出现在一键安装与 xattr 兜底两段文案里 expect(screen.getAllByText(/damaged/i).length).toBeGreaterThanOrEqual(1); @@ -144,9 +149,11 @@ test("macOS card promotes the one-line install script command", async () => { render(); - await screen.findByText("macOS"); + // 同上:等待命令本身出现(macOS 卡的安装块随资产数据异步挂载) expect( - screen.getByText(/curl -fsSL https:\/\/lambchat\.com\/install\.sh \| sh/), + await screen.findByText( + /curl -fsSL https:\/\/lambchat\.com\/install\.sh \| sh/, + ), ).toBeInTheDocument(); }); From 815b0d34c1ccd5c2cb52044ddddff8a97219ea94 Mon Sep 17 00:00:00 2001 From: clivia <3254822118@qq.com> Date: Wed, 9 Sep 2026 00:24:17 +0800 Subject: [PATCH 2/9] =?UTF-8?q?chore(release):=20bump=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=202.10.0=20=E2=86=92=202.10.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发版前置:六处版本文件同步 bump(frontend/package.json、tauri.conf.json、 android versionName/versionCode、iOS MARKETING_VERSION、pyproject.toml、 daemon __version__)。本版内容:#498 HITL 用量跨段累计、#499 本机身份段、 #500 审批队列重放收敛、#502 HITL resume 提速、#504 下载页测试竞态修复。 --- client/lambchat_sandbox/__init__.py | 2 +- frontend/android/app/build.gradle | 4 ++-- frontend/ios/App/App.xcodeproj/project.pbxproj | 4 ++-- frontend/package.json | 2 +- frontend/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/lambchat_sandbox/__init__.py b/client/lambchat_sandbox/__init__.py index 67c6911ae..dc0e80022 100644 --- a/client/lambchat_sandbox/__init__.py +++ b/client/lambchat_sandbox/__init__.py @@ -11,4 +11,4 @@ 2.x/0.3.x 均放行,旧 daemon 经 self-update 平滑升到对齐版本。 """ -__version__ = "2.10.0" +__version__ = "2.10.1" diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle index eb4b44977..2df315804 100644 --- a/frontend/android/app/build.gradle +++ b/frontend/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.lambchat.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2100 - versionName "2.10.0" + versionCode 2101 + versionName "2.10.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/frontend/ios/App/App.xcodeproj/project.pbxproj b/frontend/ios/App/App.xcodeproj/project.pbxproj index 973e2e4d2..a5c400699 100644 --- a/frontend/ios/App/App.xcodeproj/project.pbxproj +++ b/frontend/ios/App/App.xcodeproj/project.pbxproj @@ -352,7 +352,7 @@ INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.lambchat.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -372,7 +372,7 @@ INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; PRODUCT_BUNDLE_IDENTIFIER = com.lambchat.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/frontend/package.json b/frontend/package.json index e7d819e66..f69411efc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "lambchat-frontend", "private": true, - "version": "2.10.0", + "version": "2.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 0c4dbab82..d9716b161 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "LambChat", - "version": "2.10.0", + "version": "2.10.1", "identifier": "com.lambchat.app", "build": { "frontendDist": "../dist", diff --git a/pyproject.toml b/pyproject.toml index a0b344a53..77b98b8bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lambchat" -version = "2.10.0" +version = "2.10.1" description = "Full-featured AI Agent system with FastAPI, LangGraph, and LangSmith" readme = "README.md" requires-python = ">=3.12" From c4bdbbda91be4409e918ddf8073cf2b3b1608998 Mon Sep 17 00:00:00 2001 From: clivia <3254822118@qq.com> Date: Wed, 9 Sep 2026 01:04:18 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(frontend):=20ask=20human=20=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=A1=E6=8D=A2=E8=A1=8C=E4=BF=AE=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82=E9=85=8D=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=B2=99=E7=AE=B1=E7=A1=AE=E8=AE=A4=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApprovalPanel 用 replace(/\s+/g," ") 压平了后端多行 message,导致沙箱 确认门的编号操作清单挤成一行;新增 parseAskHumanMessage 纯函数把消息 解析为标题行/编号操作/补充散文三段 - 新增 ApprovalOpList 单据式只读清单:编号 + 动词胶囊 + 等宽命令, 发丝虚线分行、overflow-wrap:anywhere;ApprovalPanel 头部只放标题行, 清单进可滚动明细区;AskHumanItem pill 标签/内联预览/详情面板复用同渲染 - ScheduledTaskApprovalContent 从朴素 table+emoji 重设计为 spec-sheet 参数单 + 等宽芯片 + 代码块提示区 - 窄屏(≤640px)操作行两段式堆叠,命令独占整行;参数单标签列放宽 - 测试:9 个解析器用例 + 结构断言,全量 2602 前端用例通过 --- .../chat/ChatMessage/items/AskHumanItem.tsx | 80 +++-- .../src/components/panels/ApprovalOpList.tsx | 28 ++ .../src/components/panels/ApprovalPanel.tsx | 18 +- .../panels/ScheduledTaskApprovalContent.tsx | 149 +++++---- .../panels/__tests__/askHumanLayout.test.ts | 37 +++ .../panels/__tests__/askHumanMessage.test.ts | 77 +++++ .../src/components/panels/askHumanMessage.ts | 70 ++++ frontend/src/styles/approval.css | 314 ++++++++++++++++++ 8 files changed, 693 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/panels/ApprovalOpList.tsx create mode 100644 frontend/src/components/panels/__tests__/askHumanMessage.test.ts create mode 100644 frontend/src/components/panels/askHumanMessage.ts diff --git a/frontend/src/components/chat/ChatMessage/items/AskHumanItem.tsx b/frontend/src/components/chat/ChatMessage/items/AskHumanItem.tsx index 0a01e520b..aa3cf7c0c 100644 --- a/frontend/src/components/chat/ChatMessage/items/AskHumanItem.tsx +++ b/frontend/src/components/chat/ChatMessage/items/AskHumanItem.tsx @@ -24,6 +24,8 @@ import { ToolInlineDetails } from "./ToolInlineDetails"; import { ToolHoverCopyButton } from "./ToolHoverCopyButton"; import { ToolDurationFooter } from "./ToolDurationFooter"; import { MarkdownContent } from "../MarkdownContent"; +import { parseAskHumanMessage } from "../../../panels/askHumanMessage"; +import { ApprovalOpList } from "../../../panels/ApprovalOpList"; import type { FormField } from "../../../../types"; // ── Parsers matching backend ask_human schema ────────────────────────── @@ -282,6 +284,7 @@ function AskHumanDetail({ args, result, isPending }: ToolDetailProps) { const parsedResult = useMemo(() => parseResult(result), [result]); const { message, fields } = parsed; + const parsedMessage = useMemo(() => parseAskHumanMessage(message), [message]); // Supplement _other field when backend didn't include it // (e.g. cached events before the backend fix, or non-standard flows). @@ -364,15 +367,29 @@ function AskHumanDetail({ args, result, isPending }: ToolDetailProps) { )} - {/* Message (supports markdown) */} + {/* Message (structured ops list, or markdown prose) */} {message && (
-
- -
+ {parsedMessage.ops ? ( +
+ {parsedMessage.headline && ( +
+ {parsedMessage.headline} +
+ )} + +
+ ) : ( +
+ +
+ )}
)} @@ -478,6 +495,7 @@ const AskHumanItem = memo(function AskHumanItem({ const parsedResult = useMemo(() => parseResult(result), [result]); const { message, fields } = parsed; + const parsedMessage = useMemo(() => parseAskHumanMessage(message), [message]); // Supplement _other field when backend didn't include it // (e.g. cached events before the backend fix, or non-standard flows). @@ -531,10 +549,12 @@ const AskHumanItem = memo(function AskHumanItem({ const labelText = (() => { const base = t("chat.message.toolAskHuman"); + // 优先标题行(沙箱确认门是「确认在本机执行 N 项操作」), + // 避免把命令截断在 pill 标签里 if (message) { + const headline = parsedMessage.headline ?? parsedMessage.summary; const preview = - message.length > 50 ? message.slice(0, 47) + "…" : message; - // Strip markdown for pill label + headline.length > 50 ? headline.slice(0, 47) + "…" : headline; const plain = preview.replace(/[#*_`~>[\]!]/g, "").trim(); return `${base} — ${plain}`; } @@ -553,14 +573,31 @@ const AskHumanItem = memo(function AskHumanItem({ {/* Message summary */} {message && ( -
- - - {message.length > 200 ? message.slice(0, 197) + "…" : message} - +
+
+ + + {parsedMessage.headline ?? message} + +
+ {parsedMessage.ops && parsedMessage.ops.length > 0 && ( +
+ + 1 + + + {parsedMessage.ops[0].detail} + + {parsedMessage.ops.length > 1 && ( + + +{parsedMessage.ops.length - 1} + + )} +
+ )}
)} @@ -642,10 +679,11 @@ const AskHumanItem = memo(function AskHumanItem({ title: t("chat.message.toolAskHuman"), icon: , status, - subtitle: - message && message.length > 120 - ? message.slice(0, 117) + "…" - : message || undefined, + subtitle: message + ? parsedMessage.summary.length > 120 + ? parsedMessage.summary.slice(0, 117) + "…" + : parsedMessage.summary || undefined + : undefined, fallback: detailContent || undefined, buildDetail: (data) => ( diff --git a/frontend/src/components/panels/ApprovalOpList.tsx b/frontend/src/components/panels/ApprovalOpList.tsx new file mode 100644 index 000000000..f7a02e493 --- /dev/null +++ b/frontend/src/components/panels/ApprovalOpList.tsx @@ -0,0 +1,28 @@ +import { TerminalSquare } from "lucide-react"; +import type { AskHumanOp } from "./askHumanMessage"; + +/** + * 沙箱确认门的操作清单——「单据式」只读列表: + * 编号 + 动词标签 + 等宽命令文本,发丝线分行。 + * 同时用于审批卡(ApprovalPanel)与历史回放的 ask_human 详情面板。 + */ +export function ApprovalOpList({ ops }: { ops: AskHumanOp[] }) { + return ( +
+ {ops.map((op, index) => ( +
+ + {op.verb ? ( + + + ) : null} + {op.detail} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/panels/ApprovalPanel.tsx b/frontend/src/components/panels/ApprovalPanel.tsx index 5f472dea4..0e252d63b 100644 --- a/frontend/src/components/panels/ApprovalPanel.tsx +++ b/frontend/src/components/panels/ApprovalPanel.tsx @@ -30,6 +30,8 @@ import { toggleMultiSelectValue, toggleSingleSelectValue, } from "./approvalFormValidation"; +import { parseAskHumanMessage } from "./askHumanMessage"; +import { ApprovalOpList } from "./ApprovalOpList"; interface ApprovalPanelProps { approvals: PendingApproval[]; @@ -547,7 +549,9 @@ export function ApprovalPanel({ field.type === "multi_select" || field.type === "select", ); - const askHumanQuestion = approvalSummary; + // 头部只放标题行;编号操作清单/补充说明进明细区,长消息不再挤成一行 + const askHumanParsed = parseAskHumanMessage(currentApproval.message); + const askHumanQuestion = askHumanParsed.headline ?? approvalSummary; const isSubmitDisabled = isLoading || !isFormFieldsValid(currentApproval.fields, currentFormValues); @@ -693,6 +697,18 @@ export function ApprovalPanel({
)} + {isAskHuman && (askHumanParsed.ops || askHumanParsed.prose) && ( +
+ {askHumanParsed.ops ? ( + + ) : ( +

+ {askHumanParsed.prose} +

+ )} +
+ )} + {isAskHuman && (
{askHumanDisplayFields.map((field) => { diff --git a/frontend/src/components/panels/ScheduledTaskApprovalContent.tsx b/frontend/src/components/panels/ScheduledTaskApprovalContent.tsx index cafbc19b9..7b709ea6c 100644 --- a/frontend/src/components/panels/ScheduledTaskApprovalContent.tsx +++ b/frontend/src/components/panels/ScheduledTaskApprovalContent.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "react-i18next"; +import { clsx } from "clsx"; +import { CalendarClock, TerminalSquare, Check, Minus, Bot } from "lucide-react"; interface ScheduledTaskApprovalContentProps { preview: { @@ -11,68 +13,96 @@ interface ScheduledTaskApprovalContentProps { }; } +function SpecRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function MonoChip({ children }: { children: React.ReactNode }) { + return {children}; +} + /** - * Renders a scheduled task creation approval with i18n support. - * Used by ApprovalPanel when an approval has metadata.approval_type === "scheduled_task_create". + * Renders a scheduled task creation approval as a compact spec sheet: + * definition rows for the task parameters plus a code block for the run + * prompt. Used by ApprovalPanel when an approval has + * metadata.approval_type === "scheduled_task_create". */ export function ScheduledTaskApprovalContent({ preview, }: ScheduledTaskApprovalContentProps) { const { t } = useTranslation(); - const immediate = preview.run_on_start - ? `✅ ${t("approvals.scheduledTask.yes")}` - : `❌ ${t("approvals.scheduledTask.no")}`; - return ( -
-

{t("approvals.scheduledTask.confirmCreation")}

-

{t("approvals.scheduledTask.noTaskYet")}

+
+

+ {t("approvals.scheduledTask.confirmCreation")} +

+

+ {t("approvals.scheduledTask.noTaskYet")} +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {t("approvals.scheduledTask.name")} - {preview.name}
- {t("approvals.scheduledTask.agent")} - - {preview.agent_id} -
- {t("approvals.scheduledTask.schedule")} - {preview.schedule}
- {t("approvals.scheduledTask.runImmediately")} - {immediate}
- {t("approvals.scheduledTask.timeout")} - {preview.timeout_seconds}s
+
+ + {preview.name} + + + + + + + + + + + + {preview.run_on_start ? ( + + + + {preview.timeout_seconds}s + +
-

+

{t("approvals.scheduledTask.effect", { agent: preview.agent_id, schedule: preview.schedule, @@ -80,12 +110,15 @@ export function ScheduledTaskApprovalContent({ {preview.run_on_start && t("approvals.scheduledTask.effectImmediate")}

-

- {t("approvals.scheduledTask.promptSent")} -

-
-        {preview.message}
-      
+
+
+
+
+          {preview.message}
+        
+
); } diff --git a/frontend/src/components/panels/__tests__/askHumanLayout.test.ts b/frontend/src/components/panels/__tests__/askHumanLayout.test.ts index b563409e2..cae0b569e 100644 --- a/frontend/src/components/panels/__tests__/askHumanLayout.test.ts +++ b/frontend/src/components/panels/__tests__/askHumanLayout.test.ts @@ -13,6 +13,43 @@ const approvalCss = readFileSync( "utf8", ); +test("adapts sandbox-confirm op rows to two-line stacking on narrow screens", () => { + // 窄屏:编号 + 动词胶囊一行,命令独占整行换行并缩进对齐胶囊左缘, + // 避免胶囊挤压命令宽度;参数单标签列同步放宽 + expect(approvalCss).toMatch( + /@media \(max-width: 640px\)[\s\S]*?\.approval-op-row\s*\{[\s\S]*?flex-wrap:\s*wrap;/, + ); + expect(approvalCss).toMatch( + /@media \(max-width: 640px\)[\s\S]*?\.approval-op-detail\s*\{[\s\S]*?flex-basis:\s*100%;/, + ); + expect(approvalCss).toMatch( + /@media \(max-width: 640px\)[\s\S]*?\.approval-st-label\s*\{[\s\S]*?flex-basis:\s*5rem;/, + ); +}); + +test("renders sandbox-confirm batches as a structured op list, not one crammed line", () => { + // 沙箱确认门的 message 是「标题 + 编号操作」多行文本:头部只放标题行, + // 明细区渲染单据式操作清单,命令走等宽字体 + 任意断行 + expect(approvalSource).toMatch( + /parseAskHumanMessage\(currentApproval\.message\)/, + ); + expect(approvalSource).toMatch( + /askHumanQuestion = askHumanParsed\.headline \?\? approvalSummary/, + ); + expect(approvalSource).toMatch(/ApprovalOpList ops=\{askHumanParsed\.ops\}/); + expect(approvalSource).toMatch(/approval-ask-human-context/); + expect(approvalSource).toMatch(/askHumanParsed\.prose/); + expect(approvalCss).toMatch( + /\.approval-op-list\s*\{[\s\S]*?flex-direction:\s*column;/, + ); + expect(approvalCss).toMatch( + /\.approval-op-detail\s*\{[\s\S]*?overflow-wrap:\s*anywhere;/, + ); + expect(approvalCss).toMatch( + /\.approval-op-row \+ \.approval-op-row\s*\{[\s\S]*?border-top:\s*1px dashed/, + ); +}); + test("renders ask-human as a full card with numbered choices and footer actions", () => { expect(approvalSource).toMatch(/approval-card--ask-human/); expect(approvalSource).toMatch(/approval-ask-human-option/); diff --git a/frontend/src/components/panels/__tests__/askHumanMessage.test.ts b/frontend/src/components/panels/__tests__/askHumanMessage.test.ts new file mode 100644 index 000000000..ba68b804b --- /dev/null +++ b/frontend/src/components/panels/__tests__/askHumanMessage.test.ts @@ -0,0 +1,77 @@ +import { parseAskHumanMessage } from "../askHumanMessage"; + +test("parses sandbox confirm batch into headline and op rows", () => { + const parsed = parseAskHumanMessage( + "确认在本机执行 2 项操作:\n" + + "1. 执行命令:rm ~/下载/数据文件/a.json && echo 已删除; ls -A ~/下载\n" + + "2. 写入文件:/tmp/out.txt", + ); + expect(parsed.headline).toBe("确认在本机执行 2 项操作"); + expect(parsed.ops).toEqual([ + { + verb: "执行命令", + detail: "rm ~/下载/数据文件/a.json && echo 已删除; ls -A ~/下载", + }, + { verb: "写入文件", detail: "/tmp/out.txt" }, + ]); + expect(parsed.prose).toBeNull(); +}); + +test("keeps an op without a CJK verb prefix detail-only", () => { + const parsed = parseAskHumanMessage( + "确认在本机执行 1 项操作:\n1. 上传 3 个文件", + ); + expect(parsed.ops).toEqual([{ verb: null, detail: "上传 3 个文件" }]); +}); + +test("does not treat colons inside the command as the verb separator", () => { + const parsed = parseAskHumanMessage( + '确认在本机执行 1 项操作:\n1. 执行命令:echo "time: 10" && echo a:b', + ); + expect(parsed.ops?.[0].verb).toBe("执行命令"); + expect(parsed.ops?.[0].detail).toBe('echo "time: 10" && echo a:b'); +}); + +test("does not treat an ascii-colon command prefix as a CJK verb", () => { + const parsed = parseAskHumanMessage( + "确认在本机执行 1 项操作:\n1. rm:-rf /tmp/x", + ); + expect(parsed.ops?.[0].verb).toBeNull(); + expect(parsed.ops?.[0].detail).toBe("rm:-rf /tmp/x"); +}); + +test("plain multi-line question falls back to headline plus prose", () => { + const parsed = parseAskHumanMessage("接下来怎么做?\n请选择一个方案继续。"); + expect(parsed.headline).toBe("接下来怎么做?"); + expect(parsed.ops).toBeNull(); + expect(parsed.prose).toBe("请选择一个方案继续。"); +}); + +test("single-line question returns headline only", () => { + const parsed = parseAskHumanMessage("要继续吗?"); + expect(parsed.headline).toBe("要继续吗?"); + expect(parsed.ops).toBeNull(); + expect(parsed.prose).toBeNull(); +}); + +test("mixed numbered and unnumbered lines are not forced into op rows", () => { + const parsed = parseAskHumanMessage( + "确认执行:\n1. 执行命令:ls\n注意:以上操作不可撤销", + ); + expect(parsed.ops).toBeNull(); + expect(parsed.prose).toBe("1. 执行命令:ls\n注意:以上操作不可撤销"); +}); + +test("empty message yields no headline", () => { + const parsed = parseAskHumanMessage(""); + expect(parsed.headline).toBeNull(); + expect(parsed.ops).toBeNull(); + expect(parsed.prose).toBeNull(); +}); + +test("collapses to a single-line summary for pill labels", () => { + const summary = parseAskHumanMessage( + "确认在本机执行 1 项操作:\n1. 执行命令:rm -rf /tmp", + ).summary; + expect(summary).toBe("确认在本机执行 1 项操作: 1. 执行命令:rm -rf /tmp"); +}); diff --git a/frontend/src/components/panels/askHumanMessage.ts b/frontend/src/components/panels/askHumanMessage.ts new file mode 100644 index 000000000..07368eabf --- /dev/null +++ b/frontend/src/components/panels/askHumanMessage.ts @@ -0,0 +1,70 @@ +/** + * ask_human 消息结构化解析。 + * + * 后端沙箱确认门(sandbox_confirm.py)的 message 是「标题行 + 编号操作行」 + * 的多行文本;普通提问则是任意散文。这里把它拆成 headline / ops / prose, + * 供审批卡与工具 pill 按各自密度渲染——标题进头部、操作清单进明细区, + * 避免 replace(/\s+/g, " ") 把整段压成一行的排版事故。 + */ + +export interface AskHumanOp { + /** 操作动词(执行命令 / 写入文件 / …),无法可靠识别时为 null */ + verb: string | null; + /** 动词之后的具体内容(命令、路径等),原样保留 */ + detail: string; +} + +export interface AskHumanMessageShape { + /** 标题行(去掉收尾冒号),空消息为 null */ + headline: string | null; + /** 全部剩余行均为编号行时的结构化操作清单,否则 null */ + ops: AskHumanOp[] | null; + /** 非编号的补充说明(保留换行),没有则为 null */ + prose: string | null; + /** 全文压成单行的摘要(pill 标签 / 折叠摘要用) */ + summary: string; +} + +const NUMBERED_LINE = /^\d{1,3}\s*[.、).]\s*(.+)$/; +// 动词只认「短 + 纯中文」前缀,命令内部的冒号(含全角)不会被误切 +const VERB_PREFIX = /^([\u4e00-\u9fff]{1,8}):(.+)$/; + +function splitVerb(detail: string): AskHumanOp { + const match = VERB_PREFIX.exec(detail); + if (match) { + return { verb: match[1], detail: match[2] }; + } + return { verb: null, detail }; +} + +export function parseAskHumanMessage(message: string): AskHumanMessageShape { + const summary = message.replace(/\s+/g, " ").trim(); + + const lines = message + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return { headline: null, ops: null, prose: null, summary }; + } + + const headline = lines[0].replace(/[::]\s*$/, ""); + const rest = lines.slice(1); + + if (rest.length > 0 && rest.every((line) => NUMBERED_LINE.test(line))) { + return { + headline, + ops: rest.map((line) => splitVerb(NUMBERED_LINE.exec(line)![1].trim())), + prose: null, + summary, + }; + } + + return { + headline, + ops: null, + prose: rest.length > 0 ? rest.join("\n") : null, + summary, + }; +} diff --git a/frontend/src/styles/approval.css b/frontend/src/styles/approval.css index f0ef8ece1..bcc836ad5 100644 --- a/frontend/src/styles/approval.css +++ b/frontend/src/styles/approval.css @@ -308,6 +308,293 @@ padding: 0.65rem 1.1rem 1rem; } +/* ── 沙箱确认门操作清单(单据式只读列表) ── + * 也会渲染在工具实时面板(无 .approval-card 祖先),token 全部带兜底。 */ +.approval-op-list { + display: flex; + flex-direction: column; + border: 1px solid + var( + --theme-border, + color-mix(in srgb, var(--approval-border) 60%, transparent) + ); + border-radius: 0.75rem; + background: color-mix( + in srgb, + var(--theme-bg-card, var(--approval-bg)) 88%, + var(--theme-bg, transparent) + ); + overflow: hidden; +} + +.approval-op-row { + display: flex; + align-items: flex-start; + gap: 0.6rem; + padding: 0.55rem 0.8rem; +} + +.approval-op-row + .approval-op-row { + border-top: 1px dashed + var( + --theme-border, + color-mix(in srgb, var(--approval-border) 45%, transparent) + ); +} + +.approval-op-index { + min-width: 0.9rem; + padding-top: 0.15rem; + color: var(--theme-text-secondary, var(--approval-text-dim)); + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 1.4; + text-align: right; + user-select: none; +} + +.approval-op-verb { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 0.25rem; + margin-top: 0.05rem; + padding: 0.12rem 0.5rem; + border: 1px solid + color-mix(in srgb, var(--approval-accent, #f59e0b) 26%, transparent); + border-radius: 999px; + background: color-mix( + in srgb, + var(--approval-accent-light, #fef3c7) 16%, + transparent + ); + color: color-mix( + in srgb, + var(--approval-accent-hover, #d97706) 82%, + var(--approval-text, var(--theme-text, inherit)) + ); + font-size: 0.6875rem; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; +} + +.approval-op-detail { + min-width: 0; + flex: 1 1 auto; + color: var(--approval-text, var(--theme-text, inherit)); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.75rem; + line-height: 1.6; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +/* ask_human 明细区的上下文块:标题以下、表单以上 */ +.approval-ask-human-context { + padding: var(--approval-section-y) var(--approval-section-x) 0; +} + +.approval-ask-human-context-prose { + color: var(--approval-text-dim, var(--theme-text-secondary)); + font-size: 0.8125rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── 定时任务创建审批(spec-sheet 式参数单) ── */ +.approval-st { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.approval-st-lead { + margin: 0; + color: var(--approval-text, var(--theme-text)); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; +} + +.approval-st-note { + margin: 0; + color: var(--approval-text-dim, var(--theme-text-secondary)); + font-size: 0.75rem; + line-height: 1.5; +} + +.approval-st-sheet { + display: flex; + flex-direction: column; + border: 1px solid + var( + --theme-border, + color-mix(in srgb, var(--approval-border) 55%, transparent) + ); + border-radius: 0.75rem; + background: color-mix( + in srgb, + var(--theme-bg-card, var(--approval-bg)) 88%, + var(--theme-bg, transparent) + ); + overflow: hidden; +} + +.approval-st-row { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.5rem 0.9rem; +} + +.approval-st-row + .approval-st-row { + border-top: 1px dashed + var( + --theme-border, + color-mix(in srgb, var(--approval-border) 40%, transparent) + ); +} + +.approval-st-label { + flex: 0 0 4.5rem; + color: var(--approval-text-dim, var(--theme-text-secondary)); + font-size: 0.75rem; + line-height: 1.5; + user-select: none; +} + +.approval-st-value { + min-width: 0; + flex: 1 1 auto; + display: flex; + align-items: center; + overflow-wrap: anywhere; +} + +.approval-st-name { + color: var(--approval-text, var(--theme-text)); + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.5; +} + +.approval-st-mono { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0.14rem 0.55rem; + border: 1px solid + color-mix(in srgb, var(--approval-accent, #f59e0b) 18%, transparent); + border-radius: 0.45rem; + background: color-mix( + in srgb, + var(--approval-accent-light, #fef3c7) 10%, + transparent + ); + color: var(--approval-text, var(--theme-text)); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.75rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.approval-st-chip-icon { + flex-shrink: 0; + opacity: 0.55; +} + +.approval-st-flag { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.14rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + line-height: 1.5; +} + +.approval-st-flag--on { + background: color-mix(in srgb, #10b981 12%, transparent); + color: #059669; +} + +.approval-st-flag--off { + background: color-mix( + in srgb, + var(--theme-text-secondary, #78716c) 10%, + transparent + ); + color: var(--approval-text-dim, var(--theme-text-secondary)); +} + +.dark .approval-st-flag--on { + background: color-mix(in srgb, #10b981 16%, transparent); + color: #34d399; +} + +.approval-st-effect { + margin: 0; + color: var(--approval-text-dim, var(--theme-text-secondary)); + font-size: 0.75rem; + line-height: 1.6; +} + +.approval-st-prompt { + border: 1px solid + var( + --theme-border, + color-mix(in srgb, var(--approval-border) 55%, transparent) + ); + border-radius: 0.75rem; + background: color-mix( + in srgb, + var(--theme-bg-card, var(--approval-bg)) 88%, + var(--theme-bg, transparent) + ); + overflow: hidden; +} + +.approval-st-prompt-header { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.9rem; + border-bottom: 1px solid + color-mix( + in srgb, + var(--approval-border, var(--theme-border)) 35%, + transparent + ); + color: var(--approval-text-dim, var(--theme-text-secondary)); + font-family: var(--font-serif, ui-serif, Georgia, serif); + font-size: 0.6875rem; + font-weight: 500; + letter-spacing: 0.02em; + user-select: none; +} + +.approval-st-prompt-body { + margin: 0; + max-height: 14rem; + padding: 0.65rem 0.9rem; + overflow-y: auto; + overscroll-behavior: contain; + color: var(--approval-text, var(--theme-text)); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.75rem; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .approval-ask-human-shortcut-hint { align-items: center; gap: 0.25rem; @@ -364,6 +651,33 @@ border-radius: 1.25rem; } + /* 窄屏操作行改两段:编号 + 动词胶囊一行,命令独占整行换行, + * 避免胶囊挤压命令可用宽度、换行后左边距参差 */ + .approval-op-row { + flex-wrap: wrap; + gap: 0.4rem 0.55rem; + padding: 0.5rem 0.7rem; + } + + .approval-op-detail { + flex-basis: 100%; + padding-left: 1.45rem; /* 与动词胶囊左缘对齐 */ + } + + /* 参数单标签列放宽:四字标签 + 间距在窄屏不换行 */ + .approval-st-label { + flex-basis: 5rem; + } + + .approval-st-row { + gap: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .approval-st-prompt-body { + padding: 0.55rem 0.75rem; + } + .approval-ask-human-header { align-items: flex-start; flex-direction: row; From 7533401fefbd5542e3ef4fa5f062490677d65b55 Mon Sep 17 00:00:00 2001 From: clivia <3254822118@qq.com> Date: Wed, 9 Sep 2026 01:05:13 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix(sandbox):=20=E6=9C=AC=E5=9C=B0=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E6=96=AD=E8=81=94=E8=87=AA=E6=84=88=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9C=AA=20ACK=20=E5=B9=82=E7=AD=89=E9=87=8D=E6=8E=A8=20+=20da?= =?UTF-8?q?emon=20=E5=8E=BB=E9=87=8D=E4=B8=8E=20hello=20=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=20+=20=E9=80=9A=E9=81=93=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E5=85=88=E5=8F=91=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产断联(2026-09-09 lambchat.com)根因链:滚动发布与代理层瞬时抖动掐断 daemon 通道连接本身不可避免,但一次断联就丢整窗调用、且恢复要分钟级—— 本次把断联从「事故」降级为「秒级自愈」: - dispatch:tool_call 帧被断联通道的 BLPOP 消费即丢失(生产实测窗口内 exec 全灭 SANDBOX_TIMEOUT)。ACK 死线内周期幂等重推同一 call_id(ts 刷新避开陈旧丢弃门),daemon 重连后调用照常执行;结束时 LREM 清掉队列 残留,不留幽灵执行。 - daemon:call_id 跨连接去重(FIFO 环 512),重复帧记 duplicate_skipped 审计后跳过——at-least-once 下发 × 幂等执行,绝不重复跑用户机器上的命令。 - transport:hello 阶段独立 12s 超时。健康服务端建连即发 hello,迟迟不到 说明帧被僵死连接吞掉——不再挂满 45s 读超时,掉线窗口从分钟级压到秒级。 - daemon:断联日志带异常类型。httpx 超时族 str() 为空串,此前只有 『通道断开: 』盲日志。 - channel_frames:心跳先发射、后写注册表。注册表三连写走共享池,重任务 占满池时心跳跟着停发(本次生产实锤);keepalive 现在只依赖事件循环。 属主校验后移一拍,被踢旧流至多多带一个心跳帧。 验证:后端全量 4380 passed;本地沙箱链路 E2E 35/35 PASS; ruff/mypy 全绿。 --- client/lambchat_sandbox/daemon.py | 61 ++++++++++- client/lambchat_sandbox/transport.py | 30 ++++-- src/api/routes/sandbox.py | 19 ++-- src/infra/sandbox/relay/dispatch.py | 55 +++++++++- tests/api/routes/test_sandbox_routes.py | 5 +- tests/client/test_daemon.py | 69 ++++++++++++ tests/client/test_transport.py | 45 ++++++++ tests/infra/sandbox/relay/test_dispatch.py | 100 ++++++++++++++++++ tests/infra/sandbox/relay/test_relay_chain.py | 37 +++++++ 9 files changed, 401 insertions(+), 20 deletions(-) diff --git a/client/lambchat_sandbox/daemon.py b/client/lambchat_sandbox/daemon.py index d46b1d34e..a5af5c04a 100644 --- a/client/lambchat_sandbox/daemon.py +++ b/client/lambchat_sandbox/daemon.py @@ -36,6 +36,7 @@ import signal import sys import time +from collections import deque from collections.abc import AsyncIterator, Awaitable, Callable from pathlib import Path @@ -66,6 +67,34 @@ # 省一次 HTTP 往返;长命令到点补 ack,服务端 30s ACK 死线(远大于本值)无虞 _EXEC_ACK_DELAY_S = 8.0 +# call_id 去重环容量:服务端 dispatch 断联重推是 at-least-once 投递,重复帧 +# 幂等跳过(重复执行用户机器上的命令是不可接受的副作用)。重复帧总在几秒 +# 内到达,容量只需覆盖一个重推窗口内的调用数。 +_RECENT_CALL_IDS_MAX = 512 + + +class _CallDedupe: + """跨连接的 call_id 去重:断联重连后收到的重复帧跳过执行。 + + FIFO 环形淘汰:容量之外的旧 id 被遗忘——数小时前的迟到重复帧理论上会 + 重执行,但重推窗口只有 ACK 死线(30s),现实中不存在这种迟到。 + """ + + def __init__(self, capacity: int = _RECENT_CALL_IDS_MAX) -> None: + self._seen: set[str] = set() + self._order: deque[str] = deque() + self._capacity = capacity + + def remember(self, call_id: str) -> bool: + """首次见到返回 True 并登记;重复返回 False。""" + if call_id in self._seen: + return False + self._seen.add(call_id) + self._order.append(call_id) + while len(self._order) > self._capacity: + self._seen.discard(self._order.popleft()) + return True + def _default_machine_name() -> str: """machine_name 未配置时的展示名退回 hostname(截断防超长 URL)。""" @@ -113,6 +142,7 @@ async def run_daemon( client: ChannelClient | None = None attempt = 0 + dedupe = _CallDedupe() try: while True: if client is not None: @@ -129,6 +159,7 @@ async def run_daemon( cfg=cfg, executor=executor_, auditor=auditor_, + dedupe=dedupe, ) except TransportAuthError: await _silently_close(client) @@ -148,7 +179,13 @@ async def run_daemon( ) raise except Exception as exc: # noqa: BLE001 - 任何单连接失败都退避重连 - print(f"[sandbox] 通道断开: {exc};退避后重连…", file=sys.stderr, flush=True) + # httpx 超时族的 str() 为空串(ReadTimeout/ConnectTimeout), + # 只打消息会得到『通道断开: 』的盲日志——必须带类型名。 + print( + f"[sandbox] 通道断开: {type(exc).__name__}: {exc};退避后重连…", + file=sys.stderr, + flush=True, + ) attempt += 1 # 保留当前 client(流已关但 httpx 连接池可用)跨退避窗口:取消时仍能 post_offline await sleep_fn(backoff_delay(attempt)) @@ -165,10 +202,13 @@ async def _handle_channel( cfg: SandboxConfig, executor: Executor, auditor: Auditor, + dedupe: _CallDedupe | None = None, ) -> None: """单次连接内逐条处理 ToolCall;流结束/异常交回外层重连循环。""" async for call in calls: - await _process_call(client, call, cfg=cfg, executor=executor, auditor=auditor) + await _process_call( + client, call, cfg=cfg, executor=executor, auditor=auditor, dedupe=dedupe + ) async def _process_call( @@ -178,9 +218,14 @@ async def _process_call( cfg: SandboxConfig, executor: Executor, auditor: Auditor, + dedupe: _CallDedupe | None = None, ) -> None: """单条 ToolCall 的完整决策链:审计 received → ack → op 分发 → 迟到检查 → 执行 → done。 + call_id 去重(dedupe 非 None 时):服务端 dispatch 在 ACK 死线内对未确认 + 调用幂等重推(断联窗口丢帧的自愈),重复帧记 audit 后直接跳过——同一 + 调用绝不执行两次。 + 确认门控不在本层(spec §3.5 服务端实现):服务端统一确认门在 dispatch 前以 ask_human interrupt 完成,daemon 只收到已确认的执行请求,到达即执行。 ``confirm_policy`` 仍随连接上报(connect URL 第四段)供服务端门读取。 @@ -195,6 +240,18 @@ async def _process_call( path = str(call.payload.get("path", "")) session_id = _session_id_from_cwd(virtual_cwd) started = time.monotonic() + if dedupe is not None and not dedupe.remember(call.call_id): + auditor.log( + session_id, + { + "event": "duplicate_skipped", + "call_id": call.call_id, + "op": call.op, + "command": command, + "path": path, + }, + ) + return auditor.log( session_id, { diff --git a/client/lambchat_sandbox/transport.py b/client/lambchat_sandbox/transport.py index ad0bcd266..154ac6ceb 100644 --- a/client/lambchat_sandbox/transport.py +++ b/client/lambchat_sandbox/transport.py @@ -10,6 +10,7 @@ from __future__ import annotations +import asyncio import contextlib import json import random @@ -32,6 +33,12 @@ _CHANNEL_READ_TIMEOUT_S = 45.0 _CHANNEL_CONNECT_TIMEOUT_S = 10.0 +# hello 阶段独立超时(秒):健康服务端建连即发 hello(毫秒级),迟迟不到 +# 说明帧被断联通道吞掉(滚动发布切换/代理僵死)。不等 45s 读超时,快速 +# 失败进退避重连,把 daemon 掉线窗口从分钟级压到秒级(2026-09-09 生产断联 +# 实测:hello 丢失的连接挂满 45s 才重连)。 +_HELLO_TIMEOUT_S = 12.0 + # 结果回传/offline 通知的 per-request 超时(秒)。client 全局 timeout=None 是给 # SSE 长连接用的(心跳流不能被读超时切断),POST 沿用同一默认时服务端半死会让 # 回传永久挂起,拖垮 daemon 主循环。 @@ -189,14 +196,21 @@ async def connect(self) -> tuple[dict[str, Any], AsyncIterator[ToolCall]]: try: await _raise_for_status(response, "channel") hello: dict[str, Any] | None = None - async for line in lines: - frame = parser.feed(line) - if frame is None or frame.event != "hello": - continue - data = _parse_json_object(frame.data) - if data is not None: - hello = data - break + try: + # hello 独立短超时:僵死连接(建连后首帧永不到达)快速失败 + async with asyncio.timeout(_HELLO_TIMEOUT_S): + async for line in lines: + frame = parser.feed(line) + if frame is None or frame.event != "hello": + continue + data = _parse_json_object(frame.data) + if data is not None: + hello = data + break + except TimeoutError: + raise TransportError( + f"channel: {_HELLO_TIMEOUT_S:.0f}s 内未收到 hello 帧(连接疑似僵死)" + ) from None if hello is None: raise TransportError("SSE 通道在 hello 帧前关闭") except BaseException: diff --git a/src/api/routes/sandbox.py b/src/api/routes/sandbox.py index 261153883..36034cd77 100644 --- a/src/api/routes/sandbox.py +++ b/src/api/routes/sandbox.py @@ -94,11 +94,12 @@ async def channel_frames( ) -> AsyncIterator[str]: """SSE 帧生成器:hello -> (tool_call | 心跳) 循环;连接期心跳注册表。 - 心跳前校验属主:新连接 register 清空注册表后,旧流在此退场(后连踢前连), - 踢旧窗口收敛到一个心跳周期(15s)。旧流结束时 finally 的 unregister 只 - hdel 自己的字段,不会破坏新连接的注册。心跳带同一 ``version``/``platform`` - /``confirm_policy`` 重写——不带会把注册值降级回纯 node_id,daemon - 版本/平台/策略 15s 后丢失。 + 心跳先发射、后校验属主:keepalive 只依赖事件循环本身,注册表三连写走 + 共享池,池被重任务占满时心跳会跟着停发(2026-09-09 生产断联)。属主 + 校验(后连踢前连)因此后移一拍,踢旧窗口收敛到一两个心跳周期。旧流结 + 束时 finally 的 unregister 只 hdel 自己的字段,不会破坏新连接的注册。心 + 跳带同一 ``version``/``platform``/``confirm_policy`` 重写——不带会把注册 + 值降级回纯 node_id,daemon 版本/平台/策略 15s 后丢失。 多机(``machine_id`` 非空):属主校验按机器属主键(同机重连换属主踢旧流), 下发队列按 ``registry.queue_key`` 分机器;legacy 路径语义零变化。 @@ -121,6 +122,12 @@ async def channel_frames( while not stop.is_set(): now = loop.time() if now - last_beat >= _HEARTBEAT_SECONDS: + # 心跳先发射、后写注册表:keepalive 依赖的只是事件循环本身—— + # 注册表三连写走共享池,池被重任务占满时 await 会长时间挂起, + # 心跳跟着停发、daemon 侧 45s 读超时误判断联(2026-09-09 生产)。 + # 属主校验后移一个节拍,旧流多活一个心跳周期(repush 兜底误吃帧)。 + last_beat = now + yield ": heartbeat\n\n" if machine_id: # 多机属主校验:同机新连接已改写属主键时,旧流退场 owner = await redis.get(_owner_key(user_id, machine_id)) @@ -142,8 +149,6 @@ async def channel_frames( ) if machine_id: await redis.set(_owner_key(user_id, machine_id), client_id, ex=35) - last_beat = now - yield ": heartbeat\n\n" # 阻塞读下发队列:超时切片返回 None → 回到心跳检查;Redis 异常上抛 # 终结本流,daemon 走既有退避重连(与旧轮询模型同语义) item = await blocking.blpop(req_key, timeout=timeout_slice) diff --git a/src/infra/sandbox/relay/dispatch.py b/src/infra/sandbox/relay/dispatch.py index 76ee488d4..daf989afc 100644 --- a/src/infra/sandbox/relay/dispatch.py +++ b/src/infra/sandbox/relay/dispatch.py @@ -49,6 +49,45 @@ def _registry() -> SandboxClientRegistry: #: 50ms 轮询更糟);对测试 fake 而言这也是让出事件循环的点。 _LEGACY_POLL_INTERVAL = 0.05 +#: ACK 未确认时的幂等重推间隔(秒)。tool_call 帧被 channel 的 BLPOP 消费后 +#: 即脱离队列——连接在投递瞬间断掉(滚动发布/代理抖动)帧就丢了,daemon +#: 重连后无人再投递,调用方干等满 ACK 死线(2026-09-09 生产断联窗口内 exec +#: 全灭 SANDBOX_TIMEOUT)。重推同一 call_id(daemon 侧按 call_id 去重), +#: ACK 到达即停。 +_ACK_REPUSH_INTERVAL = 5.0 + + +class _AckRepusher: + """下发帧的幂等重推器:ack 之前周期性重推同一请求,结束时清掉队列残留。 + + ts 每次重推刷新——channel 侧按 ts 判龄丢弃陈旧帧,沿用原始时间戳会让 + 重推帧在 ACK 死线附近被自己的陈旧门吃掉。cleanup 用 LREM 精确移除本 + 调用推过的每一帧:调用失败(或完成)后队列里不留副本,daemon 重连后 + 不会执行「已无人等待的幽灵调用」。 + """ + + def __init__(self, redis, queue: str, req: dict) -> None: + self._redis = redis + self._queue = queue + self._req = req + self._pushed: list[str] = [] + + async def push(self) -> None: + self._req["ts"] = time.time() + payload = json.dumps(self._req) + self._pushed.append(payload) + await self._redis.rpush(self._queue, payload) + + def next_due(self) -> float: + return time.monotonic() + _ACK_REPUSH_INTERVAL + + async def cleanup(self) -> None: + for payload in self._pushed: + try: + await self._redis.lrem(self._queue, 0, payload) + except Exception: # noqa: BLE001 - 清理尽力而为 + pass + async def _pop_resp(redis, key: str, *, timeout: float | None = None): """读一条回传结果:新格式 RPUSH 队列优先(timeout 给出则 BLPOP 阻塞)。 @@ -108,14 +147,19 @@ async def dispatch_local_call( # 调用-机器绑定:results 端点据此拒绝同用户其他机器冒答(call_id 难猜, # 但绑定后模型上无冒答空间);无绑定键的旧调用(兼容窗口)跳过校验 await redis.set(_assign_key(call_id), target, ex=120) - await redis.rpush(registry.queue_key(user_id, target), json.dumps(req)) + repusher = _AckRepusher(redis, registry.queue_key(user_id, target), req) + await repusher.push() start = time.monotonic() acked = False ack_deadline = start + settings.SANDBOX_LOCAL_ACK_TIMEOUT exec_deadline = start + exec_timeout + next_repush = repusher.next_due() try: while time.monotonic() < exec_deadline: + if not acked and time.monotonic() >= next_repush: + await repusher.push() + next_repush = repusher.next_due() remaining = exec_deadline - time.monotonic() raw = await _pop_resp( redis, resp_key, timeout=min(_BLPOP_TIMEOUT, max(remaining, 0.01)) @@ -157,6 +201,7 @@ async def dispatch_local_call( await redis.delete(_assign_key(call_id)) except Exception: # noqa: BLE001 - 清理尽力而为 pass + await repusher.cleanup() def _stream_key(user_id: str, call_id: str) -> str: @@ -207,14 +252,19 @@ async def dispatch_local_stream( redis = _binary_redis() # stream list 是裸二进制帧(req/resp 均为 JSON,bytes 兼容) stream_key = _stream_key(user_id, call_id) resp_key = f"sandbox:resp:{call_id}" - await redis.rpush(registry.queue_key(user_id, target), json.dumps(req)) + repusher = _AckRepusher(redis, registry.queue_key(user_id, target), req) + await repusher.push() start = time.monotonic() acked = False ack_deadline = start + settings.SANDBOX_LOCAL_ACK_TIMEOUT exec_deadline = start + exec_timeout + next_repush = repusher.next_due() try: while time.monotonic() < exec_deadline: + if not acked and time.monotonic() >= next_repush: + await repusher.push() + next_repush = repusher.next_due() resp = None # results 端点为 RPUSH 队列(ack/done 按序);滚动窗口内旧实例仍 # SET(string):_pop_resp 捕获 WRONGTYPE 后回落 GET @@ -268,6 +318,7 @@ async def dispatch_local_stream( await redis.delete(key) except Exception: # noqa: BLE001 - 清理尽力而为 pass + await repusher.cleanup() _UPBLOB_WINDOW = 8 # 生产者在途帧数上限:×4MiB 帧 = Redis 峰值 ~32MiB diff --git a/tests/api/routes/test_sandbox_routes.py b/tests/api/routes/test_sandbox_routes.py index b4d2ecabe..d19084c38 100644 --- a/tests/api/routes/test_sandbox_routes.py +++ b/tests/api/routes/test_sandbox_routes.py @@ -244,7 +244,10 @@ async def collect(): assert frames[0].startswith("event: hello\n") assert frames[1].startswith("event: tool_call\n") - assert not any(f.startswith(": heartbeat") for f in frames) + # 先发射后校验(keepalive 不依赖共享池):失主观测点在发射之后—— + # 被踢旧流至多多带一个心跳帧,但不再续写注册表 + heartbeat_frames = [f for f in frames if f.startswith(": heartbeat")] + assert len(heartbeat_frames) <= 1 assert registry.beats == 0 # 失主后不再心跳续期,不把自己写回注册表 diff --git a/tests/client/test_daemon.py b/tests/client/test_daemon.py index 154d7fdec..3e4066cce 100644 --- a/tests/client/test_daemon.py +++ b/tests/client/test_daemon.py @@ -1215,3 +1215,72 @@ def _not_implemented(sig, callback): installed = daemon_module._install_sigterm_cancel() assert installed == [] + + +# ---------- +# call_id 去重 × 断联日志(2026-09-09 生产断联加固) +# ---------- + + +async def test_duplicate_call_id_executes_once(): + """dispatch 断联重推是 at-least-once 投递:daemon 按 call_id 去重,重复 + 帧记 audit 后跳过——重复执行用户机器上的命令是不可接受的副作用。""" + client = FakeClient(calls=[_call("c1"), _call("c1"), _call("c2")]) + executor = FakeExecutor( + result={"status": "ok", "stdout": "hi\n", "stderr": "", "exit_code": 0, "error": None} + ) + auditor = MemoryAuditor() + + await _run( + _cfg("none"), + FakeFactory([client, _terminator()]), + executor=executor, + auditor=auditor, + ) + + # c1 只执行一次、c2 一次;done 也只各回一次 + assert len(executor.calls) == 2 + dones = [cid for cid, body in client.posted if body.get("stage") == "done"] + assert dones == ["c1", "c2"] + + # 重复帧有专属审计记录,可事后核对 + duplicates = [ + event + for records in auditor.records.values() + for event in records + if event.get("event") == "duplicate_skipped" + ] + assert len(duplicates) == 1 + assert duplicates[0]["call_id"] == "c1" + + +async def test_channel_break_logs_exception_type(capsys): + """空消息异常(httpx.ReadTimeout/ConnectTimeout 的 str 为空串)断联时, + 日志必须带异常类型:『通道断开: ReadTimeout: 』而非『通道断开: 』—— + 盲日志是 2026-09-09 断联排查的硬伤。""" + class _EmptyTimeoutError(Exception): + """str() 为空的异常替身(httpx 超时族的形态)。""" + + async def calls_then_break(): + yield _call("c1") + raise _EmptyTimeoutError() + + class BreakAfterCall(FakeClient): + async def connect(self): + return {"sandbox_id": "sbx-fake"}, calls_then_break() + + breaker = BreakAfterCall(calls=[]) + terminator = _terminator() + + with pytest.raises(TransportAuthError): + await run_daemon( + _cfg("none"), + pat=PAT, + client_factory=FakeFactory([breaker, terminator]), + executor=FakeExecutor(), + auditor=MemoryAuditor(), + sleep_fn=SleepRecorder(), + ) + + err = capsys.readouterr().err + assert "通道断开: _EmptyTimeoutError" in err diff --git a/tests/client/test_transport.py b/tests/client/test_transport.py index 1f9b193c0..a8cded954 100644 --- a/tests/client/test_transport.py +++ b/tests/client/test_transport.py @@ -6,6 +6,7 @@ 退出 POST /api/sandbox/offline。 """ +import asyncio import json import random @@ -631,3 +632,47 @@ async def test_post_offline_without_machine_id_omits_query(): assert log[0].url.params.get("machine_id") is None await client.close() + + +# ---------- +# hello 阶段独立短超时(2026-09-09 生产断联:连接已被发布切换/代理掐死但 +# 首帧永不到达,daemon 挂满 45s 读超时才进退避,掉线窗口被拉到分钟级) +# ---------- + + +class _HangingStream(httpx.AsyncByteStream): + """建立连接后一个字节都不发的僵死流。""" + + def __init__(self) -> None: + self._release = asyncio.Event() + + async def __aiter__(self): + await self._release.wait() + yield b"" # pragma: no cover - 测试期内不抵达 + + +class _HangingTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=_HangingStream(), + ) + + +async def test_connect_hello_timeout_raises_transport_error(monkeypatch): + """hello 迟迟不到(僵死连接)时按 hello 独立超时快速失败,不挂满 45s + 读超时——TransportError 进既有退避重连,掉线窗口压到秒级。""" + import lambchat_sandbox.transport as transport_module + + monkeypatch.setattr(transport_module, "_HELLO_TIMEOUT_S", 0.1) + + client = _channel_client(_HangingTransport()) + import time as time_mod + + t0 = time_mod.monotonic() + with pytest.raises(TransportError, match="hello"): + await client.connect() + dt = time_mod.monotonic() - t0 + assert dt < 2, f"hello 超时应快速失败,耗时 {dt:.1f}s" + await client.close() diff --git a/tests/infra/sandbox/relay/test_dispatch.py b/tests/infra/sandbox/relay/test_dispatch.py index b60cbba1a..c8853e12c 100644 --- a/tests/infra/sandbox/relay/test_dispatch.py +++ b/tests/infra/sandbox/relay/test_dispatch.py @@ -58,6 +58,11 @@ async def blpop(self, key: str, timeout: float = 0): await asyncio.sleep(timeout) return None + async def lrem(self, key: str, count: int, value: str) -> None: + items = self.lists.get(key) + if items: + self.lists[key] = [x for x in items if x != value] + class _FakeRegistry: def __init__(self, online: bool): @@ -571,3 +576,98 @@ async def daemon(): assert exc.value.error_code == ErrorCode.SANDBOX_EXEC_FAILED assert "stream_interrupted" in str(exc.value.args_data.get("detail")) assert dt < 5, f"窗口等待未消费中断哨兵,耗时 {dt:.1f}s" + + +# ---------- +# ACK 死线内幂等重推:断联窗口内 tool_call 帧不丢 +# (2026-09-09 生产断联:帧被死连接的 BLPOP 消费即丢失,daemon 重连后无人 +# 再投递,agent 侧干等 30s ACK 超时全灭 SANDBOX_TIMEOUT) +# ---------- + + +async def test_unacked_call_is_repushed_until_ack(fake, monkeypatch): + """帧被断联通道吞掉(首次入队无人 ack)时,dispatch 周期性重推同一 + call_id 的幂等帧:daemon 重连后收到重推帧照常执行,调用整体不失败。""" + monkeypatch.setattr(dispatch_module, "_BLPOP_TIMEOUT", 0.01) + monkeypatch.setattr(dispatch_module, "_ACK_REPUSH_INTERVAL", 0.05) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_ACK_TIMEOUT", 2) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_EXEC_TIMEOUT", 5) + + received: list[dict] = [] + + async def daemon(): + # 首帧被「死连接」消费(lpop 后静默)——模拟 channel 断联丢帧 + await asyncio.sleep(0.02) + first = json.loads(await fake.lpop("sandbox:req:u1")) + received.append(first) + # 0.3s 后 daemon 重连,收到重推帧:ack + done + await asyncio.sleep(0.3) + retry = json.loads(await fake.lpop("sandbox:req:u1")) + received.append(retry) + assert retry["call_id"] == first["call_id"] # 幂等重推:同一调用 + assert retry["ts"] >= first["ts"] # ts 刷新:不被 channel 陈旧丢弃门吃掉 + await fake.set( + f"sandbox:resp:{retry['call_id']}", json.dumps({"user_id": "u1", "stage": "ack"}) + ) + await asyncio.sleep(0.02) + await fake.set( + f"sandbox:resp:{retry['call_id']}", + json.dumps({"user_id": "u1", "stage": "done", "status": "ok", "stdout": "hi"}), + ) + + task = asyncio.create_task(daemon()) + result = await dispatch_local_call("u1", "exec", {"command": "echo hi"}) + await task + assert result["stdout"] == "hi" + assert len(received) == 2 + + +async def test_acked_call_is_not_repushed(fake, monkeypatch): + """ack 到达后停止重推:正常往返只有一次入队(重推只针对未确认的丢失帧)。""" + monkeypatch.setattr(dispatch_module, "_BLPOP_TIMEOUT", 0.01) + monkeypatch.setattr(dispatch_module, "_ACK_REPUSH_INTERVAL", 0.05) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_ACK_TIMEOUT", 2) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_EXEC_TIMEOUT", 5) + + queue_pushes: list[str] = [] + original_rpush = fake.rpush + + async def counting_rpush(key: str, value: str) -> None: + if key == "sandbox:req:u1": + queue_pushes.append(value) + await original_rpush(key, value) + + fake.rpush = counting_rpush # type: ignore[method-assign] + + async def daemon(): + await asyncio.sleep(0.02) # 在首个重推点(0.05s)之前就 ack + req = json.loads(await fake.lpop("sandbox:req:u1")) + await fake.set( + f"sandbox:resp:{req['call_id']}", json.dumps({"user_id": "u1", "stage": "ack"}) + ) + await asyncio.sleep(0.15) # done 晚于多个重推点到点:验证 ack 后不再推 + await fake.set( + f"sandbox:resp:{req['call_id']}", + json.dumps({"user_id": "u1", "stage": "done", "status": "ok", "stdout": "hi"}), + ) + + task = asyncio.create_task(daemon()) + result = await dispatch_local_call("u1", "exec", {"command": "echo hi"}) + await task + assert result["stdout"] == "hi" + assert len(queue_pushes) == 1 # ack 一到重推即停,全程只入队一次 + + +async def test_ack_timeout_cleans_repush_residue(fake, monkeypatch): + """daemon 始终无响应时:重推按既有 ACK 死线失败,且 finally 清掉队列里 + 的全部重推残留——不给重连后的 daemon 留「无人等待的幽灵执行」。""" + monkeypatch.setattr(dispatch_module, "_BLPOP_TIMEOUT", 0.01) + monkeypatch.setattr(dispatch_module, "_ACK_REPUSH_INTERVAL", 0.05) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_ACK_TIMEOUT", 0.2) + monkeypatch.setattr(dispatch_module.settings, "SANDBOX_LOCAL_EXEC_TIMEOUT", 5) + + with pytest.raises(AppError) as exc: + await dispatch_local_call("u1", "exec", {"command": "echo hi"}) + assert exc.value.error_code == ErrorCode.SANDBOX_TIMEOUT + # 队列残留被 lrem 清空:断联期间积累的重推帧不外泄 + assert fake.lists.get("sandbox:req:u1") in (None, []) diff --git a/tests/infra/sandbox/relay/test_relay_chain.py b/tests/infra/sandbox/relay/test_relay_chain.py index 7aed530e9..27e2a5fa5 100644 --- a/tests/infra/sandbox/relay/test_relay_chain.py +++ b/tests/infra/sandbox/relay/test_relay_chain.py @@ -260,3 +260,40 @@ async def test_results_endpoint_writes_queue_with_expiry(wired): assert json.loads(queue[0])["stage"] == "ack" assert json.loads(queue[1])["stage"] == "done" assert wired.expires.count("sandbox:resp:call-x") == 2 + + +async def test_heartbeat_emitted_before_registry_writes(monkeypatch): + """心跳帧必须先于注册表三连写发射:keepalive 只该依赖事件循环本身。 + + 注册表写入走共享 Redis 池——重任务把池占满时 await 会长时间挂起,若心 + 跳跟在写后停发,daemon 侧 45s 读超时就误判断联(2026-09-09 生产断联)。 + 属主校验/注册表写后移到心跳发射之后。""" + monkeypatch.setattr(sandbox_route, "_HEARTBEAT_SECONDS", 0.05) + monkeypatch.setattr(sandbox_route, "_BLPOP_TIMEOUT", 0.01) + + calls: list[str] = [] + + class _OrderRecordingRegistry(_FakeRegistry): + async def get_active(self, user_id): + calls.append("get_active") + return ("c1", "node1") + + async def heartbeat(self, *args, **kwargs): + calls.append("heartbeat") + + redis = _FakeRedis() + registry = _OrderRecordingRegistry("legacy") # legacy 路径(无 machine_id) + stop = asyncio.Event() + + agen = sandbox_route.channel_frames(redis, registry, "u1", "c1", stop=stop) + try: + hello = await agen.__anext__() + assert hello.startswith("event: hello") + beat = await agen.__anext__() + assert beat.startswith(": heartbeat") + assert calls == [] # 心跳已在客户端手上,注册表还没写 + await agen.__anext__() # 从 yield 恢复:此刻才做属主校验 + 注册表写 + assert calls == ["get_active", "heartbeat"] + finally: + stop.set() + await agen.aclose() From 6a464a47c3904c6e2e8da061cefbe4d32eb76a06 Mon Sep 17 00:00:00 2001 From: clivia <3254822118@qq.com> Date: Wed, 9 Sep 2026 01:24:21 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(frontend):=20OAuth=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=82=B9=E5=87=BB=E5=8D=B3=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=20loading=EF=BC=8C=E9=98=B2=E6=AD=A2=E8=BF=9E=E7=82=B9?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loginWithOAuth 只设置 window.location.href,导航真正发生前页面仍可交互, 按钮没有任何反馈且可连点。现在点击即记录 pending 提供商:被点按钮的 品牌图标位换成旋转 spinner,全部 OAuth 按钮同时禁用(disabled + opacity + cursor 反馈),失败才复位,成功路径由页面跳走自然结束。 --- frontend/src/components/auth/AuthPage.tsx | 98 +++++++++++-------- .../oauthButtonLoadingSource.test.ts | 28 ++++++ 2 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/auth/__tests__/oauthButtonLoadingSource.test.ts diff --git a/frontend/src/components/auth/AuthPage.tsx b/frontend/src/components/auth/AuthPage.tsx index 621f06b71..f76263def 100644 --- a/frontend/src/components/auth/AuthPage.tsx +++ b/frontend/src/components/auth/AuthPage.tsx @@ -21,6 +21,7 @@ import { ShieldCheck, Workflow, Database, + Loader2, } from "lucide-react"; import { PasswordInput } from "./PasswordInput"; import toast from "react-hot-toast"; @@ -107,6 +108,10 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) { const [oauthProviders, setOauthProviders] = useState< { id: string; name: string }[] >([]); + // 点击跳转 OAuth 提供商期间锁住按钮(导航发生前页面仍可交互,防止连点) + const [oauthPendingProvider, setOauthPendingProvider] = useState< + string | null + >(null); const [registrationEnabled, setRegistrationEnabled] = useState(true); const [turnstileConfig, setTurnstileConfig] = useState({ enabled: false, @@ -186,12 +191,14 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) { setTurnstileKey((prev) => prev + 1); }, [mode]); - // OAuth 登录处理 + // OAuth 登录处理:点击即进入 loading,直到页面跳走;失败才复位 const handleOAuthLogin = useCallback( async (provider: string) => { + setOauthPendingProvider(provider); try { await loginWithOAuth(provider); } catch { + setOauthPendingProvider(null); toast.error(t("auth.oauthLoginFailed")); } }, @@ -790,49 +797,56 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) {