fix(gateway): harden OpenClaw task recovery tests

This commit is contained in:
Haitao Pan 2026-06-26 18:30:34 +08:00
parent da3a654ab4
commit c89ffb51ed
2 changed files with 169 additions and 4 deletions

View File

@ -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;
}

View File

@ -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, <String>[
'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: <String, dynamic>{},
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: <String, dynamic>{
'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<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
final List<String> cancelledSessionIds = <String>[];
final Map<String, Completer<GoTaskServiceResult>> _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