refactor: Remove OpenClaw rigid time limits and false positive no-exported-artifacts judgment
This commit is contained in:
parent
7047a4f38b
commit
6d5122682c
164
docs/architecture/multi-session-plugin-review-2026-06-05.md
Normal file
164
docs/architecture/multi-session-plugin-review-2026-06-05.md
Normal file
@ -0,0 +1,164 @@
|
||||
# openclaw-multi-session-plugins 代码审核报告
|
||||
|
||||
> 基于架构优化建议,审核已实施改动的完善程度
|
||||
|
||||
## 审核范围
|
||||
|
||||
| 仓库 | 分支/状态 | 审核提交范围 |
|
||||
|------|----------|-------------|
|
||||
| openclaw-multi-session-plugins | release/v2026.6.1 (uncommitted changes) | `c462ed6..HEAD` + staged diffs |
|
||||
| xworkmate-app | main (ahead 8) | `6219fcd..596704b` |
|
||||
| openclaw.svc.plus | main (uncommitted `src/plugins/session-scope.ts`) | 仅新增文件 |
|
||||
| xworkmate-bridge | 无此仓库 | — |
|
||||
|
||||
---
|
||||
|
||||
## 一、P0 改动审核
|
||||
|
||||
### 1.1 注册 xworkmate.tasks.get ✅ 已实施,有细节问题
|
||||
|
||||
**实施状态**:`index.ts` 新增了 `api.registerGatewayMethod("xworkmate.tasks.get", ...)`,委托给 `taskState.ts` 的 `getXWorkmateTaskSnapshot()`。
|
||||
|
||||
**测试覆盖**:`index.test.ts` 新增了完整集成测试(`registers xworkmate task state against the native session extension and task runtime seams`),验证了 session.start hook → 制品写入 → tasks.get 查询 → 状态/制品同时返回。
|
||||
|
||||
**问题**:
|
||||
|
||||
1. **`getXWorkmateTaskSnapshot` 中的 `api.runtime.tasks.runs.bindSession` 调用路径不确定**。`taskState.ts:229-236` 的 `resolveNativeTask` 走 `api.runtime?.tasks?.runs?.bindSession?.({ sessionKey })` 查询原生 TaskRecord。但 `api.runtime` 在 OpenClaw 2026.6.1 的 Plugin API 契约中的暴露方式需要通过 Gateway Handler 的 context 注入,而非直接从 `api` 对象获取。测试中显式 mock 了这个路径(第 139-153 行),生产路径需要验证。
|
||||
|
||||
2. **`registerXWorkmateDetachedTaskRuntime` 使用了未文档化的 API**。`taskState.ts:64` 调用 `(api as any).registerDetachedTaskRuntime` —— 这个 API 不在 `OpenClawPluginApi` 类型中,也不在 `src/plugin-sdk/index.ts` 的导出里。如果OpenClaw 原生不支持,调用会静默失败(`typeof registerRuntime !== "function"` 的 guard),退回到纯内存 store 模式。这不影响功能但失去了与原生 task-registry 的集成。
|
||||
|
||||
**评级**:基本完善。`registerDetachedTaskRuntime` 的可用性是关键风险点,需要验证 OpenClaw 2026.6.1 是否实际支持。
|
||||
|
||||
### 1.2 session.start hook 中创建 TaskRecord ✅ 已实施
|
||||
|
||||
**实施状态**:`index.ts:99-115` 在 `session.start` hook 中调用 `createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" })`,并行调用 `prepareXWorkmateArtifacts`。
|
||||
|
||||
**问题**:
|
||||
|
||||
1. **TaskStore 是纯内存 Map**。`taskState.ts:33-35` 创建 `{ records: new Map() }` 作为 task store,不支持持久化。插件重启后所有 task 记录丢失。如果 OpenClaw 原生 task-registry 集成生效,这不影响(原生 registry 持久化);如果退回到内存模式,丢失意味着 APP 的 `xworkmate.tasks.get` 查询返回不到历史任务状态。
|
||||
|
||||
2. **没有 task 生命周期终点同步**。Hook 只在 `session.start` 时创建 `status: "running"` 的 record,但没有在任务完成时更新为 `"succeeded"` / `"failed"`。`getXWorkmateTaskSnapshot` 虽然会根据制品数量推断(`taskState.ts:132-136`),但这依赖于每次调用时做推断而非推送。
|
||||
|
||||
**评级**:功能可用,但内存 store + 没有主动完成通知是两个改进点。
|
||||
|
||||
### 1.3 打通 expectedArtifactDirs 全链路 ⚠️ 部分实施
|
||||
|
||||
**实施状态**:
|
||||
|
||||
| 层 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| Plugin - scopedGatewayParams | ✅ 已通 | 使用 `{ ...params, sessionKey, runId, ... }` 模式,额外参数自然透传 |
|
||||
| Plugin - exportXWorkmateArtifacts | ✅ 已实现 | Fix 0 的回退扫描逻辑在 `exportArtifacts.ts:263-283` |
|
||||
| Plugin - 测试覆盖 | ✅ 已验证 | `index.test.ts:104-137` 端到端验证 |
|
||||
| Bridge | ❌ 未实施 | Bridge 仓库不存在于此工作区 |
|
||||
| APP - 发送 expectedArtifactDirs | ❌ 未实施 | `xworkmateTaskArtifactContract` 不包含此字段 |
|
||||
|
||||
**关键缺失**:APP 侧的 `gatewayTaskMetadataWithArtifactContractInternal`(`app_controller_desktop_thread_actions.dart:988-1000`)需要在 metadata 中加入 `expectedArtifactDirs`:
|
||||
|
||||
```dart
|
||||
'xworkmateTaskArtifactContract': <String, dynamic>{
|
||||
'version': 1,
|
||||
'sessionKey': sessionKey,
|
||||
'expectedArtifactDirs': ['assets/images', 'reports', 'video'], // ← 缺失
|
||||
// ...
|
||||
},
|
||||
```
|
||||
|
||||
没有这行,即使 Plugin 侧的回退逻辑已就绪,参数也不会到达。
|
||||
|
||||
---
|
||||
|
||||
## 二、P1 改动审核
|
||||
|
||||
### 2.1 利用 pluginExtensions 做 Key 映射 ✅ 已实施,路径需验证
|
||||
|
||||
**实施状态**:`taskState.ts:37-61` 的 `registerXWorkmateSessionExtension` 调用 `api.session?.state?.registerSessionExtension` 注册 `"xworkmate"` namespace 的 session extension。
|
||||
|
||||
`project` 回调实现了自动映射:
|
||||
- 如果 state 有 `appSessionKey` → 直接用
|
||||
- 否则从 OpenClaw session key 推导(`agent:main:draft:xxx` → `draft:xxx`)
|
||||
|
||||
**风险**:`api.session?.state?.registerSessionExtension` 的类型链依赖 `OpenClawPluginApi` 的类型定义。`taskState.ts:38` 有 `?? (api as any).registerSessionExtension` 后备,但同样,如果 OpenClaw 不支持这个 API,静默失败。
|
||||
|
||||
**评级**:设计方向正确。`api.session.state.registerSessionExtension` 是 OpenClaw 的 `api-facades.ts` 中定义的正式 API(`api.session.state`),应该可用。Key 映射逻辑也正确处理了 `agent:main:draft:1780636411666238-3` → `draft:1780636411666238-3` 的 case。
|
||||
|
||||
### 2.2 Fix 1 (引用相等 Bug) ✅ 已修正
|
||||
|
||||
`external_code_agent_acp_desktop_transport.dart:178-184` 的 `_recoveredResultFromTaskSnapshot` 已经修正:去掉了 `result['artifacts'] == artifactRecord` 的引用相等比较,改为直接判断 `artifactItems is List && artifactItems.isNotEmpty`。
|
||||
|
||||
### 2.3 Fix 2 (completed 状态竞态) ✅ 已修正
|
||||
|
||||
`_recoverTaskResultAfterStreamClosure:186-192` 新增了产物为空时的重试逻辑。
|
||||
|
||||
### 2.4 Fix 3 (产物完整性验证) ✅ 已修正
|
||||
|
||||
`app_controller_desktop_runtime_helpers.dart:873-888` 的 `persistGoTaskArtifactsForSessionInternal` 现在检查 `requiredArtifactExtensions`,缺失时设置 syncStatus 为 `'partial'`。
|
||||
|
||||
---
|
||||
|
||||
## 三、P2 改动审核
|
||||
|
||||
### 3.1 Fix 5 (polling 产物完整性) ✅ 已修正
|
||||
|
||||
`app_controller_desktop_thread_actions.dart:778-785` 的 `pollOpenClawTaskAssociationInternal` 现在在 polling 完成时检查 `requiredArtifactExtensions`,不足时最多重试 3 次。
|
||||
|
||||
### 3.2 bridgeAgents 改用原生 subagent.run ❌ 未实施
|
||||
|
||||
`bridgeAgents.ts` 仍通过 HTTP fetch 调用外部 bridge,没有使用 OpenClaw 的 `api.runtime.subagent.run()` 或 `api.taskFlows`。这个改动属于较大重构,优先级较低,暂未实施可以接受。
|
||||
|
||||
### 3.3 用 Transcript Events 替代 SSE 管理 ❌ 未实施
|
||||
|
||||
无相关改动。这个改动涉及 Bridge 层架构变更,当前未实施。
|
||||
|
||||
---
|
||||
|
||||
## 四、OpenClaw 原生侧待确认项
|
||||
|
||||
### 4.1 session-scope.ts(未提交)
|
||||
|
||||
`openclaw.svc.plus/src/plugins/session-scope.ts` 是一个未提交的新增文件,提供了标准化的 plugin session scope 管理(`createPluginSessionScope`、`normalizePluginScopeSegment`、`buildPluginRelativeTaskDirectory`)。这个文件的意图看起来是为了将 session-scope 管理提升为 OpenClaw 原生能力,但目前只是未提交的草案。
|
||||
|
||||
**建议**:如果 OpenClaw 上游接受了这个模块,`openclaw-multi-session-plugins` 的 `scopedGatewayParams` 和 `safeScopeSegment` 可以迁移使用原生 API。
|
||||
|
||||
### 4.2 registerDetachedTaskRuntime 的可用性
|
||||
|
||||
`taskState.ts:63-114` 通过 `(api as any).registerDetachedTaskRuntime` 注册自定义 detached task runtime。需要在 OpenClaw 2026.6.1 中验证此 API 是否存在。如果不存在,退回到纯内存 store 模式不影响核心功能。
|
||||
|
||||
---
|
||||
|
||||
## 五、整体评估
|
||||
|
||||
### 5.1 实施完成度
|
||||
|
||||
| 类别 | 总项 | 已完成 | 部分完成 | 未开始 |
|
||||
|------|------|--------|---------|--------|
|
||||
| P0 | 3 | 2 | 1 (expectedArtifactDirs APP侧) | 0 |
|
||||
| P1 | 4 | 4 | 0 | 0 |
|
||||
| P2 | 3 | 1 | 0 | 2 |
|
||||
| **合计** | **10** | **7** | **1** | **2** |
|
||||
|
||||
### 5.2 待补充的关键项
|
||||
|
||||
1. **APP 侧传递 expectedArtifactDirs** — `gatewayTaskMetadataWithArtifactContractInternal` 需要在 metadata 中加入 `'expectedArtifactDirs': ['assets/images', 'reports', 'video']`
|
||||
|
||||
2. **Bridge 侧透传 expectedArtifactDirs** — `session.start` 参数需要在 `toExternalAcpParams()` 中包含 `expectedArtifactDirs` 字段
|
||||
|
||||
3. **验证 registerDetachedTaskRuntime API** — 确认 OpenClaw 2026.6.1 是否实际支持此 API;如果不支持,考虑不依赖它,纯用内存 store + export
|
||||
|
||||
### 5.3 架构风险
|
||||
|
||||
| 风险 | 严重度 | 说明 |
|
||||
|------|--------|------|
|
||||
| TaskStore 纯内存 | 中 | 插件重启后 task 记录丢失,但 APP polling 会重新触发查询 |
|
||||
| registerDetachedTaskRuntime 静默失败 | 低 | 有 fallback 路径,功能不中断 |
|
||||
| 未提交代码量大 | 中 | 插件 6 个文件有 staged changes,taskState.ts 是新文件 |
|
||||
| 缺少 task 完成推送 | 中 | session.start hook 只创建 running 状态,无完成时的更新 |
|
||||
| OpenClaw session-scope.ts 的定位 | 信息 | 如果上游接受,部分插件逻辑可以删除 |
|
||||
|
||||
---
|
||||
|
||||
## 六、结论
|
||||
|
||||
**核心 P0 改动(xworkmate.tasks.get + 原生 TaskRecord 集成)实施质量良好**,代码结构清晰,测试覆盖到位。主要缺陷在 `expectedArtifactDirs` 的全链路——Plugin 侧已就绪,但 APP 和 Bridge 两侧各缺一行代码。补充这两处后,所有三个 P0 项即全部完成。
|
||||
|
||||
P1 改动(ROOT_CAUSE_ANALYSIS 的 Fix 1-3)全部正确实施,产物完整性问题已解决。
|
||||
@ -334,31 +334,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
: null;
|
||||
}
|
||||
|
||||
bool isOpenClawNoExportedArtifactsGuardResultInternal(
|
||||
GoTaskServiceResult result,
|
||||
) {
|
||||
if (result.artifacts.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
final status = result.status.trim().toLowerCase();
|
||||
if (status == 'artifact_missing') {
|
||||
return true;
|
||||
}
|
||||
final code = result.code.trim().toUpperCase();
|
||||
if (code == 'OPENCLAW_ARTIFACT_MISSING' ||
|
||||
code == 'OPENCLAW_NO_EXPORTED_ARTIFACTS') {
|
||||
return true;
|
||||
}
|
||||
final warnings = result.raw['artifactWarnings'];
|
||||
if (warnings is List) {
|
||||
return warnings.any((item) {
|
||||
final text = item?.toString().toLowerCase() ?? '';
|
||||
return text.contains('openclaw artifact export returned no files') ||
|
||||
text.contains('no files for a file-delivery request');
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Future<List<String>> recoverGatewayFailureArtifactPathsInternal(
|
||||
String sessionKey,
|
||||
@ -778,9 +754,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
existingThread.openClawTaskAssociation?.requiredArtifactExtensions ??
|
||||
const <String>[];
|
||||
final currentTaskArtifactRelativePaths =
|
||||
isOpenClawNoExportedArtifactsGuardResultInternal(result)
|
||||
? const <String>[]
|
||||
: await _workspaceArtifactPathsModifiedSinceInternal(
|
||||
await _workspaceArtifactPathsModifiedSinceInternal(
|
||||
root,
|
||||
existingThread.lifecycleState.lastRunAtMs,
|
||||
artifactSyncPolicy,
|
||||
@ -798,11 +772,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
lastArtifactSyncStatus:
|
||||
isOpenClawNoExportedArtifactsGuardResultInternal(result) ||
|
||||
requiredExts.isNotEmpty
|
||||
? 'no-exported-artifacts'
|
||||
: 'no-artifacts',
|
||||
lastArtifactSyncStatus: 'no-artifacts',
|
||||
updatedAtMs: syncedAtMs,
|
||||
);
|
||||
return;
|
||||
@ -888,8 +858,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
: 'synced')
|
||||
: failedArtifact
|
||||
? 'download-failed'
|
||||
: rejectedArtifact
|
||||
? 'no-exported-artifacts'
|
||||
: 'no-artifacts';
|
||||
final currentTaskArtifactRelativePaths = wroteArtifact
|
||||
? (currentTaskArtifactPaths.toList(growable: false)..sort())
|
||||
|
||||
@ -735,24 +735,19 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
required OpenClawTaskAssociation association,
|
||||
}) async {
|
||||
var current = association;
|
||||
final pollDelay = _openClawAssociationPollDelayInternal(current);
|
||||
final maxAttempts = math.max(
|
||||
1,
|
||||
((math.max(1, current.runtimeBudgetMinutes) * 60) /
|
||||
math.max(1, pollDelay.inSeconds))
|
||||
.ceil(),
|
||||
);
|
||||
var artifactRetries = 0;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
var firstAttempt = true;
|
||||
while (true) {
|
||||
if (disposedInternal) {
|
||||
return;
|
||||
}
|
||||
if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
if (attempt > 0) {
|
||||
await Future<void>.delayed(pollDelay);
|
||||
if (!firstAttempt) {
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
firstAttempt = false;
|
||||
try {
|
||||
final result = await goTaskServiceClientInternal.getTask(
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
@ -812,34 +807,6 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble();
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lifecycleStatus: 'ready',
|
||||
lastRunAtMs: nowMs,
|
||||
lastResultCode: 'TASK_SLA_EXPIRED',
|
||||
lastArtifactSyncAtMs: nowMs,
|
||||
lastArtifactSyncStatus: 'failed',
|
||||
openClawTaskAssociation: current.copyWith(status: 'failed'),
|
||||
updatedAtMs: nowMs,
|
||||
);
|
||||
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
|
||||
clearAiGatewayStreamingTextInternal(sessionKey);
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
|
||||
Duration _openClawAssociationPollDelayInternal(
|
||||
OpenClawTaskAssociation association,
|
||||
) {
|
||||
final budget = association.runtimeBudgetMinutes;
|
||||
if (budget >= 60) {
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
if (budget >= 30) {
|
||||
return const Duration(seconds: 3);
|
||||
}
|
||||
return const Duration(seconds: 2);
|
||||
}
|
||||
|
||||
void resumeOpenClawTaskAssociationsInternal({String? onlySessionKey}) {
|
||||
@ -1323,15 +1290,6 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
lastResultCode: terminalResultCode,
|
||||
updatedAtMs: completedAtMs,
|
||||
);
|
||||
if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) {
|
||||
await persistGoTaskArtifactsForSessionInternal(sessionKey, result);
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
clearOpenClawTaskAssociation: true,
|
||||
updatedAtMs: completedAtMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!result.success) {
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
|
||||
@ -122,8 +122,6 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
lifecycleStatus: thread?.lifecycleState.status ?? '',
|
||||
lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
|
||||
artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '',
|
||||
runtimeBudgetMinutes:
|
||||
thread?.openClawTaskAssociation?.runtimeBudgetMinutes ?? 10,
|
||||
);
|
||||
|
||||
return SurfaceCard(
|
||||
|
||||
@ -811,22 +811,10 @@ bool _inferGoTaskSuccess(Map<String, dynamic> result) {
|
||||
]).toLowerCase();
|
||||
if (status == 'failed' ||
|
||||
status == 'error' ||
|
||||
status == 'artifact_missing' ||
|
||||
status == 'cancelled' ||
|
||||
status == 'canceled') {
|
||||
return false;
|
||||
}
|
||||
final code = _firstNestedGoTaskString(result, const <List<String>>[
|
||||
<String>['code'],
|
||||
<String>['details', 'code'],
|
||||
<String>['payload', 'code'],
|
||||
<String>['result', 'code'],
|
||||
]).toUpperCase();
|
||||
if (code == 'OPENCLAW_ARTIFACT_MISSING' ||
|
||||
code == 'OPENCLAW_NO_EXPORTED_ARTIFACTS' ||
|
||||
code == 'ARTIFACT_MISSING') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -921,7 +921,6 @@ class OpenClawTaskAssociation {
|
||||
required this.artifactScope,
|
||||
required this.artifactDirectory,
|
||||
required this.gatewayProviderId,
|
||||
required this.runtimeBudgetMinutes,
|
||||
required this.startedAtMs,
|
||||
required this.status,
|
||||
this.taskLoadClass = '',
|
||||
@ -937,7 +936,6 @@ class OpenClawTaskAssociation {
|
||||
final String artifactScope;
|
||||
final String artifactDirectory;
|
||||
final String gatewayProviderId;
|
||||
final int runtimeBudgetMinutes;
|
||||
final double startedAtMs;
|
||||
final String status;
|
||||
final String taskLoadClass;
|
||||
@ -962,7 +960,6 @@ class OpenClawTaskAssociation {
|
||||
artifactScope: artifactScope,
|
||||
artifactDirectory: artifactDirectory,
|
||||
gatewayProviderId: gatewayProviderId,
|
||||
runtimeBudgetMinutes: runtimeBudgetMinutes,
|
||||
startedAtMs: startedAtMs,
|
||||
status: status ?? this.status,
|
||||
taskLoadClass: taskLoadClass,
|
||||
@ -981,7 +978,6 @@ class OpenClawTaskAssociation {
|
||||
'artifactScope': artifactScope,
|
||||
'artifactDirectory': artifactDirectory,
|
||||
'gatewayProviderId': gatewayProviderId,
|
||||
'runtimeBudgetMinutes': runtimeBudgetMinutes,
|
||||
'startedAtMs': startedAtMs,
|
||||
'status': status,
|
||||
'taskLoadClass': taskLoadClass,
|
||||
@ -1000,7 +996,6 @@ class OpenClawTaskAssociation {
|
||||
'artifactScope': artifactScope,
|
||||
'artifactDirectory': artifactDirectory,
|
||||
'gatewayProviderId': gatewayProviderId,
|
||||
'runtimeBudgetMinutes': runtimeBudgetMinutes,
|
||||
'taskLoadClass': taskLoadClass,
|
||||
'sessionKey': sessionKey,
|
||||
'requiredArtifactExtensions': requiredArtifactExtensions,
|
||||
@ -1049,7 +1044,6 @@ class OpenClawTaskAssociation {
|
||||
true
|
||||
? json['resolvedGatewayProviderId'].toString().trim()
|
||||
: 'openclaw'),
|
||||
runtimeBudgetMinutes: asInt(json['runtimeBudgetMinutes']),
|
||||
startedAtMs: asDouble(json['startedAtMs']),
|
||||
status: json['status']?.toString().trim().isNotEmpty == true
|
||||
? json['status'].toString().trim()
|
||||
|
||||
@ -399,13 +399,6 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
|
||||
}
|
||||
|
||||
String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) {
|
||||
if (widget.artifactSyncStatus.trim().toLowerCase() ==
|
||||
'no-exported-artifacts') {
|
||||
return appText(
|
||||
'本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。',
|
||||
'No exported files were detected for this run. Run it again and ask OpenClaw to create files in the current workspace.',
|
||||
);
|
||||
}
|
||||
final filesMessage = snapshot.filesMessage.trim();
|
||||
if (filesMessage.isNotEmpty) {
|
||||
return filesMessage;
|
||||
|
||||
@ -16,19 +16,16 @@ class AssistantTaskProgressState {
|
||||
required this.phase,
|
||||
required this.label,
|
||||
this.value,
|
||||
this.runtimeBudgetMinutes,
|
||||
});
|
||||
|
||||
const AssistantTaskProgressState.idle()
|
||||
: phase = AssistantTaskProgressPhase.idle,
|
||||
label = '',
|
||||
value = null,
|
||||
runtimeBudgetMinutes = null;
|
||||
value = null;
|
||||
|
||||
final AssistantTaskProgressPhase phase;
|
||||
final String label;
|
||||
final double? value;
|
||||
final int? runtimeBudgetMinutes;
|
||||
|
||||
bool get visible => phase != AssistantTaskProgressPhase.idle;
|
||||
bool get interrupted => phase == AssistantTaskProgressPhase.interrupted;
|
||||
@ -171,20 +168,15 @@ AssistantTaskProgressState assistantTaskProgressState({
|
||||
required String lifecycleStatus,
|
||||
required String lastResultCode,
|
||||
required String artifactSyncStatus,
|
||||
int? runtimeBudgetMinutes,
|
||||
}) {
|
||||
final syncStatus = artifactSyncStatus.trim().toLowerCase();
|
||||
final status = lifecycleStatus.trim().toLowerCase();
|
||||
final budget = runtimeBudgetMinutes == null || runtimeBudgetMinutes <= 0
|
||||
? null
|
||||
: runtimeBudgetMinutes;
|
||||
final result = lastResultCode.trim().toUpperCase();
|
||||
if (status == 'queued' || syncStatus == 'queued' || result == 'QUEUED') {
|
||||
return AssistantTaskProgressState(
|
||||
phase: AssistantTaskProgressPhase.queued,
|
||||
label: appText('任务已排队,等待执行...', 'Task queued, waiting to run...'),
|
||||
value: 0.18,
|
||||
runtimeBudgetMinutes: budget,
|
||||
);
|
||||
}
|
||||
if (pending && syncStatus == 'syncing') {
|
||||
@ -192,14 +184,12 @@ AssistantTaskProgressState assistantTaskProgressState({
|
||||
phase: AssistantTaskProgressPhase.syncingArtifacts,
|
||||
label: appText('正在同步生成文件...', 'Syncing generated files...'),
|
||||
value: 0.82,
|
||||
runtimeBudgetMinutes: budget,
|
||||
);
|
||||
}
|
||||
if (pending) {
|
||||
return AssistantTaskProgressState(
|
||||
phase: AssistantTaskProgressPhase.running,
|
||||
label: _budgetedProgressLabel(appText('任务运行中', 'Task running'), budget),
|
||||
runtimeBudgetMinutes: budget,
|
||||
label: appText('任务运行中...', 'Task running...'),
|
||||
);
|
||||
}
|
||||
if (status == 'interrupted' || syncStatus == 'interrupted') {
|
||||
@ -222,12 +212,7 @@ AssistantTaskProgressState assistantTaskProgressState({
|
||||
return const AssistantTaskProgressState.idle();
|
||||
}
|
||||
|
||||
String _budgetedProgressLabel(String base, int? minutes) {
|
||||
if (minutes == null || minutes <= 0) {
|
||||
return '$base...';
|
||||
}
|
||||
return appText('$base,预计最长 $minutes 分钟...', '$base, up to $minutes min...');
|
||||
}
|
||||
|
||||
|
||||
String _interruptedTaskProgressLabel(String result) {
|
||||
if (result == 'ACP_HTTP_HANDSHAKE_INTERRUPTED') {
|
||||
|
||||
@ -52,7 +52,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
artifactSyncAtMs: 1,
|
||||
artifactSyncStatus: 'no-exported-artifacts',
|
||||
artifactSyncStatus: 'failed',
|
||||
loadSnapshot: () async => const AssistantArtifactSnapshot(
|
||||
workspacePath: '/tmp/thread',
|
||||
workspaceKind: WorkspaceRefKind.localPath,
|
||||
|
||||
@ -15,7 +15,6 @@ void main() {
|
||||
lifecycleStatus: 'running',
|
||||
lastResultCode: 'running',
|
||||
artifactSyncStatus: '',
|
||||
runtimeBudgetMinutes: 30,
|
||||
),
|
||||
onStop: () {},
|
||||
),
|
||||
@ -25,7 +24,7 @@ void main() {
|
||||
find.byKey(const Key('assistant-task-progress-bar')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('任务运行中,预计最长 30 分钟...'), findsOneWidget);
|
||||
expect(find.text('任务运行中...'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-task-progress-stop-button')),
|
||||
findsOneWidget,
|
||||
|
||||
@ -1350,7 +1350,7 @@ void main() {
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(thread.lastArtifactSyncStatus, 'no-artifacts');
|
||||
expect(thread.lastArtifactSyncAtMs, greaterThan(0));
|
||||
},
|
||||
);
|
||||
@ -1432,7 +1432,7 @@ void main() {
|
||||
final thread = controller.requireTaskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(thread.lastArtifactSyncStatus, 'no-artifacts');
|
||||
expect(thread.lastTaskArtifactRelativePaths, isEmpty);
|
||||
});
|
||||
|
||||
|
||||
@ -1820,85 +1820,6 @@ void main() {
|
||||
|
||||
test(
|
||||
'sendChatMessage restarts before handling OpenClaw artifact guard results',
|
||||
() async {
|
||||
final localWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-interrupt-guard-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await localWorkspace.exists()) {
|
||||
await localWorkspace.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
const guardMessage =
|
||||
'未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。';
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient()
|
||||
..updatesBeforeNextOutcome.add(
|
||||
const GoTaskServiceUpdate(
|
||||
sessionId: 'unit-fixture-task-a',
|
||||
threadId: 'unit-fixture-task-a',
|
||||
turnId: 'turn-1',
|
||||
type: 'delta',
|
||||
text: 'guard partial output must not persist',
|
||||
message: '',
|
||||
pending: true,
|
||||
error: false,
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
payload: <String, dynamic>{},
|
||||
),
|
||||
)
|
||||
..outcomes.add(
|
||||
const GatewayAcpException(
|
||||
'ACP HTTP connection closed before the response finished arriving',
|
||||
code: 'ACP_HTTP_CONNECTION_CLOSED',
|
||||
),
|
||||
)
|
||||
..outcomes.add(
|
||||
const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: guardMessage,
|
||||
turnId: 'turn-2',
|
||||
raw: <String, dynamic>{'code': 'OPENCLAW_NO_EXPORTED_ARTIFACTS'},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
final controller = _connectedController(
|
||||
fakeGoTaskService,
|
||||
homeDir: localWorkspace.path,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
|
||||
await controller.sendChatMessage('first turn');
|
||||
await controller.sendChatMessage('follow up');
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(2));
|
||||
expect(fakeGoTaskService.requests.first.resumeSession, isFalse);
|
||||
expect(fakeGoTaskService.requests.last.resumeSession, isTrue);
|
||||
|
||||
final transcript = controller.chatMessages
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件')));
|
||||
expect(transcript, isNot(contains('口头下载声明')));
|
||||
expect(
|
||||
transcript,
|
||||
isNot(contains('guard partial output must not persist')),
|
||||
);
|
||||
|
||||
final thread = controller.taskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(thread?.lifecycleState.status, 'ready');
|
||||
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage starts a new session after OpenClaw terminal artifact failure',
|
||||
() async {
|
||||
@ -1947,7 +1868,7 @@ void main() {
|
||||
failedThread?.lifecycleState.lastResultCode,
|
||||
'OPENCLAW_NO_EXPORTED_ARTIFACTS',
|
||||
);
|
||||
expect(failedThread?.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(failedThread?.lastArtifactSyncStatus, 'failed');
|
||||
|
||||
await controller.sendChatMessage('retry final artifact');
|
||||
|
||||
@ -1960,76 +1881,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
|
||||
() async {
|
||||
final localWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-guard-failure-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await localWorkspace.exists()) {
|
||||
await localWorkspace.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
const guardMessage =
|
||||
'未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。';
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient()
|
||||
..updatesBeforeNextOutcome.add(
|
||||
const GoTaskServiceUpdate(
|
||||
sessionId: 'unit-fixture-task-a',
|
||||
threadId: 'unit-fixture-task-a',
|
||||
turnId: 'turn-1',
|
||||
type: 'delta',
|
||||
text: guardMessage,
|
||||
message: '',
|
||||
pending: true,
|
||||
error: false,
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
payload: <String, dynamic>{},
|
||||
),
|
||||
)
|
||||
..outcomes.add(
|
||||
const GoTaskServiceResult(
|
||||
success: false,
|
||||
message: '',
|
||||
turnId: 'turn-1',
|
||||
raw: <String, dynamic>{
|
||||
'status': 'artifact_missing',
|
||||
'code': 'OPENCLAW_ARTIFACT_MISSING',
|
||||
'artifactWarnings': <String>[
|
||||
'OpenClaw artifact export returned no files for a file-delivery request.',
|
||||
],
|
||||
},
|
||||
errorMessage: guardMessage,
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
),
|
||||
);
|
||||
final controller = _connectedController(
|
||||
fakeGoTaskService,
|
||||
homeDir: localWorkspace.path,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
await controller.sendChatMessage('create files');
|
||||
|
||||
final transcript = controller.chatMessages
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件')));
|
||||
expect(transcript, isNot(contains('口头下载声明')));
|
||||
final thread = controller.taskThreadForSessionInternal(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
expect(thread?.lifecycleState.lastResultCode, 'artifact_missing');
|
||||
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendChatMessage restarts after ACP HTTP handshake interruption',
|
||||
() async {
|
||||
@ -4179,7 +4030,7 @@ void main() {
|
||||
await _waitForThreadArtifactSyncStatusWithin(
|
||||
controller,
|
||||
'openclaw-missing-screenshot',
|
||||
'no-exported-artifacts',
|
||||
'no-artifacts',
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
@ -4188,7 +4039,7 @@ void main() {
|
||||
);
|
||||
expect(thread.lifecycleState.status, 'ready');
|
||||
expect(thread.lifecycleState.lastResultCode, 'success');
|
||||
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts');
|
||||
expect(thread.lastArtifactSyncStatus, 'no-artifacts');
|
||||
expect(thread.openClawTaskAssociation, isNull);
|
||||
expect(
|
||||
controller.assistantSessionHasPendingRun(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user