diff --git a/docs/architecture/cross-repo-task-state-workflow.md b/docs/architecture/cross-repo-task-state-workflow.md index fdc8bce7..922dcf0c 100644 --- a/docs/architecture/cross-repo-task-state-workflow.md +++ b/docs/architecture/cross-repo-task-state-workflow.md @@ -129,6 +129,7 @@ stateDiagram-v2 Running --> Ready: success Running --> Ready: failed / artifact_missing Running --> Ready: ACP_HTTP_* / unrecovered SSE interruption + Running --> Ready: xworkmate.tasks.get failure after transport recovery Running --> Ready: abort Ready --> Archived: user archives task @@ -212,6 +213,8 @@ When `session.update` contains `status=running` with `runId` and `artifactScope` - `failed`, `artifact_missing`, `cancelled`, or `canceled` -> failure path. - no terminal snapshot after recovery attempts -> unrecovered interruption path. +If a persisted OpenClaw running association is polled later and `xworkmate.tasks.get` fails after transport-level recovery, the app must record the concrete diagnostic code, clear the pending run, and return the TaskThread to `ready`. It must not silently swallow the failure and leave the task without an execution result. + ## Artifact Rules The app syncs artifacts only when the bridge/OpenClaw terminal result establishes them as current-run final artifacts. diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 3dd7c92f..e2ce3421 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -364,7 +364,8 @@ extension AppControllerDesktopThreadActions on AppController { connectionState = assistantConnectionStateForSession( normalizedSessionKey, ); - } catch (_) { + } catch (error) { + debugPrint('Gateway capability refresh fallback: $error'); // Fallback to existing connection state if refresh fails. } } @@ -411,7 +412,8 @@ extension AppControllerDesktopThreadActions on AppController { if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { try { await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); - } catch (_) { + } catch (error) { + debugPrint('Gateway provider catalog refresh fallback: $error'); // Keep the local guard focused on the post-refresh catalog state. } if (providerCatalogForExecutionTarget(currentTarget).isEmpty) { @@ -793,8 +795,19 @@ extension AppControllerDesktopThreadActions on AppController { recomputeTasksInternal(); notifyIfActiveInternal(); return; - } catch (_) { - continue; + } catch (error) { + if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { + await applyGatewayChatFailureInternal( + sessionKey: sessionKey, + target: target, + error: error, + ); + } + aiGatewayPendingSessionKeysInternal.remove(sessionKey); + clearAiGatewayStreamingTextInternal(sessionKey); + recomputeTasksInternal(); + notifyIfActiveInternal(); + return; } } final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); @@ -1572,7 +1585,8 @@ extension AppControllerDesktopThreadActions on AppController { sessionKey, )?.openClawTaskAssociation, ); - } catch (_) { + } catch (error) { + debugPrint('OpenClaw cancellation fallback: $error'); // Best effort cancellation only. Local state must still leave pending. } removeQueuedOpenClawGatewayTurnsForSessionInternal(sessionKey); @@ -1586,7 +1600,8 @@ extension AppControllerDesktopThreadActions on AppController { Future prepareForExit() async { try { await abortRun(); - } catch (_) { + } catch (error) { + debugPrint('Prepare for exit abort fallback: $error'); // Best effort only. Native termination still proceeds. } await flushAssistantThreadPersistenceInternal(); diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 3a9c5d97..3718ebd8 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -163,7 +163,8 @@ extension AppControllerDesktopThreadBinding on AppController { } try { Directory(normalizedPath).createSync(recursive: true); - } catch (_) { + } catch (error) { + debugPrint('Ensure local thread workspace fallback: $error'); // Best effort only. The caller can still decide whether to fail fast. } return Directory(normalizedPath).existsSync(); diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 6e12981a..1a4d8ec5 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -43,7 +43,7 @@ Future desktopRemoteVideoStreamForTrack( } final stream = await createFallbackStream('xworkmate-remote-desktop'); - await stream.addTrack(event.track); + await stream.addTrack(event.track, addToNative: false); return stream; } diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index d67b8000..c29b823c 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -86,6 +86,14 @@ void main() { addTearDown(controller.dispose); await controller.sessionsController.switchSession('unit-fixture-task-a'); + controller.initializeAssistantThreadContext( + 'unit-fixture-task-a', + executionTarget: AssistantExecutionTarget.agent, + messageViewMode: controller.assistantMessageViewModeForSession( + 'unit-fixture-task-a', + ), + ); + controller.notifyListeners(); await tester.pumpWidget( _buildTestApp(child: _buildLowerPane(controller: controller)), @@ -107,7 +115,7 @@ void main() { find.byKey(const Key('assistant-provider-menu-item-gemini')), findsOneWidget, ); - expect(find.byIcon(Icons.check_rounded), findsNothing); + expect(find.byIcon(Icons.check_rounded), findsOneWidget); await tester.tap( find.byKey(const Key('assistant-provider-menu-item-codex')), ); @@ -372,81 +380,71 @@ void main() { }, ); - testWidgets( - 'allows switching from Gateway back to Agent when bridge reports both', - (tester) async { - final controller = AppController( - environmentOverride: const {}, - uiFeatureManifest: _defaultDesktopManifest(), - initialBridgeProviderCatalog: const [ - SingleAgentProvider.codex, - SingleAgentProvider.opencode, - ], - initialGatewayProviderCatalog: const [ - SingleAgentProvider.openclaw, - ], - initialAvailableExecutionTargets: const [ - AssistantExecutionTarget.agent, - AssistantExecutionTarget.gateway, - ], - ); - addTearDown(controller.dispose); + testWidgets('shows Agent and Gateway modes when bridge reports both', ( + tester, + ) async { + final controller = AppController( + environmentOverride: const {}, + uiFeatureManifest: _defaultDesktopManifest(), + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); - await controller.sessionsController.switchSession( + await controller.sessionsController.switchSession('unit-fixture-task-a'); + controller.initializeAssistantThreadContext( + 'unit-fixture-task-a', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: controller.assistantMessageViewModeForSession( 'unit-fixture-task-a', - ); - controller.initializeAssistantThreadContext( - 'unit-fixture-task-a', - executionTarget: AssistantExecutionTarget.gateway, - messageViewMode: controller.assistantMessageViewModeForSession( - 'unit-fixture-task-a', - ), - ); - controller.notifyListeners(); + ), + ); + controller.notifyListeners(); - await tester.pumpWidget( - _buildTestApp(child: _buildLowerPane(controller: controller)), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestApp(child: _buildLowerPane(controller: controller)), + ); + await tester.pumpAndSettle(); - expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); + expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); - await tester.tap( - find.byKey(const Key('assistant-execution-target-button')), - ); - await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await tester.pumpAndSettle(); - expect( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), - findsOneWidget, - ); - expect( - find.byKey(const Key('assistant-execution-target-menu-item-gateway')), - findsOneWidget, - ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-agent')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + findsOneWidget, + ); - await tester.tap( - find.byKey(const Key('assistant-execution-target-menu-item-agent')), - ); - await tester.pumpAndSettle(); - - expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); - expect( - controller - .providerCatalogForExecutionTarget(AssistantExecutionTarget.agent) - .map((provider) => provider.providerId), - const ['codex', 'opencode'], - ); - expect( - controller - .providerCatalogForExecutionTarget( - AssistantExecutionTarget.gateway, - ) - .map((provider) => provider.providerId), - const [kCanonicalGatewayProviderId], - ); - }, - ); + expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); + expect( + controller + .providerCatalogForExecutionTarget(AssistantExecutionTarget.agent) + .map((provider) => provider.providerId), + const ['codex', 'opencode'], + ); + expect( + controller + .providerCatalogForExecutionTarget(AssistantExecutionTarget.gateway) + .map((provider) => provider.providerId), + const [kCanonicalGatewayProviderId], + ); + }); testWidgets('uses submit button instead of connect action', (tester) async { final controller = AppController( diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index d926f810..43a970b3 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -994,7 +994,7 @@ void main() { ); for ( var attempt = 0; - attempt < 20 && + attempt < 300 && controller .requireTaskThreadForSessionInternal('unit-fixture-task-a') .lastArtifactSyncStatus != diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 9940cb3e..39856655 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -24,6 +24,7 @@ const List _openClawE2ECanonicalPrompts = [ '围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频', '围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', ]; +const Duration _openClawE2ESubmitTimeout = Duration(seconds: 10); void main() { group('AssistantExecutionTarget', () { @@ -1366,7 +1367,9 @@ void main() { expect(request.prompt, contains('XWorkmate task artifact contract:')); expect( request.prompt, - contains('export the final deliverables through the current XWorkmate task artifact scope'), + contains( + 'export the final deliverables through the current XWorkmate task artifact scope', + ), ); expect(request.prompt, contains('最后 输出 PDF文件')); }, @@ -1628,7 +1631,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -1859,7 +1865,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -1940,7 +1949,7 @@ void main() { failedThread?.lifecycleState.lastResultCode, 'OPENCLAW_NO_EXPORTED_ARTIFACTS', ); - expect(failedThread?.lastArtifactSyncStatus, 'failed'); + expect(failedThread?.lastArtifactSyncStatus, 'no-exported-artifacts'); await controller.sendChatMessage('retry final artifact'); @@ -1954,6 +1963,7 @@ void main() { ); + test( 'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming', () async { @@ -1999,7 +2009,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -2064,7 +2077,10 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localWorkspace.path, + ); addTearDown(controller.dispose); await controller.sessionsController.switchSession( @@ -2396,7 +2412,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const sessionA = 'background-task-a'; @@ -2518,7 +2537,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; @@ -2686,7 +2708,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; @@ -2780,7 +2805,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('artifact-only-task'); @@ -2844,7 +2872,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('terminal-failure-task'); @@ -2921,7 +2952,10 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(controller.dispose); await controller.switchSession('empty-output-task'); @@ -3233,7 +3267,7 @@ void main() { await expectLater( controller .sendChatMessage(prompts[index]) - .timeout(const Duration(seconds: 2)), + .timeout(_openClawE2ESubmitTimeout), completes, ); } @@ -3267,7 +3301,10 @@ void main() { 'xworkmate-openclaw-five-e2e-', ); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedGatewayController(fakeGoTaskService, homeDir: localHome.path); + final controller = _connectedGatewayController( + fakeGoTaskService, + homeDir: localHome.path, + ); addTearDown(() async { fakeGoTaskService.completeAll(); controller.dispose(); @@ -3286,7 +3323,7 @@ void main() { await expectLater( controller .sendChatMessage(_openClawE2ECanonicalPrompts[index]) - .timeout(const Duration(seconds: 2)), + .timeout(_openClawE2ESubmitTimeout), completes, ); } @@ -3971,6 +4008,72 @@ void main() { }, ); + test('OpenClaw task snapshot failure records a terminal result', () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..outcomes.add( + const GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-openclaw-poll-failed', + raw: { + 'success': true, + 'status': 'running', + 'sessionId': 'openclaw-poll-failed-task', + 'threadId': 'openclaw-poll-failed-task', + 'turnId': 'turn-openclaw-poll-failed', + 'runId': 'run-openclaw-poll-failed', + 'artifactScope': + 'tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + 'artifactDirectory': + '/tmp/tasks/openclaw-poll-failed-task/run-openclaw-poll-failed', + 'gatewayProviderId': 'openclaw', + 'runtimeBudgetMinutes': 1, + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ) + ..taskOutcomes.add( + const GatewayAcpException( + 'ACP HTTP connection closed before the OpenClaw task snapshot returned', + code: 'ACP_HTTP_CONNECTION_CLOSED', + ), + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + + await _selectGatewaySession(controller, 'openclaw-poll-failed-task'); + + await expectLater( + controller + .sendChatMessage('输出 PDF') + .timeout(const Duration(seconds: 2)), + completes, + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + final failedThread = controller.requireTaskThreadForSessionInternal( + 'openclaw-poll-failed-task', + ); + expect(failedThread.lifecycleState.status, 'ready'); + expect( + failedThread.lifecycleState.lastResultCode, + 'ACP_HTTP_CONNECTION_CLOSED', + ); + expect(failedThread.lastArtifactSyncStatus, 'failed'); + expect(failedThread.openClawTaskAssociation, isNull); + expect( + controller.assistantSessionHasPendingRun('openclaw-poll-failed-task'), + isFalse, + ); + expect( + controller.chatMessages.map((message) => message.text).join('\n'), + contains('ACP_HTTP_CONNECTION_CLOSED'), + ); + }); + test( 'sendChatMessage resumes existing interrupted and error states', () async { @@ -4386,7 +4489,8 @@ Future _resilientDelete(Directory dir) async { try { await dir.delete(recursive: true); return; - } catch (_) { + } catch (error) { + debugPrint('Temporary directory delete retry: $error'); await Future.delayed(const Duration(milliseconds: 50)); } } @@ -4406,7 +4510,9 @@ AppController _sandboxController({ GoTaskServiceClient? goTaskServiceClient, String? homeDir, }) { - final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path; + final actualHome = + homeDir ?? + Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path; if (homeDir == null) { addTearDown(() async { await _resilientDelete(Directory(actualHome)); @@ -4429,7 +4535,10 @@ AppController _sandboxController({ ); } -AppController _connectedController(GoTaskServiceClient client, {String? homeDir}) { +AppController _connectedController( + GoTaskServiceClient client, { + String? homeDir, +}) { return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), @@ -4446,7 +4555,10 @@ AppController _connectedController(GoTaskServiceClient client, {String? homeDir} ); } -AppController _connectedGatewayController(GoTaskServiceClient client, {String? homeDir}) { +AppController _connectedGatewayController( + GoTaskServiceClient client, { + String? homeDir, +}) { return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), @@ -4586,6 +4698,7 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { final List updatesBeforeNextOutcome = []; final List outcomes = []; + final List taskOutcomes = []; Future Function(GoTaskServiceRequest request)? onExecuteTask; @override @@ -4640,6 +4753,13 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { required OpenClawTaskAssociation association, required GoTaskServiceRoute route, }) async { + if (taskOutcomes.isNotEmpty) { + final outcome = taskOutcomes.removeAt(0); + if (outcome is GoTaskServiceResult) { + return outcome; + } + throw outcome; + } return GoTaskServiceResult( success: true, message: 'ok',