Refactor OpenClaw task integration as thin adapter

This commit is contained in:
Haitao Pan 2026-06-06 07:56:32 +08:00
parent ea781b5206
commit c16704b052
11 changed files with 143 additions and 79 deletions

View File

@ -97,7 +97,8 @@ lib/runtime/external_code_agent_acp_desktop_transport.dart
lib/app/app_controller_openclaw_task_queue.dart
OpenClawTaskQueue
→ 本地队列管理 (max 5 active, 20 queued)
→ 持久化 & 恢复 (pollOpenClawTaskAssociationInternal)
→ 持久化 typed association
→ 恢复查询只发送 appThreadKey/openclawSessionKey/runId 到 native task-registry
```
Protocol boundary:
@ -124,7 +125,9 @@ Protocol boundary:
### 断点风险:
- 网关 WebSocket 断连 → 任务丢失
- 分布式转发 hop=3 限制 → 深层拓扑不可达
- 任务轮询恢复依赖 `xworkmate.tasks.get` → 非实时
- 任务轮询恢复依赖 `xworkmate.tasks.get` → Bridge 只转发 typed lookup
terminal state 以 OpenClaw native task-registry 为准;缺少 native task
record 时返回 `no_native_task_record`,不从 artifactScope/runId 重建任务。
---

View File

@ -198,26 +198,32 @@ Fragile:
- signing secret rotation invalidates all existing refs
```
## State 5: Snapshot (Bridge)
## State 5: Native Task Lookup And Artifact Snapshot
```
Caller: completeOpenClawTask() in openclaw_async_tasks.go
triggered by probeOpenClawTask() detecting completion
Caller: App polling/recovery via xworkmate.tasks.get
Bridge forwards typed appThreadKey/openclawSessionKey/runId to Plugin
Process:
1. Call gateway.request('xworkmate.artifacts.collect-and-snapshot')
1. Plugin resolves task state from OpenClaw native task-registry
→ api.runtime.tasks.runs.bindSession({sessionKey: openclawSessionKey})
→ resolve(runId) or findLatest()
2. Call gateway.request('xworkmate.artifacts.collect-and-snapshot')
→ Copy OpenClaw media/tmp outputs into the task scope
2. Call gateway.request('xworkmate.artifacts.export')
3. Call gateway.request('xworkmate.artifacts.export')
→ Get manifest from plugin
3. openClawArtifactExport()
4. openClawArtifactExport()
→ Transform manifest files into stable result shape
→ decorateOpenClawArtifactDownloadURLs()
→ Replace each file.ref with signed download URL:
/artifacts/openclaw/download?ref=<signed>&t=<expiry>
4. Build terminal snapshot:
5. Return task-registry-backed snapshot:
→ Terminal success/failure is not decided by Bridge state
→ Missing native task record returns no_native_task_record
{
success: true,
status: "completed",
@ -232,10 +238,16 @@ Process:
}
}
5. Store snapshot for xworkmate.tasks.get queries
5. Do not store terminal task truth for xworkmate.tasks.get queries.
The query path forwards to OpenClaw native task-registry through the plugin.
6. Send SSE session.update to app
Removed compatibility paths:
- Bridge no longer falls back when xworkmate.session.prepare is unsupported.
- Bridge no longer reassociates OpenClaw tasks from artifactScope/runId.
- Bridge no longer treats artifact export as terminal task-state evidence.
Fragile:
- If export returns empty manifest, snapshot has no artifacts
- Artifact download URLs expire after 24h
@ -339,7 +351,7 @@ stateDiagram-v2
| Plugin workspace | resolveWorkspaceDir() | `~/.openclaw/workspace` |
| Plugin scope | tasks/<session>/<run>/ | `<workspace>/tasks/<s>/<r>/` |
| Plugin export | exportXWorkmateArtifacts() | Scans only scope dir |
| Bridge snapshot | completeOpenClawTask() | In-memory, 24h signed URLs |
| Bridge snapshot | xworkmate.tasks.get proxy | Native task-registry + plugin artifact manifest |
| Bridge download | /artifacts/openclaw/download | Proxied from plugin read |
| App sync | syncArtifactsFromBridge() | `~/.xworkmate/threads/<s>/` |
| OpenClaw media | saveMediaBuffer(subdir) | `~/.openclaw/media/<subdir>/` |

View File

@ -165,9 +165,9 @@ No persistence:
| Method | Params | Returns |
|--------|--------|---------|
| `xworkmate.tasks.get` | sessionId, threadId, turnId (optional) | Terminal snapshot or running status |
| `xworkmate.tasks.cancel` | sessionId, threadId, turnId | Cancel confirmation |
| `reassociateOpenClawTask` | taskHandle (from stored params) | Reconnected session |
| `xworkmate.tasks.get` | appThreadKey, openclawSessionKey, runId/taskId | Native task-registry snapshot or structured lookup error |
| `xworkmate.tasks.cancel` | appThreadKey, openclawSessionKey, runId/taskId | Cancel confirmation |
| Removed: Bridge task reassociation | artifactScope/runId-derived taskHandle | No longer supported; route through native task registry |
## App Recovery Flow (Detailed)

View File

@ -70,7 +70,7 @@ xworkmate-bridge
│ ├─ queueTimeout: 10 min
│ └─ Returns: admission slot or OPENCLAW_GATEWAY_BUSY
└─ startOpenClawGatewayTask()
└─ startOpenClawGatewayTask()
├─ ensureProductionGatewayConnected()
├─ openClawArtifactPrepare()
│ └─ gateway.request('xworkmate.session.prepare')
@ -85,20 +85,18 @@ xworkmate-bridge
│ sessionKey is the OpenClaw native field and equals openclawSessionKey
│ (no expectedArtifactDirs root field)
├─ Create OpenClawTaskRecord
├─ Keep OpenClawTaskRecord only as live transport context
│ ├─ SessionID, ThreadID, TurnID, RunID
│ ├─ SessionKey (from gateway response)
│ ├─ TaskLoadClass (short_task/long_task/complex_chain_task)
│ ├─ RuntimeBudgetMinutes (10/30/60)
│ └─ PreparedArtifact scope ref
└─ startOpenClawTaskMonitor()
└─ Every 1s: probeOpenClawTask()
└─ gateway.request('agent.wait', timeout=1s)
├─ completed → completeOpenClawTask()
├─ failed → failOpenClawTask()
├─ SLA expired → TASK_SLA_EXPIRED
└─ silent failure >10min → cleanup
└─ Native task status lookup
└─ xworkmate.tasks.get forwards typed
{appThreadKey, openclawSessionKey, runId/taskId}
to OpenClaw native task-registry via the plugin.
Bridge no longer rebuilds task state from artifactScope/runId.
───────────────────────────────────────────────────────────
Protocol: Custom JSON-RPC v4 over WebSocket
@ -177,11 +175,11 @@ now copied into tasks/<session>/<run>/artifacts/ before export.
───────────────────────────────────────────────────────────
Back to xworkmate-bridge:
completeOpenClawTask()
terminal transport handling
├─ Call xworkmate.artifacts.collect-and-snapshot via gateway
├─ Call xworkmate.artifacts.export via gateway
├─ Collect artifact manifest
├─ Build terminal snapshot with:
├─ Build App transport payload with:
│ ├─ status: completed/failed/cancelled
│ ├─ artifacts: { items: [...], scope: "..." }
│ └─ text: final output
@ -192,6 +190,12 @@ now copied into tasks/<session>/<run>/artifacts/ before export.
└─ Send SSE session.update to app
xworkmate.tasks.get:
Bridge forwards typed lookup to the plugin/native task-registry.
Terminal state comes from native task records only. Missing native records
return structured errors such as no_native_task_record instead of inferring
success from artifacts or reconstructing a Bridge task dictionary.
Back to xworkmate-app:
ExternalCodeAgentAcpDesktopTransport
├─ Receive terminal snapshot via SSE or xworkmate.tasks.get

View File

@ -61,7 +61,7 @@ Scope: xworkmate-app, xworkmate-bridge, openclaw-multi-session-plugins, openclaw
→ startOpenClawGatewayTask()
→ openClawArtifactPrepare() // calls xworkmate.artifacts.prepare
→ gatewayruntime.send('chat.send')
startOpenClawTaskMonitor() // background probe every 1s
returns running handle; Bridge does not own terminal state
3. openclaw.svc.plus
Gateway receives 'chat.send'
@ -75,9 +75,9 @@ Scope: xworkmate-app, xworkmate-bridge, openclaw-multi-session-plugins, openclaw
→ returns manifest + base64 files to bridge
5. xworkmate-bridge
openClawArtifactExport() → collects artifacts from plugin
completeOpenClawTask() → builds terminal snapshot
sends session.update (SSE) to app
forwards native transcript/task events to app
xworkmate.tasks.get is forwarded to Plugin/OpenClaw native task-registry
artifact collect/export remains an explicit proxy operation
6. xworkmate-app
ExternalCodeAgentAcpDesktopTransport receives SSE
@ -171,15 +171,10 @@ type OpenClawTaskRecord struct {
SessionKey string // 传递给 plugin 做 scope
GatewayProviderID string // "openclaw"
TaskLoadClass string // short_task/long_task/complex_chain_task
ArtifactSinceUnixMs int64 // artifact 时间窗口起始
RuntimeBudgetMinutes int // 10/30/60
StartedAt, DeadlineAt, LastProbeAt time.Time
StartedAt, DeadlineAt time.Time
ProgressStage, ProgressMessage string
ProgressTerminal bool
FirstSilentFailureAt time.Time // 静默失败计时
PreparedArtifact *openClawPreparedArtifactScope
ArtifactContract openClawArtifactContract
AdmissionRelease func() // 释放并发槽位
}
```
@ -307,7 +302,7 @@ xworkmate-bridge 的 session 存储在 `Server.sessions map[string]*session`
**6.7 TaskSnapshot 字段不完整**
Bridge 返回的 terminal snapshot 依赖 `completeOpenClawTask()` 正确组装 artifact 列表。如果 plugin 返回的 artifact manifest 中有文件但内容为空、或相对路径超出 scopebridge 的 snapshot 会缺少 artifact 条目
Bridge 不再组装 terminal snapshot。Terminal success/failure 必须来自 OpenClaw native task-registryartifact manifest 只由 Plugin 的 collect/export/read 路径提供。如果 plugin 返回的 artifact manifest 中有文件但内容为空、或相对路径超出 scopeApp 侧 artifact 栏可能缺少条目,但任务终态不应因此由 Bridge 推断
**6.8 SSE 流中断后的轮询策略**

View File

@ -14,8 +14,8 @@
- Do not modify `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw.svc.plus`.
- Do not rely on prompt text parsing for metadata.
- Do not use `agent:main:${appThreadKey}` or `replace("agent:main:", "")` as the new-path session mapping.
- Legacy string derivation is allowed only as one-time migration that writes a durable mapping.
- Do not reverse-map session keys from string conventions. The deterministic `agent:main:<appThreadKey>` form is allowed only for initial native session creation before prepare persists the mapping.
- Legacy string derivation and migration-only mapping fields are removed from the current flow.
- Do not restore plugin-owned task DB, session DB, or event bus.
- Validation must reference `docs/cases/`, especially `docs/cases/openclaw-gateway-e2e-regression/README.md`.
@ -48,8 +48,7 @@ type XWorkmateSessionMappingV1 = {
expectedArtifactDirs: string[];
createdAt: string;
updatedAt: string;
source: "session_start" | "bridge_prepare" | "legacy_migration";
legacyDerived?: boolean;
source: "session_start" | "bridge_prepare";
};
```
@ -69,7 +68,7 @@ Add tests for:
- Mapping is written to `SessionEntry.pluginExtensions`.
- Idempotent same mapping updates `updatedAt`.
- Conflicting existing mapping fails closed.
- Legacy derivation, if needed, writes `legacyDerived: true` and persists the mapping.
- Missing typed mapping returns `mapping_not_found` / `invalid_lookup`; current flow does not derive replacement keys.
**Step 2: Run failing test**
@ -133,7 +132,7 @@ In `index.ts`, register `xworkmate.session.prepare`. It should:
- call `prepareXWorkmateArtifacts`;
- return `{ ok: true, mapping, artifactScope, artifactDirectory, expectedArtifactDirs }`.
Keep `xworkmate.artifacts.prepare/export/collect-and-snapshot` as thin artifact operations. Remove in-memory `createXWorkmateTaskStore()` as a required source of truth.
Keep `xworkmate.artifacts.prepare/export/collect-and-snapshot` as thin artifact operations. The in-memory plugin task store is removed and must not be reintroduced as a task source of truth.
**Step 4: Run plugin suite**
@ -322,7 +321,7 @@ Retain only transport/reconciliation caches that have clear owner, scope, and in
Run:
```bash
rg -n "agent:main:\\$|replace\\(|ThreadSessionMapper|createXWorkmateTaskStore|sessionMappingsBy|records = new Map|bridgeAgents" \
rg -n "ThreadSessionMapper|sessionMappingsBy|records = new Map|bridgeAgents" \
/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-multi-session-plugins \
/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-bridge \
/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate-app

View File

@ -62,9 +62,7 @@ class ExternalCodeAgentAcpDesktopTransport implements GoTaskServiceClient {
if (update.isDone && update.message.trim().isNotEmpty) {
completedMessage = update.message.trim();
}
if (update.payload['status']?.toString().trim().toLowerCase() ==
'running' &&
(update.payload['runId']?.toString().trim().isNotEmpty == true)) {
if (OpenClawTaskAssociation.fromJsonOrNull(update.payload) != null) {
runningTaskSnapshot = <String, dynamic>{...update.payload};
}
onUpdate(update);
@ -130,6 +128,9 @@ class ExternalCodeAgentAcpDesktopTransport implements GoTaskServiceClient {
final association = OpenClawTaskAssociation.fromJsonOrNull(
runningTaskSnapshot,
);
if (association == null) {
return null;
}
final attempts = _recoveryAttemptsForRequest(request);
for (var attempt = 0; attempt < attempts; attempt += 1) {
if (attempt > 0) {
@ -139,12 +140,7 @@ class ExternalCodeAgentAcpDesktopTransport implements GoTaskServiceClient {
try {
response = await _client.request(
method: 'xworkmate.tasks.get',
params:
association?.toTaskGetParams() ??
<String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,
},
params: association.toTaskGetParams(),
endpointOverride: endpoint,
);
} on GatewayAcpException {
@ -177,9 +173,11 @@ class ExternalCodeAgentAcpDesktopTransport implements GoTaskServiceClient {
final result = _recoveredResultFromTaskSnapshot(snapshot);
final resultArtifacts = _castMap(result['artifacts']);
final artifactItems = resultArtifacts['items'] ?? resultArtifacts;
final hasArtifacts = result.isNotEmpty &&
final hasArtifacts =
result.isNotEmpty &&
(artifactItems is List && artifactItems.isNotEmpty ||
result['artifacts'] is List && (result['artifacts'] as List).isNotEmpty);
result['artifacts'] is List &&
(result['artifacts'] as List).isNotEmpty);
if (!hasArtifacts && status == 'completed' && attempt < attempts - 1) {
continue;
}

View File

@ -993,19 +993,10 @@ class OpenClawTaskAssociation {
Map<String, dynamic> toTaskGetParams() {
return <String, dynamic>{
'sessionId': sessionId,
'threadId': threadId,
'turnId': turnId,
'runId': runId,
'artifactScope': artifactScope,
'artifactDirectory': artifactDirectory,
'gatewayProviderId': gatewayProviderId,
'taskLoadClass': taskLoadClass,
'appThreadKey': appThreadKey,
'openclawSessionKey': openclawSessionKey,
'includeArtifacts': true,
'requiredArtifactExtensions': requiredArtifactExtensions,
'expectedArtifactExtensions': expectedArtifactExtensions,
};
}
@ -1015,14 +1006,10 @@ class OpenClawTaskAssociation {
}
final json = value.cast<String, dynamic>();
final runId = json['runId']?.toString().trim() ?? '';
final artifactScope = json['artifactScope']?.toString().trim() ?? '';
final appThreadKey = json['appThreadKey']?.toString().trim() ?? '';
final openclawSessionKey =
json['openclawSessionKey']?.toString().trim() ?? '';
if (runId.isEmpty ||
artifactScope.isEmpty ||
appThreadKey.isEmpty ||
openclawSessionKey.isEmpty) {
if (runId.isEmpty || appThreadKey.isEmpty || openclawSessionKey.isEmpty) {
return null;
}
double asDouble(Object? raw) {
@ -1037,7 +1024,7 @@ class OpenClawTaskAssociation {
threadId: json['threadId']?.toString().trim() ?? '',
turnId: json['turnId']?.toString().trim() ?? '',
runId: runId,
artifactScope: artifactScope,
artifactScope: json['artifactScope']?.toString().trim() ?? '',
artifactDirectory: json['artifactDirectory']?.toString().trim() ?? '',
gatewayProviderId:
json['gatewayProviderId']?.toString().trim().isNotEmpty == true

View File

@ -399,6 +399,12 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
}
String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) {
if (widget.artifactSyncStatus.trim().toLowerCase() == 'failed') {
return appText(
'本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。',
'No files were generated for this run. Try again and ask OpenClaw to create files in the current workspace.',
);
}
final filesMessage = snapshot.filesMessage.trim();
if (filesMessage.isNotEmpty) {
return filesMessage;

View File

@ -87,7 +87,11 @@ void main() {
params['openclawSessionKey'],
'agent:main:draft:1780658097668838-1',
);
expect(params['runId'], 'run-1');
expect(params, isNot(contains('sessionKey')));
expect(params, isNot(contains('sessionId')));
expect(params, isNot(contains('threadId')));
expect(params, isNot(contains('artifactScope')));
});
test('recognizes openclaw as the canonical gateway provider', () {

View File

@ -658,8 +658,19 @@ void main() {
if (method == 'session.start') {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-a'},
'method': 'session.update',
'params': <String, dynamic>{
'sessionId': 'unit-fixture-task-a',
'threadId': 'unit-fixture-task-a',
'turnId': 'turn-recovered',
'type': 'status',
'event': 'running',
'status': 'running',
'runId': 'turn-recovered',
'appThreadKey': 'unit-fixture-task-a',
'openclawSessionKey': 'agent:main:unit-fixture-task-a',
'gatewayProviderId': 'openclaw',
},
});
final eventBytes = utf8.encode('data: $event\n\n');
request.response.headers.set(
@ -774,6 +785,10 @@ void main() {
'turnId': 'turn-final',
'type': 'status',
'event': 'completed',
'status': 'completed',
'runId': 'turn-final',
'appThreadKey': 'unit-fixture-task-final',
'openclawSessionKey': 'agent:main:unit-fixture-task-final',
'message': 'early completed output without artifacts',
'success': true,
},
@ -882,8 +897,19 @@ void main() {
if (method == 'session.message') {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-sse'},
'method': 'session.update',
'params': <String, dynamic>{
'sessionId': 'unit-fixture-task-sse',
'threadId': 'unit-fixture-task-sse',
'turnId': 'turn-recovered-sse',
'type': 'status',
'event': 'running',
'status': 'running',
'runId': 'turn-recovered-sse',
'appThreadKey': 'unit-fixture-task-sse',
'openclawSessionKey': 'agent:main:unit-fixture-task-sse',
'gatewayProviderId': 'openclaw',
},
});
request.response.headers.set(
HttpHeaders.contentTypeHeader,
@ -971,8 +997,19 @@ void main() {
if (method == 'session.start') {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-b'},
'method': 'session.update',
'params': <String, dynamic>{
'sessionId': 'unit-fixture-task-b',
'threadId': 'unit-fixture-task-b',
'turnId': 'turn-recovered-running',
'type': 'status',
'event': 'running',
'status': 'running',
'runId': 'turn-recovered-running',
'appThreadKey': 'unit-fixture-task-b',
'openclawSessionKey': 'agent:main:unit-fixture-task-b',
'gatewayProviderId': 'openclaw',
},
});
final eventBytes = utf8.encode('data: $event\n\n');
request.response.headers.set(
@ -1092,6 +1129,8 @@ void main() {
'artifactDirectory':
'/home/ubuntu/.openclaw/workspace/tasks/unit-fixture-task-handle/run-running',
'gatewayProviderId': 'openclaw',
'appThreadKey': 'unit-fixture-task-handle',
'openclawSessionKey': 'agent:main:unit-fixture-task-handle',
},
});
final eventBytes = utf8.encode('data: $event\n\n');
@ -1178,9 +1217,15 @@ void main() {
expect(snapshotPolls, 1);
expect(taskGetParams.single['runId'], 'run-running');
expect(
taskGetParams.single['artifactScope'],
'tasks/unit-fixture-task-handle/run-running',
taskGetParams.single['appThreadKey'],
'unit-fixture-task-handle',
);
expect(
taskGetParams.single['openclawSessionKey'],
'agent:main:unit-fixture-task-handle',
);
expect(taskGetParams.single, isNot(contains('sessionKey')));
expect(taskGetParams.single, isNot(contains('artifactScope')));
expect(result.success, isTrue);
expect(result.message, 'completed after task handle');
expect(result.artifacts.single.relativePath, 'reports/final.md');
@ -1200,8 +1245,19 @@ void main() {
if (method == 'session.start') {
final event = jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'xworkmate.bridge.accepted',
'params': <String, dynamic>{'sessionId': 'unit-fixture-task-c'},
'method': 'session.update',
'params': <String, dynamic>{
'sessionId': 'unit-fixture-task-c',
'threadId': 'unit-fixture-task-c',
'turnId': 'turn-failed',
'type': 'status',
'event': 'running',
'status': 'running',
'runId': 'turn-failed',
'appThreadKey': 'unit-fixture-task-c',
'openclawSessionKey': 'agent:main:unit-fixture-task-c',
'gatewayProviderId': 'openclaw',
},
});
final eventBytes = utf8.encode('data: $event\n\n');
request.response.headers.set(