refactor/app-thread-key

This commit is contained in:
Haitao Pan 2026-06-06 06:51:30 +08:00
parent 495af092c5
commit ea781b5206
6 changed files with 145 additions and 60 deletions

View File

@ -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/<session>/<run>/ (multi-session-plugins)
prepare: map session + 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)
@ -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 = <workspace>/tasks/<safeSessionKey>/<safeRunId>/
6. scopeRoot = <workspace>/tasks/<safeSessionKey>/<safeRunId>/
→ fs.mkdir(scopeRoot, { recursive: true })
5. Validate: isWithinRoot(workspaceRoot, scopeRoot)
7. Validate: isWithinRoot(workspaceRoot, scopeRoot)
Output:
artifactScope: "tasks/<safeSessionKey>/<safeRunId>/"
artifactDirectory: "<workspace>/tasks/<safeSessionKey>/<safeRunId>/"
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/<session>/<run>/
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/<session>/<run>/"
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)

View File

@ -23,8 +23,9 @@ xworkmate-app
└─ AppController.sendChatMessage()
├─ Resolve/create TaskThread by sessionKey/threadId
├─ Prepare local workspace: ~/.xworkmate/threads/<session>/
├─ 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/<sessionKey>/<runId>/
│ └─ gateway.request('xworkmate.session.prepare')
│ ├─ schemaVersion: 1
│ ├─ appThreadKey: App TaskThread key
│ ├─ openclawSessionKey: OpenClaw SessionEntry key
│ ├─ expectedArtifactDirs: typed artifact contract
│ └─ scope: tasks/<openclawSessionKey>/<runId>/
├─ 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/<session>/<run>/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 <workspace>/tasks/<safeSessionKey>/<safeRunId>/
@ -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

View File

@ -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 全链路保留。
## 回归命令

View File

@ -955,8 +955,8 @@ extension AppControllerDesktopThreadActions on AppController {
return <String, dynamic>{
...baseMetadata,
'xworkmateTaskArtifactContract': <String, dynamic>{
'version': 1,
'sessionKey': sessionKey,
'schemaVersion': 1,
'appThreadKey': sessionKey,
'scopeKind': 'task',
'finalDeliverableDetection': 'remote-runtime',
'requiresExportBeforeFinalResponse': true,

View File

@ -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 <String>[],
this.expectedArtifactExtensions = const <String>[],
});
@ -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<String> requiredArtifactExtensions;
final List<String> 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<String, dynamic>();
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'],
),

View File

@ -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<String, dynamic>();
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 <String>[
'artifacts/',
'reports/',
'exports/',
'assets/',
'assets/images/',
'dist/',
],
);
expect(artifactContract['expectedArtifactDirs'], const <String>[
'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<String, dynamic>();
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 <String>[
'artifacts/',
'reports/',
'exports/',
'assets/',
'assets/images/',
'dist/',
],
);
expect(artifactContract['expectedArtifactDirs'], const <String>[
'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': <String>['.png'],
@ -4623,7 +4653,7 @@ Future<List<String>> _startOpenClawActiveTasks(
await expectLater(
controller
.sendChatMessage('active task $index')
.timeout(const Duration(seconds: 2)),
.timeout(_openClawE2ESubmitTimeout),
completes,
);
await fakeGoTaskService.waitForRequestCount(index + 1);