From ea781b5206748d3581a2647a1de62ca9dec42194 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 6 Jun 2026 06:51:30 +0800 Subject: [PATCH] refactor/app-thread-key --- .../chain-map-artifact-lifecycle.md | 41 +++++---- docs/architecture/chain-map-task-execution.md | 29 +++++-- .../openclaw-gateway-e2e-regression/README.md | 20 ++++- ...app_controller_desktop_thread_actions.dart | 4 +- .../runtime_models_runtime_payloads.dart | 27 ++++-- .../assistant_execution_target_test.dart | 84 +++++++++++++------ 6 files changed, 145 insertions(+), 60 deletions(-) diff --git a/docs/architecture/chain-map-artifact-lifecycle.md b/docs/architecture/chain-map-artifact-lifecycle.md index 0d036e95..81fb4123 100644 --- a/docs/architecture/chain-map-artifact-lifecycle.md +++ b/docs/architecture/chain-map-artifact-lifecycle.md @@ -7,7 +7,7 @@ Repo chain: openclaw-multi-session-plugins ↔ xworkmate-bridge ↔ xworkmate-ap ``` [prepare] → [execute] → [collect-and-snapshot] → [export] → [snapshot] → [download] → [sync] - prepare: mkdir tasks/// (multi-session-plugins) + prepare: map session + 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) @@ -25,32 +25,42 @@ App terminal rule: ## State 1: Prepare ``` -Caller: xworkmate-bridge → gateway.request('xworkmate.artifacts.prepare') -Handler: openclaw-multi-session-plugins → prepareXWorkmateArtifacts() +Caller: xworkmate-bridge → gateway.request('xworkmate.session.prepare') +Handler: openclaw-multi-session-plugins → recordXWorkmateSessionMapping() + prepareXWorkmateArtifacts() Inputs: - sessionKey: string // "agent:default:abc123" - runId: string // "20260605-001" - workspaceDir?: string // optional explicit path + schemaVersion: 1 + appThreadKey: string // "draft:1780658097668838-1" + openclawSessionKey: string // "agent:main:draft:1780658097668838-1" + runId: string // "20260605-001" + expectedArtifactDirs?: string[] + workspaceDir?: string Process: - 1. resolveWorkspaceDir({ sessionKey, params, pluginConfig, config }) + 1. Validate typed mapping metadata. + + 2. Patch SessionEntry.pluginExtensions: + ["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"] + → schemaVersion, appThreadKey, openclawSessionKey, expectedArtifactDirs + + 3. resolveWorkspaceDir({ openclawSessionKey, params, pluginConfig, config }) → Falls back through: explicit → pluginConfig → agent config → profile env → ~/.openclaw/workspace - 2. safeScopeSegment(sessionKey) + 4. safeScopeSegment(openclawSessionKey) → replace [/\\:*?"<>|] with "-", truncate to 96 chars - 3. safeScopeSegment(runId) → same rules + 5. safeScopeSegment(runId) → same rules - 4. scopeRoot = /tasks/// + 6. scopeRoot = /tasks/// → fs.mkdir(scopeRoot, { recursive: true }) - 5. Validate: isWithinRoot(workspaceRoot, scopeRoot) + 7. Validate: isWithinRoot(workspaceRoot, scopeRoot) Output: artifactScope: "tasks///" artifactDirectory: "/tasks///" + mapping: { appThreadKey, openclawSessionKey, expectedArtifactDirs } Fragile: - workspace resolution chain has 5 ordered sources @@ -99,13 +109,13 @@ Caller: xworkmate-bridge → gateway.request('xworkmate.artifacts.collect-and- Handler: openclaw-multi-session-plugins → collectAndSnapshotXWorkmateArtifacts() Inputs: - sessionKey: mapped OpenClaw session key + openclawSessionKey: mapped OpenClaw session key runId: OpenClaw run id artifactScope: tasks/// sinceUnixMs: task start timestamp Process: - 1. Validate artifactScope matches sessionKey/runId. + 1. Validate artifactScope matches openclawSessionKey/runId. 2. Scan fixed OpenClaw output roots: - ~/.openclaw/media/ - /tmp/openclaw/ @@ -127,11 +137,12 @@ Handler: openclaw-multi-session-plugins → exportXWorkmateArtifacts() Inputs: artifactScope: "tasks///" + openclawSessionKey?: string workspaceDir?: string artifactRef?: string // alternative: read single artifact maxFiles?: number // default: 200 maxInlineBytes?: number // default: 512KB, files larger are omitted - expectedArtifactDirs?: string[] // from session.start metadata.xworkmateTaskArtifactContract only + expectedArtifactDirs?: string[] // from typed mapping/session.prepare only Process: 1. resolveScopeRoot(workspaceRoot, artifactScope) @@ -153,7 +164,7 @@ Process: Protocol boundary: `expectedArtifactDirs` is bridge artifact-contract data, not agent execution data. Bridge must not put it in `chat.send` params. Bridge must not probe old - root-level or metadata-root compatibility keys. + root-level, metadata-root, prompt-text, or `sessionKey` compatibility keys. 3. For each file under maxFiles limit: → Read content (up to maxInlineBytes) diff --git a/docs/architecture/chain-map-task-execution.md b/docs/architecture/chain-map-task-execution.md index c399d415..e59c82f3 100644 --- a/docs/architecture/chain-map-task-execution.md +++ b/docs/architecture/chain-map-task-execution.md @@ -23,8 +23,9 @@ xworkmate-app └─ AppController.sendChatMessage() ├─ Resolve/create TaskThread by sessionKey/threadId ├─ Prepare local workspace: ~/.xworkmate/threads// - ├─ Build task context prompt (sessionKey, workspace, contract) + ├─ Build task context prompt (TaskThread.sessionKey, workspace, contract) ├─ Attach metadata.xworkmateTaskArtifactContract + │ └─ schemaVersion, appThreadKey, expectedArtifactDirs └─ Select execution path: ├─ Agent providers (codex/opencode/gemini/hermes) │ └─ DesktopGoTaskService.startSession() @@ -72,11 +73,16 @@ xworkmate-bridge └─ startOpenClawGatewayTask() ├─ ensureProductionGatewayConnected() ├─ openClawArtifactPrepare() - │ └─ gateway.request('xworkmate.artifacts.prepare') - │ └─ scope: tasks/// + │ └─ gateway.request('xworkmate.session.prepare') + │ ├─ schemaVersion: 1 + │ ├─ appThreadKey: App TaskThread key + │ ├─ openclawSessionKey: OpenClaw SessionEntry key + │ ├─ expectedArtifactDirs: typed artifact contract + │ └─ scope: tasks/// │ ├─ gateway.request('chat.send') │ └─ payload: sessionKey, message, attachments, idempotencyKey + │ sessionKey is the OpenClaw native field and equals openclawSessionKey │ (no expectedArtifactDirs root field) │ ├─ Create OpenClawTaskRecord @@ -116,10 +122,17 @@ openclaw.svc.plus └─ tasks///output.md ← MAY be written here openclaw-multi-session-plugins - Receives gateway RPC: xworkmate.artifacts.prepare + Receives gateway RPC: xworkmate.session.prepare + recordXWorkmateSessionMapping() + ├─ Validate schemaVersion=1 typed metadata + ├─ Require appThreadKey and openclawSessionKey + ├─ Write SessionEntry.pluginExtensions + │ └─ ["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"] + └─ Fail closed on appThreadKey/openclawSessionKey conflicts + prepareXWorkmateArtifacts() ├─ resolveWorkspaceDir() → workspace root - ├─ safeScopeSegment(sessionKey) → sanitize + ├─ safeScopeSegment(openclawSessionKey) → sanitize ├─ safeScopeSegment(runId) → sanitize └─ mkdir /tasks/// @@ -139,13 +152,15 @@ openclaw-multi-session-plugins `expectedArtifactDirs` source: session.start.metadata.xworkmateTaskArtifactContract.expectedArtifactDirs → bridge artifact contract + → xworkmate.session.prepare mapping → xworkmate.artifacts.collect-and-snapshot / export only Forbidden compatibility paths: + session.start.metadata.xworkmateTaskArtifactContract.sessionKey session.start.expectedArtifactDirs session.start.metadata.expectedArtifactDirs chat.send.expectedArtifactDirs - xworkmate.tasks.get.expectedArtifactDirs + xworkmate.tasks.get.sessionKey Receives gateway RPC: xworkmate.artifacts.collect-and-snapshot collectAndSnapshotXWorkmateArtifacts() @@ -230,7 +245,7 @@ etc.), not by extending the task execution lifecycle. ## Fragile Points 1. **F1: Tool output path mismatch** — Tools save to media/, plugin exports from tasks/ → gap -2. **F2: Session key mismatch** — Bridge maps App threadId to an explicit OpenClaw sessionKey before prepare/chat/export +2. **F2: Session key mismatch** — Bridge maps App `appThreadKey` to an explicit `openclawSessionKey` before prepare/chat/export and the plugin persists that mapping in SessionEntry.pluginExtensions 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/cases/openclaw-gateway-e2e-regression/README.md b/docs/cases/openclaw-gateway-e2e-regression/README.md index d3fe8550..7984b2f3 100644 --- a/docs/cases/openclaw-gateway-e2e-regression/README.md +++ b/docs/cases/openclaw-gateway-e2e-regression/README.md @@ -2,6 +2,18 @@ 这个 case 固化 5 个真实 OpenClaw Gateway 提示词,用于验证 XWorkmate App -> XWorkmate Bridge -> OpenClaw Gateway 的 5 并发稳定性、任务隔离和 artifact 同步。 +Related key-mapping regression: + +- App thread: `~/.xworkmate/threads/draft-1780658097668838-1` +- `appThreadKey`: `draft:1780658097668838-1` +- `openclawSessionKey`: `agent:main:draft:1780658097668838-1` +- OpenClaw URL: `https://openclaw.svc.plus/chat?session=agent%3Amain%3Adraft%3A1780658097668838-1` + +The durable source of truth is +`SessionEntry.pluginExtensions["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"]`. +Bridge/App must not recover this mapping by replacing `agent:main:` or by using a +legacy `sessionKey` compatibility field. + ## 覆盖目标 - 连续出图:7 张连续风格 PNG。 @@ -15,8 +27,9 @@ | 仓库 | 文件 | 覆盖点 | | --- | --- | --- | | `xworkmate-bridge` | `internal/acp/web_contract_test.go` | `TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases` 通过 HTTP SSE 同时提交 5 个 OpenClaw Gateway 请求,断言不出现 queued、invalid handshake、socket closed、ACP_HTTP_CONNECTION_CLOSED、GATEWAY_CONNECT_FAILED。 | -| `xworkmate-app` | `test/runtime/assistant_execution_target_test.dart` | `OpenClaw gateway admits five representative E2E tasks without queueing` 断言 App 侧 5 个代表任务同时进入 running,复用各自 session/thread,不进入 queued。 | -| `openclaw-multi-session-plugins` | `src/exportArtifacts.test.ts` | 同线程 `assets/images/**/*.png`、manifest、视频/PDF 交付物能被 export 到当前 task artifact scope,不串到旧线程或旧 run。 | +| `xworkmate-app` | `test/runtime/assistant_execution_target_test.dart` | `OpenClaw gateway admits five representative E2E tasks without queueing` 断言 App 侧 5 个代表任务同时进入 running,复用各自 session/thread,不进入 queued,并且 artifact contract 使用 `schemaVersion/appThreadKey/expectedArtifactDirs`,不再写 `sessionKey` 兼容字段。 | +| `openclaw-multi-session-plugins` | `src/taskState.test.ts` | `appThreadKey -> openclawSessionKey` 写入 `pluginExtensions`,`xworkmate.tasks.get` 通过 mapping 查询 OpenClaw native task-registry,查不到时返回 `no_native_task_record`。 | +| `openclaw-multi-session-plugins` | `src/exportArtifacts.test.ts` | 同线程 `assets/images/**/*.png`、manifest、视频/PDF 交付物能被 export 到当前 task artifact scope,不串到旧线程或旧 run,`expectedArtifactDirs` 不存在也保留字段,路径 traversal 被拒绝。 | ## 5 个提示词 @@ -119,6 +132,9 @@ - 不出现 `ACP_HTTP_CONNECTION_CLOSED`。 - 当前任务没有 artifact 时显示明确空态,不显示旧 run 文件。 - 当前任务生成 PNG/PDF/视频文件时,右侧 artifact 自动同步并只显示当前任务本轮文件。 +- `xworkmate.session.prepare` 写入的 mapping 同时包含 `appThreadKey` 和 `openclawSessionKey`。 +- `xworkmate.tasks.get` 使用 `appThreadKey/openclawSessionKey/runId`,不发送旧 `sessionKey` lookup 参数。 +- `expectedArtifactDirs` 从 App metadata 到 Bridge prepare/export/snapshot 到 Plugin artifact resolver 全链路保留。 ## 回归命令 diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 0974e5f1..f55be97f 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -955,8 +955,8 @@ extension AppControllerDesktopThreadActions on AppController { return { ...baseMetadata, 'xworkmateTaskArtifactContract': { - 'version': 1, - 'sessionKey': sessionKey, + 'schemaVersion': 1, + 'appThreadKey': sessionKey, 'scopeKind': 'task', 'finalDeliverableDetection': 'remote-runtime', 'requiresExportBeforeFinalResponse': true, diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 9f2fbf79..f370e295 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -923,8 +923,9 @@ class OpenClawTaskAssociation { required this.gatewayProviderId, required this.startedAtMs, required this.status, + required this.appThreadKey, + required this.openclawSessionKey, this.taskLoadClass = '', - this.sessionKey = '', this.requiredArtifactExtensions = const [], this.expectedArtifactExtensions = const [], }); @@ -938,8 +939,9 @@ class OpenClawTaskAssociation { final String gatewayProviderId; final double startedAtMs; final String status; + final String appThreadKey; + final String openclawSessionKey; final String taskLoadClass; - final String sessionKey; final List requiredArtifactExtensions; final List expectedArtifactExtensions; @@ -962,8 +964,9 @@ class OpenClawTaskAssociation { gatewayProviderId: gatewayProviderId, startedAtMs: startedAtMs, status: status ?? this.status, + appThreadKey: appThreadKey, + openclawSessionKey: openclawSessionKey, taskLoadClass: taskLoadClass, - sessionKey: sessionKey, requiredArtifactExtensions: requiredArtifactExtensions, expectedArtifactExtensions: expectedArtifactExtensions, ); @@ -980,8 +983,9 @@ class OpenClawTaskAssociation { 'gatewayProviderId': gatewayProviderId, 'startedAtMs': startedAtMs, 'status': status, + 'appThreadKey': appThreadKey, + 'openclawSessionKey': openclawSessionKey, 'taskLoadClass': taskLoadClass, - 'sessionKey': sessionKey, 'requiredArtifactExtensions': requiredArtifactExtensions, 'expectedArtifactExtensions': expectedArtifactExtensions, }; @@ -997,7 +1001,9 @@ class OpenClawTaskAssociation { 'artifactDirectory': artifactDirectory, 'gatewayProviderId': gatewayProviderId, 'taskLoadClass': taskLoadClass, - 'sessionKey': sessionKey, + 'appThreadKey': appThreadKey, + 'openclawSessionKey': openclawSessionKey, + 'includeArtifacts': true, 'requiredArtifactExtensions': requiredArtifactExtensions, 'expectedArtifactExtensions': expectedArtifactExtensions, }; @@ -1010,7 +1016,13 @@ class OpenClawTaskAssociation { final json = value.cast(); final runId = json['runId']?.toString().trim() ?? ''; final artifactScope = json['artifactScope']?.toString().trim() ?? ''; - if (runId.isEmpty || artifactScope.isEmpty) { + final appThreadKey = json['appThreadKey']?.toString().trim() ?? ''; + final openclawSessionKey = + json['openclawSessionKey']?.toString().trim() ?? ''; + if (runId.isEmpty || + artifactScope.isEmpty || + appThreadKey.isEmpty || + openclawSessionKey.isEmpty) { return null; } double asDouble(Object? raw) { @@ -1038,8 +1050,9 @@ class OpenClawTaskAssociation { status: json['status']?.toString().trim().isNotEmpty == true ? json['status'].toString().trim() : 'running', + appThreadKey: appThreadKey, + openclawSessionKey: openclawSessionKey, taskLoadClass: json['taskLoadClass']?.toString().trim() ?? '', - sessionKey: json['sessionKey']?.toString().trim() ?? '', requiredArtifactExtensions: _stringListFromJson( json['requiredArtifactExtensions'], ), diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 736c0ea0..a39f87c2 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -64,6 +64,32 @@ void main() { ); }); + test('OpenClaw task lookup params use typed session mapping keys', () { + const association = OpenClawTaskAssociation( + sessionId: 'draft:1780658097668838-1', + threadId: 'draft:1780658097668838-1', + turnId: 'turn-1', + runId: 'run-1', + artifactScope: 'tasks/agent:main:draft:1780658097668838-1/run-1', + artifactDirectory: + '/tmp/tasks/agent:main:draft:1780658097668838-1/run-1', + gatewayProviderId: 'openclaw', + startedAtMs: 0, + status: 'running', + appThreadKey: 'draft:1780658097668838-1', + openclawSessionKey: 'agent:main:draft:1780658097668838-1', + ); + + final params = association.toTaskGetParams(); + + expect(params['appThreadKey'], 'draft:1780658097668838-1'); + expect( + params['openclawSessionKey'], + 'agent:main:draft:1780658097668838-1', + ); + expect(params, isNot(contains('sessionKey'))); + }); + test('recognizes openclaw as the canonical gateway provider', () { final provider = SingleAgentProvider.fromJsonValue('openclaw'); @@ -1310,19 +1336,19 @@ void main() { final artifactContract = (request.metadata['xworkmateTaskArtifactContract'] as Map) .cast(); + expect(artifactContract['schemaVersion'], 1); + expect(artifactContract['appThreadKey'], request.sessionId); + expect(artifactContract, isNot(contains('sessionKey'))); expect(artifactContract['finalDeliverableDetection'], 'remote-runtime'); expect(artifactContract['requiresExportBeforeFinalResponse'], isTrue); - expect( - artifactContract['expectedArtifactDirs'], - const [ - 'artifacts/', - 'reports/', - 'exports/', - 'assets/', - 'assets/images/', - 'dist/', - ], - ); + expect(artifactContract['expectedArtifactDirs'], const [ + 'artifacts/', + 'reports/', + 'exports/', + 'assets/', + 'assets/images/', + 'dist/', + ]); expect(artifactContract, isNot(contains('expectedArtifactExtensions'))); expect(request.prompt, isNot(contains('Task load classification:'))); expect( @@ -1366,19 +1392,19 @@ void main() { final artifactContract = (request.metadata['xworkmateTaskArtifactContract'] as Map) .cast(); + expect(artifactContract['schemaVersion'], 1); + expect(artifactContract['appThreadKey'], request.sessionId); + expect(artifactContract, isNot(contains('sessionKey'))); expect(artifactContract['scopeKind'], 'task'); expect(artifactContract['rejectTextOnlyFileClaims'], isTrue); - expect( - artifactContract['expectedArtifactDirs'], - const [ - 'artifacts/', - 'reports/', - 'exports/', - 'assets/', - 'assets/images/', - 'dist/', - ], - ); + expect(artifactContract['expectedArtifactDirs'], const [ + 'artifacts/', + 'reports/', + 'exports/', + 'assets/', + 'assets/images/', + 'dist/', + ]); expect( artifactContract['currentTaskWorkspace'], request.workingDirectory, @@ -3930,12 +3956,14 @@ void main() { 'status': 'running', 'sessionId': 'openclaw-poll-failed-task', 'threadId': 'openclaw-poll-failed-task', + 'appThreadKey': 'openclaw-poll-failed-task', + 'openclawSessionKey': 'agent:main:openclaw-poll-failed-task', 'turnId': 'turn-openclaw-poll-failed', 'runId': 'run-openclaw-poll-failed', 'artifactScope': - 'tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + 'tasks/agent:main:openclaw-poll-failed-task/run-openclaw-poll-failed', 'artifactDirectory': - '/tmp/tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + '/tmp/tasks/agent:main:openclaw-poll-failed-task/run-openclaw-poll-failed', 'gatewayProviderId': 'openclaw', 'runtimeBudgetMinutes': 1, }, @@ -3998,12 +4026,14 @@ void main() { 'status': 'running', 'sessionId': 'openclaw-missing-screenshot', 'threadId': 'openclaw-missing-screenshot', + 'appThreadKey': 'openclaw-missing-screenshot', + 'openclawSessionKey': 'agent:main:openclaw-missing-screenshot', 'turnId': 'turn-openclaw-missing-screenshot', 'runId': 'run-openclaw-missing-screenshot', 'artifactScope': - 'tasks/openclaw-missing-screenshot/run-openclaw-missing-screenshot', + 'tasks/agent:main:openclaw-missing-screenshot/run-openclaw-missing-screenshot', 'artifactDirectory': - '/tmp/tasks/openclaw-missing-screenshot/run-openclaw-missing-screenshot', + '/tmp/tasks/agent:main:openclaw-missing-screenshot/run-openclaw-missing-screenshot', 'gatewayProviderId': 'openclaw', 'runtimeBudgetMinutes': 1, 'requiredArtifactExtensions': ['.png'], @@ -4623,7 +4653,7 @@ Future> _startOpenClawActiveTasks( await expectLater( controller .sendChatMessage('active task $index') - .timeout(const Duration(seconds: 2)), + .timeout(_openClawE2ESubmitTimeout), completes, ); await fakeGoTaskService.waitForRequestCount(index + 1);