From c89ffb51ed929987539f54c79414da529a4bf467 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 26 Jun 2026 18:30:34 +0800 Subject: [PATCH] fix(gateway): harden OpenClaw task recovery tests --- ...pp_controller_desktop_runtime_helpers.dart | 14 +- .../assistant_execution_target_test.dart | 159 +++++++++++++++++- 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 684b169a..481e448b 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1008,8 +1008,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final budget = kOpenClawRunningPollBudgets[taskLoadClass.trim().toLowerCase()] ?? kOpenClawRunningPollDefaultBudget; - final limitMs = - (budget + kOpenClawRunningPollGrace).inMilliseconds.toDouble(); + final limitMs = (budget + kOpenClawRunningPollGrace).inMilliseconds + .toDouble(); final currentMs = nowMs ?? DateTime.now().millisecondsSinceEpoch.toDouble(); return currentMs - anchorMs >= limitMs; } @@ -1450,6 +1450,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { final normalizedHost = endpoint.host.trim().toLowerCase(); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); final bridgeHost = bridgeEndpoint?.host.trim().toLowerCase() ?? ''; + final isLoopback = + normalizedHost == '127.0.0.1' || + normalizedHost == 'localhost' || + normalizedHost == '::1'; final accountSyncState = settingsControllerInternal.accountSyncState; final managedBridgeReady = settingsControllerInternal.accountSessionTokenInternal @@ -1457,7 +1461,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController { .isNotEmpty && accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true; - if (bridgeHost.isEmpty || normalizedHost != bridgeHost) { + if (bridgeHost.isEmpty || (normalizedHost != bridgeHost && !isLoopback)) { return null; } @@ -1472,6 +1476,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { return _normalizeAuthorizationHeaderInternal(manualBridgeToken); } + final envBridgeToken = _runtimeBridgeAuthEnvTokenInternal(); + if (envBridgeToken != null && envBridgeToken.isNotEmpty) { + return _normalizeAuthorizationHeaderInternal(envBridgeToken); + } return null; } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 1e3cc5b4..6cd58e74 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -3655,6 +3655,59 @@ void main() { }, ); + test( + 'abortRun remains locally terminal when bridge cancel fails', + () async { + final fakeGoTaskService = _BlockingGoTaskServiceClient( + failCancel: true, + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(() { + fakeGoTaskService.completeAll(); + controller.dispose(); + }); + + await _selectGatewaySession(controller, 'cancel-fails-openclaw'); + final pendingFuture = controller.sendChatMessage('stop despite cancel'); + await _waitForThreadLifecycleStatus( + controller, + 'cancel-fails-openclaw', + 'running', + ); + + await controller.abortRun(); + + expect(fakeGoTaskService.cancelledSessionIds, [ + 'cancel-fails-openclaw', + ]); + expect( + controller.assistantSessionHasPendingRun('cancel-fails-openclaw'), + isFalse, + ); + expect( + controller + .requireTaskThreadForSessionInternal('cancel-fails-openclaw') + .lifecycleState + .lastResultCode, + 'aborted', + ); + + fakeGoTaskService.complete( + 'cancel-fails-openclaw', + const GoTaskServiceResult( + success: true, + message: 'late cancel-failed result', + turnId: 'turn-cancel-fails', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + await pendingFuture; + }, + ); + test( 'continueAssistantTaskInternal requeues a stopped OpenClaw task without clearing queued work', () async { @@ -4104,6 +4157,103 @@ void main() { ); }); + test('OpenClaw running poll times out and clears pending state', () async { + final staleStartedAtMs = DateTime.now() + .subtract(kOpenClawRunningPollBudgets['short_task']!) + .subtract(kOpenClawRunningPollGrace) + .subtract(const Duration(seconds: 1)) + .millisecondsSinceEpoch + .toDouble(); + final fakeGoTaskService = _RecordingGoTaskServiceClient() + ..taskOutcomes.add( + GoTaskServiceResult( + success: true, + message: '', + turnId: 'turn-openclaw-running-timeout', + raw: { + 'success': true, + 'status': 'running', + 'sessionId': 'openclaw-running-timeout', + 'threadId': 'openclaw-running-timeout', + 'appThreadKey': 'openclaw-running-timeout', + 'openclawSessionKey': 'agent:main:openclaw-running-timeout', + 'turnId': 'turn-openclaw-running-timeout', + 'runId': 'run-openclaw-running-timeout', + 'artifactScope': + 'tasks/agent:main:openclaw-running-timeout/run-openclaw-running-timeout', + 'artifactDirectory': + '/tmp/tasks/agent:main:openclaw-running-timeout/run-openclaw-running-timeout', + 'gatewayProviderId': 'openclaw', + 'startedAtMs': staleStartedAtMs, + 'taskLoadClass': 'short_task', + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + const association = OpenClawTaskAssociation( + sessionId: 'openclaw-running-timeout', + threadId: 'openclaw-running-timeout', + turnId: 'turn-openclaw-running-timeout', + runId: 'run-openclaw-running-timeout', + artifactScope: + 'tasks/agent:main:openclaw-running-timeout/run-openclaw-running-timeout', + artifactDirectory: + '/tmp/tasks/agent:main:openclaw-running-timeout/run-openclaw-running-timeout', + gatewayProviderId: 'openclaw', + startedAtMs: 0, + status: 'running', + appThreadKey: 'openclaw-running-timeout', + openclawSessionKey: 'agent:main:openclaw-running-timeout', + taskLoadClass: 'short_task', + ); + controller.upsertTaskThreadInternal( + 'openclaw-running-timeout', + executionTarget: AssistantExecutionTarget.gateway, + selectedProvider: SingleAgentProvider.openclaw, + selectedProviderSource: ThreadSelectionSource.explicit, + lifecycleStatus: 'running', + lastResultCode: 'running', + openClawTaskAssociation: association, + ); + controller.aiGatewayPendingSessionKeysInternal.add( + 'openclaw-running-timeout', + ); + + await controller + .pollOpenClawTaskAssociationInternal( + sessionKey: 'openclaw-running-timeout', + target: AssistantExecutionTarget.gateway, + association: association, + ) + .timeout(const Duration(seconds: 1)); + + final thread = controller.requireTaskThreadForSessionInternal( + 'openclaw-running-timeout', + ); + expect(thread.lifecycleState.status, 'interrupted'); + expect( + thread.lifecycleState.lastResultCode, + kOpenClawRunningPollTimeoutCode, + ); + expect(thread.lastArtifactSyncStatus, 'interrupted'); + expect(thread.openClawTaskAssociation, isNull); + expect( + controller.assistantSessionHasPendingRun('openclaw-running-timeout'), + isFalse, + ); + expect( + controller + .localSessionMessagesInternal['openclaw-running-timeout'] + ?.last + .text, + contains(kOpenClawRunningPollTimeoutCode), + ); + }); + test( 'OpenClaw terminal task-scope snapshot without artifacts keeps polling', () async { @@ -4944,9 +5094,10 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient { } class _BlockingGoTaskServiceClient implements GoTaskServiceClient { - _BlockingGoTaskServiceClient({this.onRequest}); + _BlockingGoTaskServiceClient({this.onRequest, this.failCancel = false}); final void Function(GoTaskServiceRequest request)? onRequest; + final bool failCancel; final List requests = []; final List cancelledSessionIds = []; final Map> _pending = @@ -5056,6 +5207,12 @@ class _BlockingGoTaskServiceClient implements GoTaskServiceClient { OpenClawTaskAssociation? association, }) async { cancelledSessionIds.add(sessionId); + if (failCancel) { + throw const GatewayAcpException( + 'cancel failed', + code: 'OPENCLAW_GATEWAY_SOCKET_CLOSED', + ); + } } @override