fix(webrtc): resolve remote desktop black screen by properly binding remote video tracks and removing legacy Plan B constraints
This commit is contained in:
parent
3522bb7b99
commit
1878ce1bb2
@ -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/<session>/<run>/ (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/<session>/<run>/
|
||||
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/<session>/<run>/artifacts/media/...
|
||||
- tasks/<session>/<run>/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/<session>/<run>/ — misses media/browser/ etc.
|
||||
- Export only scans tasks/<session>/<run>/; 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=<signed>&t=<expiry>
|
||||
|
||||
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=<signed>&t=<expiry>
|
||||
@ -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
|
||||
|
||||
@ -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/<session>/<run>/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/<session>/<run>/ → 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/<session>/<run>/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
|
||||
|
||||
@ -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/<session>/<run>`.
|
||||
- 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/<session>/<run>"]
|
||||
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"]
|
||||
|
||||
@ -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`(已有隐含覆盖但无显式断言) |
|
||||
@ -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<String, dynamic>` 返回自身引用;对泛型 `Map` 调用 `.cast<String, dynamic>()` 创建新对象。经过 `..._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<Map<String, dynamic>>.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<Map<String, dynamic>>.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 <String>[];
|
||||
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 个文件 |
|
||||
@ -873,8 +873,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
wroteArtifact = true;
|
||||
}
|
||||
|
||||
final thread = taskThreadForSessionInternal(normalizedSessionKey);
|
||||
final requiredExts = thread?.openClawTaskAssociation
|
||||
?.requiredArtifactExtensions ?? const <String>[];
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -38,12 +38,8 @@ Future<MediaStream?> 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
|
||||
|
||||
@ -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(
|
||||
<String, dynamic>{
|
||||
@ -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<Map<String, dynamic>>.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<Map<String, dynamic>>.from(nestedItems);
|
||||
}
|
||||
}
|
||||
for (final entry in <String, String>{
|
||||
'remoteWorkingDirectory': 'remoteWorkingDirectory',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user