From c4ca5a2c3405c21f36a9b3efc217b5f677686dca Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 2 Jun 2026 16:21:17 +0800 Subject: [PATCH] Preserve artifacts after interrupted bridge responses --- ...pp_controller_desktop_runtime_helpers.dart | 32 +++++++++++++++++++ ...app_controller_desktop_thread_actions.dart | 16 ++++++---- .../assistant_execution_target_test.dart | 23 ++++++++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 51cad4cd..e8d1a8f0 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -366,6 +366,38 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return false; } + Future> recoverGatewayFailureArtifactPathsInternal( + String sessionKey, + Object error, + ) async { + if (interruptedAcpHttpTransportCodeInternal(error) != + 'ACP_HTTP_CONNECTION_CLOSED') { + return const []; + } + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final thread = taskThreadForSessionInternal(normalizedSessionKey); + if (thread == null || + thread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { + return const []; + } + final root = Directory(thread.workspaceBinding.workspacePath); + try { + final policy = await _loadArtifactSyncPolicyInternal( + root, + thread.selectedSkillKeys, + ); + return _workspaceArtifactPathsModifiedSinceInternal( + root, + thread.lifecycleState.lastRunAtMs, + policy, + ); + } catch (_) { + return const []; + } + } + String jsonLikeTextForDiagnosticsInternal(Object? value) { try { return jsonEncode(value); diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 3ecd40a1..d0e3422d 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -626,7 +626,7 @@ extension AppControllerDesktopThreadActions on AppController { clearAiGatewayStreamingTextInternal(sessionKey); return; } - applyGatewayChatFailureInternal( + await applyGatewayChatFailureInternal( sessionKey: sessionKey, target: target, error: error, @@ -1087,7 +1087,7 @@ extension AppControllerDesktopThreadActions on AppController { ); } catch (error) { if (!disposedInternal) { - applyGatewayChatFailureInternal( + await applyGatewayChatFailureInternal( sessionKey: turn.sessionKey, target: turn.target, error: error, @@ -1252,13 +1252,15 @@ extension AppControllerDesktopThreadActions on AppController { await persistGoTaskArtifactsForSessionInternal(sessionKey, result); } - void applyGatewayChatFailureInternal({ + Future applyGatewayChatFailureInternal({ required String sessionKey, required AssistantExecutionTarget target, required Object error, - }) { + }) async { clearAiGatewayStreamingTextInternal(sessionKey); final completedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + final recoveredArtifactPaths = + await recoverGatewayFailureArtifactPathsInternal(sessionKey, error); upsertTaskThreadInternal( sessionKey, lifecycleStatus: 'ready', @@ -1266,8 +1268,10 @@ extension AppControllerDesktopThreadActions on AppController { lastResultCode: gatewayFailureResultCodeInternal(error), lastRemoteWorkingDirectory: '', lastArtifactSyncAtMs: completedAtMs, - lastArtifactSyncStatus: 'failed', - lastTaskArtifactRelativePaths: const [], + lastArtifactSyncStatus: recoveredArtifactPaths.isEmpty + ? 'failed' + : 'interrupted', + lastTaskArtifactRelativePaths: recoveredArtifactPaths, clearOpenClawTaskAssociation: true, updatedAtMs: completedAtMs, ); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 5e9cefef..601cefba 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -1370,6 +1370,14 @@ void main() { } }); final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..onExecuteTask = ((request) async { + await Directory( + '${request.workingDirectory}/assets/images', + ).create(recursive: true); + await File( + '${request.workingDirectory}/assets/images/final.v2.png', + ).writeAsBytes([1, 2, 3, 4]); + }) ..updatesBeforeNextOutcome.add( const GoTaskServiceUpdate( sessionId: 'unit-fixture-task-a', @@ -1432,7 +1440,13 @@ void main() { controller .taskThreadForSessionInternal('unit-fixture-task-a') ?.lastArtifactSyncStatus, - 'failed', + 'interrupted', + ); + expect( + controller + .taskThreadForSessionInternal('unit-fixture-task-a') + ?.lastTaskArtifactRelativePaths, + ['assets/images/final.v2.png'], ); await controller.sendChatMessage('follow up'); @@ -1869,6 +1883,11 @@ void main() { expect(fakeGoTaskService.requests.last.resumeSession, isFalse); await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅'); expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅'); + await _waitForThreadLastResultCode( + controller, + 'unit-fixture-task-a', + 'SUCCESS', + ); final thread = controller.taskThreadForSessionInternal( 'unit-fixture-task-a', ); @@ -4185,6 +4204,7 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { final List updatesBeforeNextOutcome = []; final List outcomes = []; + Future Function(GoTaskServiceRequest request)? onExecuteTask; @override Future loadExternalAcpCapabilities({ @@ -4207,6 +4227,7 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { }) async { executeCount += 1; requests.add(request); + await onExecuteTask?.call(request); for (final update in List.from( updatesBeforeNextOutcome, )) {