refactor: Remove OpenClaw rigid time limits and false positive no-exported-artifacts judgment

This commit is contained in:
Haitao Pan 2026-06-05 18:10:34 +08:00
parent 7047a4f38b
commit 6d5122682c
12 changed files with 182 additions and 284 deletions

View 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 changestaskState.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全部正确实施产物完整性问题已解决。

View File

@ -334,31 +334,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
: null; : 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( Future<List<String>> recoverGatewayFailureArtifactPathsInternal(
String sessionKey, String sessionKey,
@ -778,9 +754,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
existingThread.openClawTaskAssociation?.requiredArtifactExtensions ?? existingThread.openClawTaskAssociation?.requiredArtifactExtensions ??
const <String>[]; const <String>[];
final currentTaskArtifactRelativePaths = final currentTaskArtifactRelativePaths =
isOpenClawNoExportedArtifactsGuardResultInternal(result) await _workspaceArtifactPathsModifiedSinceInternal(
? const <String>[]
: await _workspaceArtifactPathsModifiedSinceInternal(
root, root,
existingThread.lifecycleState.lastRunAtMs, existingThread.lifecycleState.lastRunAtMs,
artifactSyncPolicy, artifactSyncPolicy,
@ -798,11 +772,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
upsertTaskThreadInternal( upsertTaskThreadInternal(
normalizedSessionKey, normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs, lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: lastArtifactSyncStatus: 'no-artifacts',
isOpenClawNoExportedArtifactsGuardResultInternal(result) ||
requiredExts.isNotEmpty
? 'no-exported-artifacts'
: 'no-artifacts',
updatedAtMs: syncedAtMs, updatedAtMs: syncedAtMs,
); );
return; return;
@ -888,8 +858,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
: 'synced') : 'synced')
: failedArtifact : failedArtifact
? 'download-failed' ? 'download-failed'
: rejectedArtifact
? 'no-exported-artifacts'
: 'no-artifacts'; : 'no-artifacts';
final currentTaskArtifactRelativePaths = wroteArtifact final currentTaskArtifactRelativePaths = wroteArtifact
? (currentTaskArtifactPaths.toList(growable: false)..sort()) ? (currentTaskArtifactPaths.toList(growable: false)..sort())

View File

@ -735,24 +735,19 @@ extension AppControllerDesktopThreadActions on AppController {
required OpenClawTaskAssociation association, required OpenClawTaskAssociation association,
}) async { }) async {
var current = association; 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; var artifactRetries = 0;
for (var attempt = 0; attempt < maxAttempts; attempt += 1) { var firstAttempt = true;
while (true) {
if (disposedInternal) { if (disposedInternal) {
return; return;
} }
if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { if (!aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
return; return;
} }
if (attempt > 0) { if (!firstAttempt) {
await Future<void>.delayed(pollDelay); await Future<void>.delayed(const Duration(seconds: 2));
} }
firstAttempt = false;
try { try {
final result = await goTaskServiceClientInternal.getTask( final result = await goTaskServiceClientInternal.getTask(
route: GoTaskServiceRoute.externalAcpSingle, route: GoTaskServiceRoute.externalAcpSingle,
@ -812,34 +807,6 @@ extension AppControllerDesktopThreadActions on AppController {
return; 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}) { void resumeOpenClawTaskAssociationsInternal({String? onlySessionKey}) {
@ -1323,15 +1290,6 @@ extension AppControllerDesktopThreadActions on AppController {
lastResultCode: terminalResultCode, lastResultCode: terminalResultCode,
updatedAtMs: completedAtMs, updatedAtMs: completedAtMs,
); );
if (isOpenClawNoExportedArtifactsGuardResultInternal(result)) {
await persistGoTaskArtifactsForSessionInternal(sessionKey, result);
upsertTaskThreadInternal(
sessionKey,
clearOpenClawTaskAssociation: true,
updatedAtMs: completedAtMs,
);
return;
}
if (!result.success) { if (!result.success) {
upsertTaskThreadInternal( upsertTaskThreadInternal(
sessionKey, sessionKey,

View File

@ -122,8 +122,6 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
lifecycleStatus: thread?.lifecycleState.status ?? '', lifecycleStatus: thread?.lifecycleState.status ?? '',
lastResultCode: thread?.lifecycleState.lastResultCode ?? '', lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '', artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '',
runtimeBudgetMinutes:
thread?.openClawTaskAssociation?.runtimeBudgetMinutes ?? 10,
); );
return SurfaceCard( return SurfaceCard(

View File

@ -811,22 +811,10 @@ bool _inferGoTaskSuccess(Map<String, dynamic> result) {
]).toLowerCase(); ]).toLowerCase();
if (status == 'failed' || if (status == 'failed' ||
status == 'error' || status == 'error' ||
status == 'artifact_missing' ||
status == 'cancelled' || status == 'cancelled' ||
status == 'canceled') { status == 'canceled') {
return false; 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; return true;
} }

View File

@ -921,7 +921,6 @@ class OpenClawTaskAssociation {
required this.artifactScope, required this.artifactScope,
required this.artifactDirectory, required this.artifactDirectory,
required this.gatewayProviderId, required this.gatewayProviderId,
required this.runtimeBudgetMinutes,
required this.startedAtMs, required this.startedAtMs,
required this.status, required this.status,
this.taskLoadClass = '', this.taskLoadClass = '',
@ -937,7 +936,6 @@ class OpenClawTaskAssociation {
final String artifactScope; final String artifactScope;
final String artifactDirectory; final String artifactDirectory;
final String gatewayProviderId; final String gatewayProviderId;
final int runtimeBudgetMinutes;
final double startedAtMs; final double startedAtMs;
final String status; final String status;
final String taskLoadClass; final String taskLoadClass;
@ -962,7 +960,6 @@ class OpenClawTaskAssociation {
artifactScope: artifactScope, artifactScope: artifactScope,
artifactDirectory: artifactDirectory, artifactDirectory: artifactDirectory,
gatewayProviderId: gatewayProviderId, gatewayProviderId: gatewayProviderId,
runtimeBudgetMinutes: runtimeBudgetMinutes,
startedAtMs: startedAtMs, startedAtMs: startedAtMs,
status: status ?? this.status, status: status ?? this.status,
taskLoadClass: taskLoadClass, taskLoadClass: taskLoadClass,
@ -981,7 +978,6 @@ class OpenClawTaskAssociation {
'artifactScope': artifactScope, 'artifactScope': artifactScope,
'artifactDirectory': artifactDirectory, 'artifactDirectory': artifactDirectory,
'gatewayProviderId': gatewayProviderId, 'gatewayProviderId': gatewayProviderId,
'runtimeBudgetMinutes': runtimeBudgetMinutes,
'startedAtMs': startedAtMs, 'startedAtMs': startedAtMs,
'status': status, 'status': status,
'taskLoadClass': taskLoadClass, 'taskLoadClass': taskLoadClass,
@ -1000,7 +996,6 @@ class OpenClawTaskAssociation {
'artifactScope': artifactScope, 'artifactScope': artifactScope,
'artifactDirectory': artifactDirectory, 'artifactDirectory': artifactDirectory,
'gatewayProviderId': gatewayProviderId, 'gatewayProviderId': gatewayProviderId,
'runtimeBudgetMinutes': runtimeBudgetMinutes,
'taskLoadClass': taskLoadClass, 'taskLoadClass': taskLoadClass,
'sessionKey': sessionKey, 'sessionKey': sessionKey,
'requiredArtifactExtensions': requiredArtifactExtensions, 'requiredArtifactExtensions': requiredArtifactExtensions,
@ -1049,7 +1044,6 @@ class OpenClawTaskAssociation {
true true
? json['resolvedGatewayProviderId'].toString().trim() ? json['resolvedGatewayProviderId'].toString().trim()
: 'openclaw'), : 'openclaw'),
runtimeBudgetMinutes: asInt(json['runtimeBudgetMinutes']),
startedAtMs: asDouble(json['startedAtMs']), startedAtMs: asDouble(json['startedAtMs']),
status: json['status']?.toString().trim().isNotEmpty == true status: json['status']?.toString().trim().isNotEmpty == true
? json['status'].toString().trim() ? json['status'].toString().trim()

View File

@ -399,13 +399,6 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
} }
String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) { 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(); final filesMessage = snapshot.filesMessage.trim();
if (filesMessage.isNotEmpty) { if (filesMessage.isNotEmpty) {
return filesMessage; return filesMessage;

View File

@ -16,19 +16,16 @@ class AssistantTaskProgressState {
required this.phase, required this.phase,
required this.label, required this.label,
this.value, this.value,
this.runtimeBudgetMinutes,
}); });
const AssistantTaskProgressState.idle() const AssistantTaskProgressState.idle()
: phase = AssistantTaskProgressPhase.idle, : phase = AssistantTaskProgressPhase.idle,
label = '', label = '',
value = null, value = null;
runtimeBudgetMinutes = null;
final AssistantTaskProgressPhase phase; final AssistantTaskProgressPhase phase;
final String label; final String label;
final double? value; final double? value;
final int? runtimeBudgetMinutes;
bool get visible => phase != AssistantTaskProgressPhase.idle; bool get visible => phase != AssistantTaskProgressPhase.idle;
bool get interrupted => phase == AssistantTaskProgressPhase.interrupted; bool get interrupted => phase == AssistantTaskProgressPhase.interrupted;
@ -171,20 +168,15 @@ AssistantTaskProgressState assistantTaskProgressState({
required String lifecycleStatus, required String lifecycleStatus,
required String lastResultCode, required String lastResultCode,
required String artifactSyncStatus, required String artifactSyncStatus,
int? runtimeBudgetMinutes,
}) { }) {
final syncStatus = artifactSyncStatus.trim().toLowerCase(); final syncStatus = artifactSyncStatus.trim().toLowerCase();
final status = lifecycleStatus.trim().toLowerCase(); final status = lifecycleStatus.trim().toLowerCase();
final budget = runtimeBudgetMinutes == null || runtimeBudgetMinutes <= 0
? null
: runtimeBudgetMinutes;
final result = lastResultCode.trim().toUpperCase(); final result = lastResultCode.trim().toUpperCase();
if (status == 'queued' || syncStatus == 'queued' || result == 'QUEUED') { if (status == 'queued' || syncStatus == 'queued' || result == 'QUEUED') {
return AssistantTaskProgressState( return AssistantTaskProgressState(
phase: AssistantTaskProgressPhase.queued, phase: AssistantTaskProgressPhase.queued,
label: appText('任务已排队,等待执行...', 'Task queued, waiting to run...'), label: appText('任务已排队,等待执行...', 'Task queued, waiting to run...'),
value: 0.18, value: 0.18,
runtimeBudgetMinutes: budget,
); );
} }
if (pending && syncStatus == 'syncing') { if (pending && syncStatus == 'syncing') {
@ -192,14 +184,12 @@ AssistantTaskProgressState assistantTaskProgressState({
phase: AssistantTaskProgressPhase.syncingArtifacts, phase: AssistantTaskProgressPhase.syncingArtifacts,
label: appText('正在同步生成文件...', 'Syncing generated files...'), label: appText('正在同步生成文件...', 'Syncing generated files...'),
value: 0.82, value: 0.82,
runtimeBudgetMinutes: budget,
); );
} }
if (pending) { if (pending) {
return AssistantTaskProgressState( return AssistantTaskProgressState(
phase: AssistantTaskProgressPhase.running, phase: AssistantTaskProgressPhase.running,
label: _budgetedProgressLabel(appText('任务运行中', 'Task running'), budget), label: appText('任务运行中...', 'Task running...'),
runtimeBudgetMinutes: budget,
); );
} }
if (status == 'interrupted' || syncStatus == 'interrupted') { if (status == 'interrupted' || syncStatus == 'interrupted') {
@ -222,12 +212,7 @@ AssistantTaskProgressState assistantTaskProgressState({
return const AssistantTaskProgressState.idle(); 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) { String _interruptedTaskProgressLabel(String result) {
if (result == 'ACP_HTTP_HANDSHAKE_INTERRUPTED') { if (result == 'ACP_HTTP_HANDSHAKE_INTERRUPTED') {

View File

@ -52,7 +52,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
_buildTestApp( _buildTestApp(
artifactSyncAtMs: 1, artifactSyncAtMs: 1,
artifactSyncStatus: 'no-exported-artifacts', artifactSyncStatus: 'failed',
loadSnapshot: () async => const AssistantArtifactSnapshot( loadSnapshot: () async => const AssistantArtifactSnapshot(
workspacePath: '/tmp/thread', workspacePath: '/tmp/thread',
workspaceKind: WorkspaceRefKind.localPath, workspaceKind: WorkspaceRefKind.localPath,

View File

@ -15,7 +15,6 @@ void main() {
lifecycleStatus: 'running', lifecycleStatus: 'running',
lastResultCode: 'running', lastResultCode: 'running',
artifactSyncStatus: '', artifactSyncStatus: '',
runtimeBudgetMinutes: 30,
), ),
onStop: () {}, onStop: () {},
), ),
@ -25,7 +24,7 @@ void main() {
find.byKey(const Key('assistant-task-progress-bar')), find.byKey(const Key('assistant-task-progress-bar')),
findsOneWidget, findsOneWidget,
); );
expect(find.text('任务运行中,预计最长 30 分钟...'), findsOneWidget); expect(find.text('任务运行中...'), findsOneWidget);
expect( expect(
find.byKey(const Key('assistant-task-progress-stop-button')), find.byKey(const Key('assistant-task-progress-stop-button')),
findsOneWidget, findsOneWidget,

View File

@ -1350,7 +1350,7 @@ void main() {
final thread = controller.requireTaskThreadForSessionInternal( final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a', 'unit-fixture-task-a',
); );
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread.lastArtifactSyncStatus, 'no-artifacts');
expect(thread.lastArtifactSyncAtMs, greaterThan(0)); expect(thread.lastArtifactSyncAtMs, greaterThan(0));
}, },
); );
@ -1432,7 +1432,7 @@ void main() {
final thread = controller.requireTaskThreadForSessionInternal( final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a', 'unit-fixture-task-a',
); );
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread.lastArtifactSyncStatus, 'no-artifacts');
expect(thread.lastTaskArtifactRelativePaths, isEmpty); expect(thread.lastTaskArtifactRelativePaths, isEmpty);
}); });

View File

@ -1820,85 +1820,6 @@ void main() {
test( test(
'sendChatMessage restarts before handling OpenClaw artifact guard results', '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( test(
'sendChatMessage starts a new session after OpenClaw terminal artifact failure', 'sendChatMessage starts a new session after OpenClaw terminal artifact failure',
() async { () async {
@ -1947,7 +1868,7 @@ void main() {
failedThread?.lifecycleState.lastResultCode, failedThread?.lifecycleState.lastResultCode,
'OPENCLAW_NO_EXPORTED_ARTIFACTS', 'OPENCLAW_NO_EXPORTED_ARTIFACTS',
); );
expect(failedThread?.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(failedThread?.lastArtifactSyncStatus, 'failed');
await controller.sendChatMessage('retry final artifact'); 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( test(
'sendChatMessage restarts after ACP HTTP handshake interruption', 'sendChatMessage restarts after ACP HTTP handshake interruption',
() async { () async {
@ -4179,7 +4030,7 @@ void main() {
await _waitForThreadArtifactSyncStatusWithin( await _waitForThreadArtifactSyncStatusWithin(
controller, controller,
'openclaw-missing-screenshot', 'openclaw-missing-screenshot',
'no-exported-artifacts', 'no-artifacts',
const Duration(seconds: 10), const Duration(seconds: 10),
); );
@ -4188,7 +4039,7 @@ void main() {
); );
expect(thread.lifecycleState.status, 'ready'); expect(thread.lifecycleState.status, 'ready');
expect(thread.lifecycleState.lastResultCode, 'success'); expect(thread.lifecycleState.lastResultCode, 'success');
expect(thread.lastArtifactSyncStatus, 'no-exported-artifacts'); expect(thread.lastArtifactSyncStatus, 'no-artifacts');
expect(thread.openClawTaskAssociation, isNull); expect(thread.openClawTaskAssociation, isNull);
expect( expect(
controller.assistantSessionHasPendingRun( controller.assistantSessionHasPendingRun(