fix(webrtc): resolve remote desktop black screen by properly binding remote video tracks and removing legacy Plan B constraints

This commit is contained in:
Haitao Pan 2026-06-05 12:38:43 +08:00
parent c29b985a18
commit c1919bc074
9 changed files with 544 additions and 32 deletions

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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`(已有隐含覆盖但无显式断言) |

View File

@ -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 个文件 |

View File

@ -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();
}

View File

@ -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,

View File

@ -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

View File

@ -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',