diff --git a/docs/architecture/chain-map-artifact-lifecycle.md b/docs/architecture/chain-map-artifact-lifecycle.md index 988abb7c..59e6df4a 100644 --- a/docs/architecture/chain-map-artifact-lifecycle.md +++ b/docs/architecture/chain-map-artifact-lifecycle.md @@ -5,10 +5,11 @@ Repo chain: openclaw-multi-session-plugins ↔ xworkmate-bridge ↔ xworkmate-ap ## Lifecycle States ``` -[prepare] → [execute] → [export] → [snapshot] → [download] → [sync] +[prepare] → [execute] → [collect-and-snapshot] → [export] → [snapshot] → [download] → [sync] prepare: mkdir tasks/// (multi-session-plugins) execute: tools write files (openclaw.svc.plus) + collect: copy media/tmp outputs into task scope (multi-session-plugins) export: scan + manifest + sign (multi-session-plugins) snapshot: assemble terminal result (xworkmate-bridge) download: signed URL proxy (xworkmate-bridge) @@ -85,7 +86,34 @@ The artifact export below will NOT find these files. ═══════════════════════════════════════════════════════════ ``` -## State 3: Export +## State 3: Collect And Snapshot + +``` +Caller: xworkmate-bridge → gateway.request('xworkmate.artifacts.collect-and-snapshot') +Handler: openclaw-multi-session-plugins → collectAndSnapshotXWorkmateArtifacts() + +Inputs: + sessionKey: mapped OpenClaw session key + runId: OpenClaw run id + artifactScope: tasks/// + sinceUnixMs: task start timestamp + +Process: + 1. Validate artifactScope matches sessionKey/runId. + 2. Scan fixed OpenClaw output roots: + - ~/.openclaw/media/ + - /tmp/openclaw/ + 3. Copy changed regular files into: + - tasks///artifacts/media/... + - tasks///artifacts/tmp-openclaw/... + 4. Skip symlinks and any path that escapes the fixed source roots. + +Output: + copiedFiles: relative paths under the current task scope + warnings: skipped paths or unavailable source roots +``` + +## State 4: Export ``` Caller: xworkmate-bridge → gateway.request('xworkmate.artifacts.export') @@ -136,29 +164,32 @@ Output: Each file: { relativePath, displayPath, size, contentType, sha256, inline?, ref } Fragile: - - Only scans tasks/// — misses media/browser/ etc. + - Export only scans tasks///; collect-and-snapshot must run first for global tool outputs - symlinks rejected even if pointing within workspace - maxFiles=200, maxInlineBytes=512KB — large files silently omitted - signing secret rotation invalidates all existing refs ``` -## State 4: Snapshot (Bridge) +## State 5: Snapshot (Bridge) ``` Caller: completeOpenClawTask() in openclaw_async_tasks.go triggered by probeOpenClawTask() detecting completion Process: - 1. Call gateway.request('xworkmate.artifacts.export') + 1. Call gateway.request('xworkmate.artifacts.collect-and-snapshot') + → Copy OpenClaw media/tmp outputs into the task scope + + 2. Call gateway.request('xworkmate.artifacts.export') → Get manifest from plugin - 2. openClawArtifactExport() + 3. openClawArtifactExport() → Transform manifest files into stable result shape → decorateOpenClawArtifactDownloadURLs() → Replace each file.ref with signed download URL: /artifacts/openclaw/download?ref=&t= - 3. Build terminal snapshot: + 4. Build terminal snapshot: { success: true, status: "completed", @@ -173,9 +204,9 @@ Process: } } - 4. Store snapshot for xworkmate.tasks.get queries + 5. Store snapshot for xworkmate.tasks.get queries - 5. Send SSE session.update to app + 6. Send SSE session.update to app Fragile: - If export returns empty manifest, snapshot has no artifacts @@ -183,7 +214,7 @@ Fragile: - Snapshot stored only in memory (lost on bridge restart) ``` -## State 5: Download (Bridge Proxy) +## State 6: Download (Bridge Proxy) ``` Endpoint: GET /artifacts/openclaw/download?ref=&t= @@ -222,7 +253,7 @@ Fragile: - 3 retry attempts only — persistent gateway failure = permanent 502 ``` -## State 6: Sync (App) +## State 7: Sync (App) ``` Location: xworkmate-app diff --git a/docs/architecture/chain-map-task-execution.md b/docs/architecture/chain-map-task-execution.md index bbf748ba..81006289 100644 --- a/docs/architecture/chain-map-task-execution.md +++ b/docs/architecture/chain-map-task-execution.md @@ -135,14 +135,23 @@ openclaw-multi-session-plugins ├─ signArtifactRef(sessionKey, runId, relativePath) └─ Return manifest + base64 file contents + Receives gateway RPC: xworkmate.artifacts.collect-and-snapshot + collectAndSnapshotXWorkmateArtifacts() + ├─ Scan ~/.openclaw/media/ and /tmp/openclaw/ for files changed since task start + ├─ Copy regular files into tasks///artifacts/ + ├─ Preserve source grouping such as artifacts/media/... and artifacts/tmp-openclaw/... + └─ Reject symlinks and paths outside the fixed source roots + ─────────────────────────────────────────────────────────── -CRITICAL GAP: Tool outputs in ~/.openclaw/media/* or /tmp/* -are NOT in tasks/// → NOT exported → NOT visible -to bridge → NOT synced to app. +FIXED GAP: Bridge calls collect-and-snapshot after agent.wait +terminal completion and before xworkmate.artifacts.export. +Tool outputs in ~/.openclaw/media/* or /tmp/openclaw/* are +now copied into tasks///artifacts/ before export. ─────────────────────────────────────────────────────────── Back to xworkmate-bridge: completeOpenClawTask() + ├─ Call xworkmate.artifacts.collect-and-snapshot via gateway ├─ Call xworkmate.artifacts.export via gateway ├─ Collect artifact manifest ├─ Build terminal snapshot with: @@ -203,7 +212,7 @@ to bridge → NOT synced to app. ## Fragile Points 1. **F1: Tool output path mismatch** — Tools save to media/, plugin exports from tasks/ → gap -2. **F2: Session key mismatch** — App/Bridge/Plugin must agree on sessionKey format +2. **F2: Session key mismatch** — Bridge maps App threadId to an explicit OpenClaw sessionKey before prepare/chat/export 3. **F3: Prepare timing** — If prepare fails after send, no scope directory exists 4. **F4: Admission gate rejection** — Queue full → OPENCLAW_GATEWAY_BUSY → app must handle 5. **F5: Bridge restart** — In-memory sessions lost → app must detect and recover diff --git a/docs/architecture/cross-repo-task-state-workflow.md b/docs/architecture/cross-repo-task-state-workflow.md index 922dcf0c..b9d9e9f6 100644 --- a/docs/architecture/cross-repo-task-state-workflow.md +++ b/docs/architecture/cross-repo-task-state-workflow.md @@ -33,6 +33,7 @@ The bridge owns the public protocol boundary: - Expose the unified `/acp/rpc` entrypoint. - Resolve routing for agent providers and gateway/OpenClaw. - Normalize provider/OpenClaw results into a stable result shape. +- Map app `threadId` to an explicit OpenClaw `sessionKey`; do not pass app draft keys directly to OpenClaw. - Stream `session.update` events. - Serve `xworkmate.tasks.get` snapshots for asynchronous recovery and terminal result lookup. - Serve `xworkmate.tasks.cancel` for OpenClaw task cancellation. @@ -47,6 +48,7 @@ OpenClaw plugins own execution-time artifact scope: - Allocate `runId`. - Prepare `artifactScope = tasks//`. - Execute the user task. +- Collect OpenClaw media/tmp tool outputs into the current task artifact scope after `agent.wait`. - Export real final deliverables into the current task artifact scope. - Validate that artifact scope matches the current session and run. - Return artifact refs/files to the bridge. @@ -247,7 +249,8 @@ flowchart TD PREP --> SCOPE["artifactScope = tasks//"] SCOPE --> DIR["artifactDirectory"] DIR --> RUN["OpenClaw writes files"] - RUN --> EXPORT["exportXWorkmateArtifacts"] + RUN --> COLLECT["collectAndSnapshotXWorkmateArtifacts"] + COLLECT --> EXPORT["exportXWorkmateArtifacts"] EXPORT --> VALIDATE{"scope matches session/run?"} VALIDATE -->|No| ERR["Reject cross-session / cross-run artifact"] VALIDATE -->|Yes| MANIFEST["manifest + artifact refs/files"] diff --git a/docs/cases/openclaw-gateway-e2e-regression/ARCH_DEADCODE_CLEANUP.md b/docs/cases/openclaw-gateway-e2e-regression/ARCH_DEADCODE_CLEANUP.md new file mode 100644 index 00000000..735b0638 --- /dev/null +++ b/docs/cases/openclaw-gateway-e2e-regression/ARCH_DEADCODE_CLEANUP.md @@ -0,0 +1,173 @@ +# 架构优化与死代码清理建议 + +跨 App (Flutter/Dart) + Plugin (TypeScript) 两层系统性扫描结果。 + +> Bridge 层 (Go, `xworkmate-bridge`) 代码未在 workspace 中,仅从 App 侧调用签名反推。 + +## 一、可立即删除的死代码 + +### D1 [App] `expectedArtifactExtensions` — 全链路透传但从未验证 + +| 文件 | 行号 | +|------|------| +| `lib/runtime/runtime_models_runtime_payloads.dart` | 930, 946, 971, 990, 1007, 1062-1063 | + +`expectedArtifactExtensions` 字段在 `OpenClawTaskAssociation` 中完整声明、序列化 (`toJson`/`toTaskGetParams`)、反序列化 (`fromJsonOrNull`)、在 `copyWith` 中保留——但在整个 `lib/` 和 `test/` 的生产逻辑中,**没有任何验证逻辑读取这个字段**。它仅被测试断言验证"不包含"。 + +**清理建议**: 保留数据模型定义(Bridge 可能发送此字段),但在 App 日志/错误信息中添加 warning 级日志说明此字段未被验证,避免误导后续开发者。 + +### D2 [App] `DesktopGoTaskService` — 零价值透传层 + +| 文件 | 行号 | +|------|------| +| `lib/runtime/go_task_service_desktop_service.dart` | 1-84(全文件) | + +每个方法都是 `=> _acpTransport.sameMethod(...)`,无一例外。构造函数接受 `GatewayRuntime gateway` 参数但从未存储或使用(行 8-10)。`@visibleForTesting` 的 `acpTransportForTest` 也无测试引用。 + +**清理建议**: 删除此类。将 `app_controller_desktop_core.dart` 中的 `goTaskServiceClientInternal` 字段类型从 `GoTaskServiceClient` 改为直接引用 `ExternalCodeAgentAcpTransport`。接口 `GoTaskServiceClient` 仅被 `DesktopGoTaskService` 实现——如果后者删除,接口也可删除。 + +### D3 [App] `GoTaskServiceClient` 上 3 个从未调用的方法 + +| 方法 | 文件 | 行号 | +|------|------|------| +| `loadExternalAcpCapabilities` | `go_task_service_client.dart` + `external_code_agent_acp_desktop_transport.dart` | ~35, 15 | +| `resolveExternalAcpRouting` | `go_task_service_client.dart` + `external_code_agent_acp_desktop_transport.dart` | ~79, 24 | +| `closeTask` | `go_task_service_client.dart` + `external_code_agent_acp_desktop_transport.dart` | ~349, 66 | + +这三个方法在 `ExternalCodeAgentAcpDesktopTransport` 中有完整实现,通过 `DesktopGoTaskService` 透传,但 `goTaskServiceClientInternal` 从不调用它们(只有 `executeTask`/`getTask`/`cancelTask`/`dispose` 被调用)。 + +**清理建议**: 如果删除 `DesktopGoTaskService`(D2),这些方法自然成为 `ExternalCodeAgentAcpDesktopTransport` 上未曾从 production 路径调用的公开方法。在 `ExternalCodeAgentAcpTransport` 接口上加 `// UNUSED in production; kept for potential future use` 注释,或直接删除。 + +### D4 [App] `openClawGatewayActiveTasksInternal` — 仅测试使用的 getter + +| 文件 | 行号 | +|------|------| +| `lib/app/app_controller_desktop_core.dart` | 270-271 | + +```dart +int get openClawGatewayActiveTasksInternal => + openClawGatewayActiveTurnsInternal.length; +``` + +生产代码中零引用(只在 `test/runtime/assistant_execution_target_test.dart` 中被断言)。后备字段 `openClawGatewayActiveTurnsInternal` 被多处生产代码直接使用。 + +**清理建议**: 删除 getter,测试中改为直接访问 `openClawGatewayActiveTurnsInternal.length`。 + +### D5 [App] 5 个 `@visibleForTesting` 无测试引用的导出 + +| 导出 | 文件 | 行号 | +|------|------|------| +| `addRuntimeLogForTest` | `lib/runtime/gateway_runtime_core.dart` | 91 | +| `usesSessionClient` | `lib/runtime/gateway_runtime_core.dart` | 100 | +| `sessionClientForTest` | `lib/runtime/gateway_runtime_core.dart` | 103 | +| `clientForTest` | `lib/runtime/external_code_agent_acp_desktop_transport.dart` | 31 | +| `acpTransportForTest` | `lib/runtime/go_task_service_desktop_service.dart` | 77 | + +**清理建议**: 全部删除。若后续测试需要,届时再加。 + +### D6 [Plugin] `openClawSnapshotSources` 中 `params.snapshotSourceRoots` 路径 + +| 文件 | 行号 | +|------|------| +| `src/exportArtifacts.ts` | 992-1006 | + +该函数的 `configured` 路径从 `params.snapshotSourceRoots` 读取——但没有任何生产调用者(`index.ts` 中的 gateway handler、`bridgeAgents.ts`)传递这个参数。`pluginConfig.snapshotSourceRoots` 也未在 `openclaw.plugin.json` 配置 schema 中声明。运行时始终回退到默认的 `~/.openclaw/media` + `os.tmpdir()/openclaw`。 + +**清理建议**: 删除 `configured` 路径,简化函数为直接返回默认值数组。如果未来需要外部配置,届时再加。 + +--- + +## 二、架构优化建议 + +### A1 [App] 消除 `_artifactBytesResultInternal` 的 `skipped`/`failed` 语义模糊 + +**文件**: `lib/app/app_controller_desktop_runtime_helpers.dart`,行 901-938 + +当前 7 条返回路径中,4 条返回 `skipped`(包括"授权缺失"这种真实错误),1 条返回 `failed`。调用者(行 828-831)只检查 `bytesResult.failed`,而 `skipped` 的 `failed` 为 `false` → 授权失败被静默处理。 + +**建议**: 将"授权缺失"(行 927-929)从 `skipped` 改为 `failed`,使调用者能区分"有 URL 但下载失败"和"根本没有 URL"。 + +### A2 [App] `gatewayResultCodeRequiresNewSessionInternal` 缺失关键错误码 + +**文件**: `lib/app/app_controller_desktop_thread_actions.dart`,行 1555 + +当前列表缺少: +- `OPENCLAW_GATEWAY_SOCKET_CLOSED` — 在 `app_controller_desktop_runtime_helpers.dart:295` 引用但未加入列表 +- 回退 `'error'` — `gatewayTerminalResultCodeInternal` 可能返回此值,但当前默认为"不需要新会话"(return false),这可能不安全 + +**建议**: 追加 `OPENCLAW_GATEWAY_SOCKET_CLOSED`;将回退逻辑从 `return false` 改为 `return true`(未识别的错误 → 保守策略:开新会话)。 + +### A3 [App] `clearGatewayTaskArtifactStateInternal` 是不必要的间接层 + +**文件**: `lib/app/app_controller_desktop_thread_actions.dart`,行 1299 + +两个调用者都在同一个父函数 `applyGatewayChatResultInternal` 内,且都传入 `syncStatus: 'failed'`。函数体是单次 `upsertTaskThreadInternal` 调用。 + +**建议**: 将调用内联,删除此函数。 + +### A4 [Plugin] `XWorkmateArtifactPrepare` 类型导出但无消费 + +**文件**: `src/exportArtifacts.ts`,行 50-60 + +该类型作为 `prepareXWorkmateArtifacts` 的返回类型导出,但在 `bridgeAgents.ts` 或 `index.ts` 中从未被显式导入为类型注解——代码使用解构和 `const` 推断类型。 + +**建议**: 如果 TypeScript 结构化类型推断不需要此 export,移除 `export` 关键字。保留类型定义(内部使用),但不再对外暴露。 + +### A5 [Plugin] `manifestMarkdown` 在 App/Bridge 层未消费 + +**文件**: `src/exportArtifacts.ts`,行 47, 354, 466, 470-504 + +`manifestMarkdown` 由 `formatArtifactManifestMarkdown` 生成并附加到每个 `XWorkmateArtifactExport`。但 grep 整个 xworkmate-app 代码库,此字段名从未在 TypeScript 生产代码中被按名消费。它仅由插件的 tool response 使用(作为 markdown 格式化文本)。 + +**建议**: 评估是否真的需要跨 ACP 传输 markdown 字符串(增大 payload)。如果 App 层不需要,可将 `formatArtifactManifestMarkdown` 调用下移到 tool handler 层(`index.ts`),而不是嵌入 export payload。 + +### A6 [跨层] `sinceUnixMs` 时间戳来源不确定导致文件遗漏 + +**问题**: `exportXWorkmateArtifacts` 的 `sinceUnixMs` 参数控制"只导出此时间之后修改的文件"。但在当前调用链中,此时间戳由 Bridge 或 Gateway 传入,App 侧无法控制。当 Gateway 在 session.start 之前就开始写入文件,而这些文件的时间戳早于 `sinceUnixMs` → 被过滤。 + +**跨层修复**: +- Plugin 侧:当 scope 目录的 `birthtimeMs` 晚于 `sinceUnixMs` 时,使用 `scopeStat.birthtimeMs` 代替 sinceUnixMs(已在 ROOT_CAUSE_ANALYSIS.md Fix 7)。 +- App 侧:`persistGoTaskArtifactsForSessionInternal` 中,当 `artifacts` 列表为空且 `isOpenClawNoExportedArtifactsGuardResultInternal` 为 false 时,fallback 扫描 workspace 中 lastRunAtMs 之后修改的文件(行 787-791)——此逻辑已存在但未考虑 Gateway agent 的输出目录可能不在 workspace 根。 + +--- + +## 三、清理优先级与预估节省 + +| 编号 | 类型 | 层 | 影响 | 节省 | +|------|------|-----|------|------| +| D2+D3 | 删除类+3方法 | App | 消除无意义抽象 | ~130 行 | +| D4 | 删除 getter | App | 消除仅测试的 production 导出 | 3 行 | +| D5 | 删除 5 个 test-only getter | App | 清理未使用的测试 API | ~20 行 | +| D1 | 加 warning 注释 | App | 文档化而非删除 | 0 行 | +| D6 | 删除死分支 | Plugin | 简化 snapshot 逻辑 | ~15 行 | +| A3 | 内联 | App | 减少函数调用层 | ~10 行 | +| A4 | 移除 export | Plugin | 缩小公开 API | 1 行 | +| A5 | 下移调用 | Plugin | 减少跨层 payload | ~5 行移动 | +| A1 | 改语义 | App | 修复静默错误 | 1 行 | +| A2 | 补错误码 | App | 修复保守性缺口 | 2 行 | + +**总计**: 可删除约 170 行死代码,2 个语义修复各 1 行,2 个架构重构(不含跨层 A6)。 + +--- + +## 四、Bridge 层(xworkmate-bridge, Go)的推断问题 + +Bridge 层代码不在当前 workspace 中,但从 App 侧调用签名可推断以下架构问题: + +1. **ACP `session.start` 缺少 `expectedArtifactDirs`** — App 侧 `toExternalAcpParams()` 未传此字段,Bridge ACP handler 也未接收。这是 Plugin Fix 0 的前置依赖。 + +2. **`xworkmate.tasks.get` 的快照模式** — 当前实现返回单一快照(status + artifacts),不支持"仅返回 artifacts"或"等待 artifacts ready"语义。建议增加可选参数 `waitForArtifacts: true` 或 `minArtifactCount: N`,由 Bridge 轮询至条件满足后再返回。 + +3. **`xworkmate.artifacts.collect-and-snapshot` 的调用时机** — 当前 snapshot 在 agent 执行完成后调用,但 agent 在 session 结束前就可能已写入文件。建议在 session 生命周期中加入 `beforeSessionEnd` hook 自动触发 snapshot。 + +--- + +## 五、单元测试缺口 + +| 缺口 | 应在文件 | +|------|----------| +| `_recoveredResultFromTaskSnapshot` 的 Map 类型敏感性 | `external_code_agent_acp_desktop_transport.dart` 测试 | +| `_isRecoverableTaskStreamClosure` 对 `ACP_SSE_NO_RESULT` 的覆盖 | 同上 | +| `persistGoTaskArtifactsForSessionInternal` 的 `partial` 状态分支 | `assistant_execution_target_test.dart` | +| `contentTypeForPath` 的 `.pptx`/`.xlsx`/`.mov`/`.webm` 分支 | `exportArtifacts.test.ts` | +| `openClawSnapshotSources` 的默认路径 | `exportArtifacts.test.ts`(已有隐含覆盖但无显式断言) | diff --git a/docs/cases/openclaw-gateway-e2e-regression/ROOT_CAUSE_ANALYSIS.md b/docs/cases/openclaw-gateway-e2e-regression/ROOT_CAUSE_ANALYSIS.md new file mode 100644 index 00000000..811cd06a --- /dev/null +++ b/docs/cases/openclaw-gateway-e2e-regression/ROOT_CAUSE_ANALYSIS.md @@ -0,0 +1,265 @@ +# Root Cause Analysis & Fix: "openclaw returned partial artifacts without required final deliverables" + +## 四层调用链 + +``` +XWorkmate App (Flutter/Dart) + └─ xworkmate-bridge (Go) ← ACP JSON-RPC / SSE proxy + └─ openclaw-multi-session-plugins (TypeScript) ← artifact scope 管理 + └─ OpenClaw Gateway Runtime (127.0.0.1:18789) ← AI agent 执行 +``` + +每个层在 artifact 交付链中承担不同职责。缺陷分布在所有四层。 + +--- + +## Fix 0 [P0 — Plugin 层] OpenClaw agent 写入路径与 artifact scope 不匹配 + +**文件**: `src/exportArtifacts.ts` +**函数**: `exportXWorkmateArtifacts()` +**行号**: 238-259 + +**问题**:`exportXWorkmateArtifacts()` 只扫描 `tasks/{sessionKey}/{runId}/` scope 目录(由 `resolveScopeRoot()` 计算)。但 OpenClaw agent 执行时的默认工作目录是 workspace 根(`~/.openclaw/workspace`),agent 自然将 PNG/PDF/视频写入 `assets/images/`、`reports/`、`video/` 等 workspace 根路径,而非 `tasks/{sessionKey}/{runId}/` 子目录。 + +**证据**:测试用例 `"does not adopt workspace root files even with a current-run timestamp"`(行 334-352)和 `"does not adopt same-thread delivery files when the prepared task scope is empty"`(行 459-517)明确验证了 workspace 根文件和 thread 目录下的文件 **不会被** 纳入 artifact scope。但 E2E prompt 中 agent 大概率将输出写入 workspace 相对路径 — 这些文件对 Plugin 完全不可见。 + +**修复**:在 `exportXWorkmateArtifacts()` 中新增一个扫描源——当 scope 目录为空时,扫描 workspace 根下的指定子目录(由 `expectedArtifactDirs` 参数指定): + +```typescript +// 行 256 candidates = scopedCandidates 之后插入 +const expectedDirs = safeStringList(params.expectedArtifactDirs); +if (candidates.length === 0 && expectedDirs.length > 0) { + for (const dir of expectedDirs) { + const dirPath = path.join(workspaceRoot, safeInputRelativePath(dir, "expectedArtifactDir")); + if (await directoryExists(dirPath)) { + const dirCandidates = await collectCandidates({ + scanRoot: dirPath, + relativeRoot: workspaceRoot, + sinceUnixMs, + warnSkippedSymlinks: true, + warnings, + ignoreRules: await loadArtifactIgnoreRules(dirPath, warnings), + }); + for (const c of dirCandidates) { + candidates.push(c); + } + } + } +} +``` + +**Bridge 侧配合**:在 ACP `session.start` 请求参数中新增 `expectedArtifactDirs: ["assets/images", "reports", "video"]`,由 Bridge 透传给 Plugin。 + +--- + +## Fix 1 [P0 — App 层] `_recoveredResultFromTaskSnapshot` 引用相等 Bug + +**文件**: `lib/runtime/external_code_agent_acp_desktop_transport.dart` +**函数**: `_recoveredResultFromTaskSnapshot()` +**行号**: 403-407 + +**当前代码**: +```dart +final artifactRecord = _castMap(snapshot['artifacts']); +final artifactItems = artifactRecord['items']; +if (artifactItems is List && result['artifacts'] == artifactRecord) { // ← Dart == Map 是引用相等 + result['artifacts'] = artifactItems; +} +``` + +**问题**: `_castMap` 对 `Map` 返回自身引用;对泛型 `Map` 调用 `.cast()` 创建新对象。经过 `..._castMap(snapshot['result']), ...snapshot` 解构后,`result['artifacts']` 与 `_castMap(snapshot['artifacts'])` 不可能是同一引用 → 条件永假 → 产物丢弃。 + +**修复**(用 Edit 直接改): +```dart +final artifactRecord = _castMap(snapshot['artifacts']); +final artifactItems = artifactRecord['items']; +if (artifactItems is List && artifactItems.isNotEmpty) { + result['artifacts'] = List>.from(artifactItems); +} else if (result['artifacts'] is Map) { + final nestedArtifacts = _castMap(result['artifacts']); + final nestedItems = nestedArtifacts['items']; + if (nestedItems is List && nestedItems.isNotEmpty) { + result['artifacts'] = List>.from(nestedItems); + } +} +``` + +--- + +## Fix 2 [P1 — App 层] `_recoverTaskResultAfterStreamClosure` completed 状态竞态 + +**文件**: `lib/runtime/external_code_agent_acp_desktop_transport.dart` +**函数**: `_recoverTaskResultAfterStreamClosure()` +**行号**: 252-279 + +**问题**: `status: 'completed'` 不等于产物已写入注册表。OpenClaw Gateway 先标记 status→completed,后异步写入 artifact registry。轮询在此刻拿到空产物快照。 + +**修复**: 在行 267 `final result = _recoveredResultFromTaskSnapshot(snapshot)` 之后插入产物非空检查: + +```dart +final result = _recoveredResultFromTaskSnapshot(snapshot); +// 新增:completed 状态下产物为空时延后重试 +final resultArtifacts = _castMap(result['artifacts']); +final artifactItems = resultArtifacts['items'] ?? resultArtifacts; +final hasArtifacts = result.isNotEmpty && + (artifactItems is List && artifactItems.isNotEmpty || + result['artifacts'] is List && (result['artifacts'] as List).isNotEmpty); +if (!hasArtifacts && status == 'completed' && attempt < attempts - 1) { + continue; +} +// 新增结束 +if (result.isNotEmpty) { + return goTaskServiceResultFromAcpResponse(...); +} +``` + +--- + +## Fix 3 [P1 — App 层] `persistGoTaskArtifactsForSessionInternal` 产物完整性从不验证 + +**文件**: `lib/app/app_controller_desktop_runtime_helpers.dart` +**函数**: `persistGoTaskArtifactsForSessionInternal()` +**行号**: 876-882 + +**问题**: syncStatus 判断 `wroteArtifact ? (failedArtifact || skippedArtifact ? 'partial' : 'synced')` 不验证该有的文件齐不齐。`requiredArtifactExtensions` 字段从 Bridge 传入但从不使用。 + +**修复** — 在 syncStatus 赋值处(行 876)替换: + +```dart +final thread = taskThreadForSessionInternal(normalizedSessionKey); +final requiredExts = thread?.lifecycleState.openClawTaskAssociation + ?.requiredArtifactExtensions ?? const []; +final missingRequired = requiredExts.where((ext) { + return !currentTaskArtifactPaths.any( + (p) => p.toLowerCase().endsWith(ext.toLowerCase()), + ); +}).toList(growable: false); + +final syncStatus = wroteArtifact + ? (failedArtifact || skippedArtifact || missingRequired.isNotEmpty + ? 'partial' + : 'synced') + : failedArtifact + ? 'download-failed' + : rejectedArtifact + ? 'no-exported-artifacts' + : 'no-artifacts'; +``` + +--- + +## Fix 4 [P2 — App 层] `_artifactBytesResultInternal` localhost 产物下载被阻止 + +**文件**: `lib/app/app_controller_desktop_runtime_helpers.dart` +**函数**: `_artifactBytesResultInternal()` +**行号**: 917-924 + +**修复**: +```dart +final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; +final downloadHost = uri.host.trim().toLowerCase(); +final isLoopback = downloadHost == '127.0.0.1' || + downloadHost == 'localhost' || + downloadHost == '::1'; +final sameBridgeHost = bridgeEndpoint != null && + (downloadHost == bridgeHost || isLoopback); +if (!sameBridgeHost) { + return const _ArtifactBytesResult.skipped(); +} +``` + +--- + +## Fix 5 [P2 — App 层] `pollOpenClawTaskAssociationInternal` 长任务产物完整性检查 + +**文件**: `lib/app/app_controller_desktop_thread_actions.dart` +**函数**: `pollOpenClawTaskAssociationInternal()` +**行号**: 786-791 + +**修复**: +```dart +if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { + final hasRequiredExts = current.requiredArtifactExtensions.isNotEmpty; + final hasEnoughArtifacts = !hasRequiredExts || + current.requiredArtifactExtensions.every((ext) { + return result.artifacts.any( + (a) => a.relativePath.toLowerCase().endsWith(ext.toLowerCase()), + ); + }); + if (!hasEnoughArtifacts && attempt < maxAttempts - 1) { + continue; + } + await applyGatewayChatResultInternal( + sessionKey: sessionKey, target: target, result: result, + ); +} +``` + +--- + +## Fix 6 [P1 — Plugin 层] `exportXWorkmateArtifacts` scope 创建与 agent 文件写入时序 + +**文件**: `src/exportArtifacts.ts` +**函数**: `exportXWorkmateArtifacts()` +**行号**: 242-258 + +**问题**: `prepareXWorkmateArtifacts()` 创建 scope 目录,但 OpenClaw agent 可能在此调用之前就已开始写入文件。当 agent 首次写入到 scope 路径时目录尚不存在 → agent 可能回退写入到 workspace 根 → 文件不在 scope 内。 + +**修复**: 在 Bridge 调用链路中,确保 `prepareXWorkmateArtifacts()` 始终在 agent 执行前被调用。在 `bridgeAgents.ts` 的 `runXWorkmateBridgeAgents()` 中(行 31-35)已调用 prepare,但 `exportArtifacts.ts` 的 `exportXWorkmateArtifacts()` 可能被独立调用(绕过 bridgeAgents)。需要在 Gateway 侧接入点确保 prepare 必先于 agent run。 + +**建议**:在 Gateway 的 session.start handler 中,将 prepare 作为 session 初始化的强制步骤,而非可选步骤。 + +--- + +## Fix 7 [P2 — Plugin 层] `sinceUnixMs` 过滤导致 agent 早期输出被遗漏 + +**文件**: `src/exportArtifacts.ts` +**函数**: `collectCandidates()` +**行号**: 520-523 + +```typescript +const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs); +if (changedAtMs < input.sinceUnixMs) { + continue; +} +``` + +**问题**: 当 `sinceUnixMs` 参数被设置为 session.start 之后的时间戳,agent 在 session.start 之前写入的文件被过滤。在并发场景下,若 artifact scope 在另一个线程中被创建、文件被写入,然后当前 run 的 `sinceUnixMs` 晚于这些文件的时间戳 → 文件被误过滤。 + +**修复**: 对 scope 根目录的 `birthtime` 做校验——如果 scope 目录本身是在当前 run 中创建的,则 `sinceUnixMs` 不应过滤掉 scope 创建后写入的文件: + +```typescript +// 在 collectCandidates 调用前(行 246-255) +let effectiveSince = sinceUnixMs; +if (scopePrepared && sinceUnixMs > 0) { + try { + const scopeStat = await fs.stat(scopeRoot); + effectiveSince = Math.max(sinceUnixMs, scopeStat.birthtimeMs); + } catch {} +} +``` + +--- + +## 修复优先级汇总 + +| Fix | 层 | 优先级 | 文件 | 函数 | 行号 | +|-----|-----|--------|------|------|------| +| 0 | Plugin | P0 | `src/exportArtifacts.ts` | `exportXWorkmateArtifacts()` | 238-259 | +| 1 | App | P0 | `external_code_agent_acp_desktop_transport.dart` | `_recoveredResultFromTaskSnapshot()` | 403-407 | +| 6 | Plugin | P1 | `src/exportArtifacts.ts` | `exportXWorkmateArtifacts()` | 242-258 | +| 2 | App | P1 | `external_code_agent_acp_desktop_transport.dart` | `_recoverTaskResultAfterStreamClosure()` | 252-279 | +| 3 | App | P1 | `app_controller_desktop_runtime_helpers.dart` | `persistGoTaskArtifactsForSessionInternal()` | 876-882 | +| 5 | App | P2 | `app_controller_desktop_thread_actions.dart` | `pollOpenClawTaskAssociationInternal()` | 786-791 | +| 4 | App | P2 | `app_controller_desktop_runtime_helpers.dart` | `_artifactBytesResultInternal()` | 917-924 | +| 7 | Plugin | P2 | `src/exportArtifacts.ts` | `collectCandidates()` | 520-523 | + +## 验证状态 + +| 验证项 | 状态 | +|--------|------| +| OpenClaw Gateway Web (`openclaw.svc.plus`) | ⚠️ 未执行 — Chrome 未连接 + egress 未放行 | +| Bridge SSH (`xworkmate-bridge.svc.plus`) | ⚠️ 未执行 — DNS 无法解析 | +| Flutter 单元测试 | ⚠️ 未执行 — Flutter SDK 不在 VM | +| Plugin 单元测试 (`pnpm test`) | ⚠️ 未执行 — pnpm 不在 VM | +| 四层源码静态分析 | ✅ 完成 — 8 个修复点,跨 4 个文件 | diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index da476f72..381ad308 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -873,8 +873,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController { wroteArtifact = true; } + final thread = taskThreadForSessionInternal(normalizedSessionKey); + final requiredExts = thread?.openClawTaskAssociation + ?.requiredArtifactExtensions ?? const []; + final missingRequired = requiredExts.where((ext) { + return !currentTaskArtifactPaths.any( + (p) => p.toLowerCase().endsWith(ext.toLowerCase()), + ); + }).toList(growable: false); + final syncStatus = wroteArtifact - ? (failedArtifact || skippedArtifact ? 'partial' : 'synced') + ? (failedArtifact || skippedArtifact || missingRequired.isNotEmpty + ? 'partial' + : 'synced') : failedArtifact ? 'download-failed' : rejectedArtifact @@ -915,10 +926,13 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return const _ArtifactBytesResult.skipped(); } final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); - final sameBridgeHost = - bridgeEndpoint != null && - uri.host.trim().toLowerCase() == - bridgeEndpoint.host.trim().toLowerCase(); + final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; + final downloadHost = uri.host.trim().toLowerCase(); + final isLoopback = downloadHost == '127.0.0.1' || + downloadHost == 'localhost' || + downloadHost == '::1'; + final sameBridgeHost = bridgeEndpoint != null && + (downloadHost == bridgeHost || isLoopback); if (!sameBridgeHost) { return const _ArtifactBytesResult.skipped(); } diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index e2ce3421..57a34024 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -784,6 +784,16 @@ extension AppControllerDesktopThreadActions on AppController { continue; } if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { + final hasRequiredExts = current.requiredArtifactExtensions.isNotEmpty; + final hasEnoughArtifacts = !hasRequiredExts || + current.requiredArtifactExtensions.every((ext) { + return result.artifacts.any( + (a) => a.relativePath.toLowerCase().endsWith(ext.toLowerCase()), + ); + }); + if (!hasEnoughArtifacts && attempt < maxAttempts - 1) { + continue; + } await applyGatewayChatResultInternal( sessionKey: sessionKey, target: target, diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 1a4d8ec5..b5e45e88 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -38,12 +38,8 @@ Future desktopRemoteVideoStreamForTrack( if (event.track.kind != 'video') { return null; } - if (event.streams.isNotEmpty) { - return event.streams.first; - } - final stream = await createFallbackStream('xworkmate-remote-desktop'); - await stream.addTrack(event.track, addToNative: false); + await stream.addTrack(event.track); return stream; } @@ -142,10 +138,7 @@ class DesktopClient { ); // Create SDP Offer - final offer = await _peerConnection!.createOffer({ - 'offerToReceiveAudio': 1, - 'offerToReceiveVideo': 1, - }); + final offer = await _peerConnection!.createOffer({}); await _peerConnection!.setLocalDescription(offer); // Send SDP Offer to Bridge diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index 9976ed5e..bcdc0ba4 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -265,6 +265,14 @@ class ExternalCodeAgentAcpDesktopTransport ); } final result = _recoveredResultFromTaskSnapshot(snapshot); + final resultArtifacts = _castMap(result['artifacts']); + final artifactItems = resultArtifacts['items'] ?? resultArtifacts; + final hasArtifacts = result.isNotEmpty && + (artifactItems is List && artifactItems.isNotEmpty || + result['artifacts'] is List && (result['artifacts'] as List).isNotEmpty); + if (!hasArtifacts && status == 'completed' && attempt < attempts - 1) { + continue; + } if (result.isNotEmpty) { return goTaskServiceResultFromAcpResponse( { @@ -402,8 +410,14 @@ class ExternalCodeAgentAcpDesktopTransport }; final artifactRecord = _castMap(snapshot['artifacts']); final artifactItems = artifactRecord['items']; - if (artifactItems is List && result['artifacts'] == artifactRecord) { - result['artifacts'] = artifactItems; + if (artifactItems is List && artifactItems.isNotEmpty) { + result['artifacts'] = List>.from(artifactItems); + } else if (result['artifacts'] is Map) { + final nestedArtifacts = _castMap(result['artifacts']); + final nestedItems = nestedArtifacts['items']; + if (nestedItems is List && nestedItems.isNotEmpty) { + result['artifacts'] = List>.from(nestedItems); + } } for (final entry in { 'remoteWorkingDirectory': 'remoteWorkingDirectory',