fix: require OpenClaw artifact export before completion

This commit is contained in:
Haitao Pan 2026-06-06 18:23:05 +08:00
parent b2f4fc7868
commit b7a842fce3
5 changed files with 154 additions and 16 deletions

View File

@ -334,8 +334,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
: null;
}
Future<List<String>> recoverGatewayFailureArtifactPathsInternal(
String sessionKey,
Object error,
@ -750,12 +748,34 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
);
final artifacts = result.artifacts;
if (artifacts.isEmpty) {
final association = existingThread.openClawTaskAssociation;
final waitingForOpenClawArtifacts =
association != null &&
(association.requiresArtifactExport ||
association.requiredArtifactExtensions.isNotEmpty) &&
(association.artifactScope.trim().isNotEmpty ||
association.artifactDirectory.trim().isNotEmpty) &&
result.success;
if (waitingForOpenClawArtifacts) {
upsertTaskThreadInternal(
normalizedSessionKey,
lifecycleStatus: 'running',
lastResultCode: 'running',
lastArtifactSyncAtMs: syncedAtMs,
lastArtifactSyncStatus: 'syncing',
openClawTaskAssociation: association.copyWith(
status: 'syncing-artifacts',
),
updatedAtMs: syncedAtMs,
);
return;
}
final currentTaskArtifactRelativePaths =
await _workspaceArtifactPathsModifiedSinceInternal(
root,
existingThread.lifecycleState.lastRunAtMs,
artifactSyncPolicy,
);
root,
existingThread.lifecycleState.lastRunAtMs,
artifactSyncPolicy,
);
if (currentTaskArtifactRelativePaths.isNotEmpty) {
upsertTaskThreadInternal(
normalizedSessionKey,

View File

@ -772,16 +772,27 @@ extension AppControllerDesktopThreadActions on AppController {
continue;
}
if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
final hasTaskScopedOpenClawArtifacts =
current.artifactScope.trim().isNotEmpty ||
current.artifactDirectory.trim().isNotEmpty;
final hasRequiredExts = current.requiredArtifactExtensions.isNotEmpty;
final requiresArtifactExport =
current.requiresArtifactExport || hasRequiredExts;
final hasEnoughArtifacts =
!hasRequiredExts ||
current.requiredArtifactExtensions.every((ext) {
return result.artifacts.any(
(a) =>
a.relativePath.toLowerCase().endsWith(ext.toLowerCase()),
);
});
if (!hasEnoughArtifacts) {
result.artifacts.isNotEmpty &&
(!hasRequiredExts ||
current.requiredArtifactExtensions.every((ext) {
return result.artifacts.any(
(a) => a.relativePath.toLowerCase().endsWith(
ext.toLowerCase(),
),
);
}));
final shouldKeepPollingForArtifacts =
hasTaskScopedOpenClawArtifacts &&
requiresArtifactExport &&
!hasEnoughArtifacts;
if (shouldKeepPollingForArtifacts) {
final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble();
current = current.copyWith(status: 'syncing-artifacts');
upsertTaskThreadInternal(
@ -1257,6 +1268,22 @@ extension AppControllerDesktopThreadActions on AppController {
final completedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
final assistantText = result.message.trim();
final hasCurrentRunArtifacts = result.artifacts.isNotEmpty;
final openClawAssociation = result.openClawTaskAssociation;
final waitingForOpenClawArtifacts =
openClawAssociation != null &&
!hasCurrentRunArtifacts &&
result.success &&
(openClawAssociation.requiresArtifactExport ||
openClawAssociation.requiredArtifactExtensions.isNotEmpty) &&
(openClawAssociation.artifactScope.trim().isNotEmpty ||
openClawAssociation.artifactDirectory.trim().isNotEmpty);
if (waitingForOpenClawArtifacts) {
persistOpenClawTaskAssociationInternal(
sessionKey: sessionKey,
association: openClawAssociation.copyWith(status: 'syncing-artifacts'),
);
return;
}
final noDisplayableOutput =
result.success && assistantText.isEmpty && !hasCurrentRunArtifacts;
final terminalResultCode = noDisplayableOutput

View File

@ -928,6 +928,7 @@ class OpenClawTaskAssociation {
this.taskLoadClass = '',
this.requiredArtifactExtensions = const <String>[],
this.expectedArtifactExtensions = const <String>[],
this.requiresArtifactExport = false,
});
final String sessionId;
@ -944,6 +945,7 @@ class OpenClawTaskAssociation {
final String taskLoadClass;
final List<String> requiredArtifactExtensions;
final List<String> expectedArtifactExtensions;
final bool requiresArtifactExport;
bool get isTerminal {
final normalized = status.trim().toLowerCase();
@ -969,6 +971,7 @@ class OpenClawTaskAssociation {
taskLoadClass: taskLoadClass,
requiredArtifactExtensions: requiredArtifactExtensions,
expectedArtifactExtensions: expectedArtifactExtensions,
requiresArtifactExport: requiresArtifactExport,
);
}
@ -988,6 +991,7 @@ class OpenClawTaskAssociation {
'taskLoadClass': taskLoadClass,
'requiredArtifactExtensions': requiredArtifactExtensions,
'expectedArtifactExtensions': expectedArtifactExtensions,
'requiresArtifactExport': requiresArtifactExport,
};
}
@ -1046,6 +1050,10 @@ class OpenClawTaskAssociation {
expectedArtifactExtensions: _stringListFromJson(
json['expectedArtifactExtensions'],
),
requiresArtifactExport:
_boolFromJson(json['requiresArtifactExport']) ??
_boolFromJson(json['requiresExportBeforeFinalResponse']) ??
false,
);
}
}
@ -1071,6 +1079,20 @@ List<String> _stringListFromJson(Object? value) {
return items;
}
bool? _boolFromJson(Object? value) {
if (value is bool) {
return value;
}
final normalized = value?.toString().trim().toLowerCase() ?? '';
if (normalized == 'true' || normalized == '1' || normalized == 'yes') {
return true;
}
if (normalized == 'false' || normalized == '0' || normalized == 'no') {
return false;
}
return null;
}
class ThreadLifecycleState {
const ThreadLifecycleState({
required this.archived,

View File

@ -1744,6 +1744,75 @@ void main() {
expect(snapshot.resultMessage, 'No task artifacts recorded for this run.');
});
test('keeps OpenClaw task-scope empty artifact results syncing', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-openclaw-empty-artifacts-workspace-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
controller.upsertTaskThreadInternal(
'unit-fixture-openclaw-empty',
workspaceBinding: WorkspaceBinding(
workspaceId: 'unit-fixture-openclaw-empty',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
lifecycleStatus: 'running',
lastResultCode: 'running',
openClawTaskAssociation: const OpenClawTaskAssociation(
sessionId: 'unit-fixture-openclaw-empty',
threadId: 'unit-fixture-openclaw-empty',
turnId: 'turn-empty',
runId: 'turn-empty',
artifactScope:
'tasks/agent:main:unit-fixture-openclaw-empty/turn-empty',
artifactDirectory:
'/home/ubuntu/.openclaw/workspace/tasks/agent:main:unit-fixture-openclaw-empty/turn-empty',
gatewayProviderId: 'openclaw',
startedAtMs: 1,
status: 'running',
appThreadKey: 'unit-fixture-openclaw-empty',
openclawSessionKey: 'agent:main:unit-fixture-openclaw-empty',
requiresArtifactExport: true,
),
);
const result = GoTaskServiceResult(
success: true,
message: 'completed but export is still empty',
turnId: 'turn-empty',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-openclaw-empty',
result,
);
final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-openclaw-empty',
);
expect(thread.lifecycleState.status, 'running');
expect(thread.lifecycleState.lastResultCode, 'running');
expect(thread.lastArtifactSyncStatus, 'syncing');
expect(thread.lastTaskArtifactRelativePaths, isEmpty);
expect(thread.openClawTaskAssociation?.status, 'syncing-artifacts');
});
test(
'records workspace files produced during an empty-artifact task run',
() async {

View File

@ -4055,7 +4055,7 @@ void main() {
});
test(
'OpenClaw terminal snapshot without required artifacts keeps polling',
'OpenClaw terminal task-scope snapshot without artifacts keeps polling',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
@ -4078,7 +4078,7 @@ void main() {
'/tmp/tasks/agent:main:openclaw-missing-screenshot/run-openclaw-missing-screenshot',
'gatewayProviderId': 'openclaw',
'runtimeBudgetMinutes': 1,
'requiredArtifactExtensions': <String>['.png'],
'requiresArtifactExport': true,
},
errorMessage: '',
resolvedModel: '',